mirror of
https://github.com/railwayapp/cli
synced 2026-04-21 14:07:23 +00:00
feat(ssh): native SSH and key management commands (#809)
* feat(ssh): add native SSH using serviceInstanceId Adds support for native SSH connections using the new flow: `ssh <serviceInstanceId>@ssh.railway.com` Changes: - Add native SSH module that uses serviceInstanceId for routing - Add SSH key management (auto-detect local keys, register with Railway) - Add GraphQL queries for SSH keys and service instances - Native SSH is used by default when local SSH keys exist - Add --relay flag to force WebSocket fallback mode The native SSH flow: 1. Checks for local SSH keys (~/.ssh/id_*.pub) 2. Ensures key is registered with Railway (prompts or auto-registers) 3. Gets serviceInstanceId via GraphQL 4. Runs ssh <serviceInstanceId>@ssh.railway.com * fix(ssh): use relay mode for command execution Railway's SSH proxy doesn't forward exec commands through the QUIC tunnel, so command execution requires relay mode. Native SSH is now only used for interactive shells where it works correctly. - Commands use relay mode (railway ssh <command>) - Interactive shells use native SSH (railway ssh) - Tmux sessions continue using relay mode * feat(ssh): show which SSH key is being used Display the SSH key path when connecting to help users understand which key is being used for authentication. * refactor(ssh): clean up unused code and fix clippy warnings - Remove unused run_native_ssh_with_tmux (exec commands not supported) - Remove unused find_registered_local_key and ensure_ssh_key_registered - Fix &PathBuf -> &Path clippy warnings - Keep tmux sessions using WebSocket relay since SSH exec isn't supported * refactor(ssh): scan ~/.ssh/ for all .pub files instead of hardcoding - Dynamically scans ~/.ssh/ directory for all .pub files - Filters to supported key types (ed25519, ecdsa, rsa, dss) - Sorts by key type preference (ed25519 first) * feat(ssh): add key management commands - Add `railway ssh keys` to list registered SSH keys - Add `railway ssh keys add` to register a local key - Add `railway ssh keys remove` to delete a registered key - Shows which local keys match registered keys - Supports 2FA for key deletion * feat(ssh): add SSH key management commands Add `railway ssh keys` command with subcommands: - `list` (default): Show registered, GitHub, and local SSH keys - `add`: Register a local SSH key with Railway - `remove`: Delete a registered SSH key - `github`: Import SSH keys from GitHub account Also removes unused LogFormat::Simple variant. * fix(ssh): format command hints on separate lines * refactor(ssh): use direct serviceInstance query instead of listing all * refactor(ssh): make native SSH opt-in via --native flag WebSocket relay is now the default SSH method. Users can opt into native SSH with --native flag when they want direct SSH connections. * feat(ssh): add command execution support to native SSH mode Pass command arguments to the ssh binary when using --native flag, enabling commands like `railway ssh --native echo hello`. * fix(ssh): improve 2FA handling and key registration UX - Use validateTwoFactor mutation before delete instead of passing code to sshPublicKeyDelete - Update GraphQL mutation to remove unused code parameter - Fix 2FA error detection to be case-insensitive and match "two factor" string - Replace silent non-TTY key registration with explicit error directing user to register manually - Simplify run_native_ssh to always inherit stdio regardless of command mode * fix(ssh): require user to select which SSH key to register * style: run cargo fmt
This commit is contained in:
parent
b24f48db41
commit
81cd7d0ada
13 changed files with 975 additions and 96 deletions
442
src/commands/ssh/keys.rs
Normal file
442
src/commands/ssh/keys.rs
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
use std::fmt;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::Parser;
|
||||
use is_terminal::IsTerminal;
|
||||
|
||||
use crate::client::GQLClient;
|
||||
use crate::config::Configs;
|
||||
use crate::controllers::ssh_keys::{
|
||||
LocalSshKey, compute_fingerprint_from_pubkey, delete_ssh_key, find_local_ssh_keys,
|
||||
get_github_ssh_keys, get_registered_ssh_keys, register_ssh_key,
|
||||
};
|
||||
use crate::gql::queries::git_hub_ssh_keys::GitHubSshKeysGitHubSshKeys;
|
||||
use crate::gql::queries::ssh_public_keys::SshPublicKeysSshPublicKeysEdgesNode;
|
||||
use crate::util::prompt::{prompt_options, prompt_text};
|
||||
|
||||
/// Wrapper for LocalSshKey to implement Display for prompts
|
||||
struct LocalKeyOption(LocalSshKey);
|
||||
|
||||
impl fmt::Display for LocalKeyOption {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{} ({})",
|
||||
self.0
|
||||
.path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy(),
|
||||
self.0.fingerprint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper for registered SSH key to implement Display for prompts
|
||||
struct RegisteredKeyOption(SshPublicKeysSshPublicKeysEdgesNode);
|
||||
|
||||
impl fmt::Display for RegisteredKeyOption {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{} ({})", self.0.name, self.0.fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper for GitHub SSH key to implement Display for prompts
|
||||
struct GitHubKeyOption(GitHubSshKeysGitHubSshKeys);
|
||||
|
||||
impl fmt::Display for GitHubKeyOption {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let parts: Vec<&str> = self.0.key.split_whitespace().collect();
|
||||
let key_type = parts.first().unwrap_or(&"unknown");
|
||||
let fingerprint = compute_fingerprint_from_pubkey(&self.0.key).unwrap_or_default();
|
||||
if fingerprint.is_empty() {
|
||||
write!(f, "{} ({})", self.0.title, key_type)
|
||||
} else {
|
||||
write!(f, "{} ({}) {}", self.0.title, key_type, fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manage SSH keys registered with Railway
|
||||
#[derive(Parser, Clone)]
|
||||
pub struct Args {
|
||||
#[clap(subcommand)]
|
||||
command: Option<Commands>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Clone)]
|
||||
enum Commands {
|
||||
/// List all registered SSH keys
|
||||
#[clap(alias = "ls")]
|
||||
List,
|
||||
|
||||
/// Add/register a local SSH key with Railway
|
||||
Add {
|
||||
/// Path to the public key file (defaults to auto-detect)
|
||||
#[clap(long, short)]
|
||||
key: Option<String>,
|
||||
|
||||
/// Name for the key (defaults to filename)
|
||||
#[clap(long, short)]
|
||||
name: Option<String>,
|
||||
},
|
||||
|
||||
/// Remove a registered SSH key
|
||||
#[clap(alias = "rm", alias = "delete")]
|
||||
Remove {
|
||||
/// Key ID or fingerprint to remove
|
||||
key: Option<String>,
|
||||
|
||||
/// 2FA code (required if 2FA is enabled)
|
||||
#[clap(long = "2fa-code")]
|
||||
two_factor_code: Option<String>,
|
||||
},
|
||||
|
||||
/// Import SSH keys from your GitHub account
|
||||
#[clap(alias = "import")]
|
||||
Github,
|
||||
}
|
||||
|
||||
pub async fn command(args: Args) -> Result<()> {
|
||||
match args.command {
|
||||
Some(Commands::List) | None => list_keys().await,
|
||||
Some(Commands::Add { key, name }) => add_key(key, name).await,
|
||||
Some(Commands::Remove {
|
||||
key,
|
||||
two_factor_code,
|
||||
}) => remove_key(key, two_factor_code).await,
|
||||
Some(Commands::Github) => import_github_keys().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_keys() -> Result<()> {
|
||||
let configs = Configs::new()?;
|
||||
let client = GQLClient::new_authorized(&configs)?;
|
||||
|
||||
let registered_keys = get_registered_ssh_keys(&client, &configs).await?;
|
||||
let local_keys = find_local_ssh_keys()?;
|
||||
let github_keys = get_github_ssh_keys(&client, &configs)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Show registered Railway keys
|
||||
if !registered_keys.is_empty() {
|
||||
println!("Registered SSH Keys:");
|
||||
|
||||
for key in ®istered_keys {
|
||||
let local_match = local_keys.iter().find(|l| l.fingerprint == key.fingerprint);
|
||||
|
||||
// Extract comment/hostname from public key
|
||||
let parts: Vec<&str> = key.public_key.split_whitespace().collect();
|
||||
let key_type = parts.first().unwrap_or(&"");
|
||||
let hostname = parts.get(2).unwrap_or(&"");
|
||||
|
||||
println!(" {}", key.name);
|
||||
println!(" Fingerprint: {}", key.fingerprint);
|
||||
if !key_type.is_empty() {
|
||||
println!(" Type: {}", key_type);
|
||||
}
|
||||
if !hostname.is_empty() {
|
||||
println!(" Hostname: {}", hostname);
|
||||
}
|
||||
if local_match.is_some() {
|
||||
println!(" Source: local (~/.ssh/)");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
// Show GitHub keys
|
||||
if !github_keys.is_empty() {
|
||||
println!("GitHub SSH Keys:");
|
||||
for key in &github_keys {
|
||||
let parts: Vec<&str> = key.key.split_whitespace().collect();
|
||||
let key_type = parts.first().unwrap_or(&"unknown");
|
||||
let hostname = parts.get(2).unwrap_or(&"");
|
||||
let fingerprint = compute_fingerprint_from_pubkey(&key.key).unwrap_or_default();
|
||||
|
||||
// Check if already registered
|
||||
let is_registered = registered_keys.iter().any(|r| r.fingerprint == fingerprint);
|
||||
|
||||
println!(" {}", key.title);
|
||||
if !fingerprint.is_empty() {
|
||||
println!(" Fingerprint: {}", fingerprint);
|
||||
}
|
||||
println!(" Type: {}", key_type);
|
||||
if !hostname.is_empty() {
|
||||
println!(" Hostname: {}", hostname);
|
||||
}
|
||||
if is_registered {
|
||||
println!(" Status: registered");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
let has_unregistered = github_keys.iter().any(|gh| {
|
||||
let fp = compute_fingerprint_from_pubkey(&gh.key).unwrap_or_default();
|
||||
!registered_keys.iter().any(|r| r.fingerprint == fp)
|
||||
});
|
||||
if has_unregistered {
|
||||
println!("Import with:\n railway ssh keys github");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
// Show local keys that aren't registered
|
||||
let unregistered: Vec<_> = local_keys
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
!registered_keys
|
||||
.iter()
|
||||
.any(|r| r.fingerprint == l.fingerprint)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if registered_keys.is_empty() && github_keys.is_empty() {
|
||||
println!("No SSH keys registered with Railway.");
|
||||
println!();
|
||||
println!("Add a key with: railway ssh keys add");
|
||||
println!("Or register at: https://railway.com/account/ssh-keys");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !unregistered.is_empty() {
|
||||
println!("Local Keys (not registered):");
|
||||
for key in unregistered {
|
||||
// Extract hostname from public key
|
||||
let parts: Vec<&str> = key.public_key.split_whitespace().collect();
|
||||
let hostname = parts.get(2).unwrap_or(&"");
|
||||
|
||||
println!(
|
||||
" {}",
|
||||
key.path.file_name().unwrap_or_default().to_string_lossy()
|
||||
);
|
||||
println!(" Fingerprint: {}", key.fingerprint);
|
||||
println!(" Type: {}", key.key_type);
|
||||
if !hostname.is_empty() {
|
||||
println!(" Hostname: {}", hostname);
|
||||
}
|
||||
println!(" Path: {}", key.path.display());
|
||||
println!();
|
||||
}
|
||||
println!("Add with:\n railway ssh keys add");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_key(key_path: Option<String>, name: Option<String>) -> Result<()> {
|
||||
let configs = Configs::new()?;
|
||||
let client = GQLClient::new_authorized(&configs)?;
|
||||
|
||||
let local_keys = find_local_ssh_keys()?;
|
||||
if local_keys.is_empty() {
|
||||
bail!(
|
||||
"No SSH keys found in ~/.ssh/\n\n\
|
||||
Generate one with:\n ssh-keygen -t ed25519\n\n\
|
||||
Then run this command again."
|
||||
);
|
||||
}
|
||||
|
||||
let registered_keys = get_registered_ssh_keys(&client, &configs).await?;
|
||||
|
||||
// Filter to unregistered keys
|
||||
let unregistered: Vec<_> = local_keys
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
!registered_keys
|
||||
.iter()
|
||||
.any(|r| r.fingerprint == l.fingerprint)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if unregistered.is_empty() {
|
||||
println!("All local SSH keys are already registered with Railway.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Select key to add
|
||||
let key_to_add = if let Some(path) = key_path {
|
||||
// Find by path
|
||||
local_keys
|
||||
.iter()
|
||||
.find(|k| k.path.to_string_lossy().contains(&path))
|
||||
.ok_or_else(|| anyhow::anyhow!("Key not found: {}", path))?
|
||||
.clone()
|
||||
} else if unregistered.len() == 1 {
|
||||
unregistered[0].clone()
|
||||
} else if std::io::stdin().is_terminal() {
|
||||
let options: Vec<LocalKeyOption> = unregistered
|
||||
.into_iter()
|
||||
.map(|k| LocalKeyOption(k.clone()))
|
||||
.collect();
|
||||
let selected = prompt_options("Select a key to register", options)?;
|
||||
selected.0
|
||||
} else {
|
||||
// Non-interactive: use first (preferred) key
|
||||
unregistered[0].clone()
|
||||
};
|
||||
|
||||
// Determine name
|
||||
let key_name = name.unwrap_or_else(|| {
|
||||
key_to_add
|
||||
.path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("ssh-key")
|
||||
.to_string()
|
||||
});
|
||||
|
||||
println!(
|
||||
"Registering key: {} ({})",
|
||||
key_to_add.path.display(),
|
||||
key_to_add.fingerprint
|
||||
);
|
||||
|
||||
register_ssh_key(&client, &configs, &key_name, &key_to_add.public_key).await?;
|
||||
|
||||
println!("SSH key '{}' registered successfully!", key_name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_key(key: Option<String>, two_factor_code: Option<String>) -> Result<()> {
|
||||
let configs = Configs::new()?;
|
||||
let client = GQLClient::new_authorized(&configs)?;
|
||||
|
||||
let registered_keys = get_registered_ssh_keys(&client, &configs).await?;
|
||||
|
||||
if registered_keys.is_empty() {
|
||||
println!("No SSH keys registered with Railway.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Select key to remove
|
||||
let key_to_remove = if let Some(key_id) = key {
|
||||
// Find by ID or fingerprint
|
||||
registered_keys
|
||||
.into_iter()
|
||||
.find(|k| k.id == key_id || k.fingerprint == key_id || k.name == key_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Key not found: {}", key_id))?
|
||||
} else if std::io::stdin().is_terminal() {
|
||||
let options: Vec<RegisteredKeyOption> = registered_keys
|
||||
.into_iter()
|
||||
.map(RegisteredKeyOption)
|
||||
.collect();
|
||||
let selected = prompt_options("Select a key to remove", options)?;
|
||||
selected.0
|
||||
} else {
|
||||
bail!("Key ID or fingerprint required in non-interactive mode");
|
||||
};
|
||||
|
||||
// Get 2FA code if needed and not provided
|
||||
let code = if two_factor_code.is_some() {
|
||||
two_factor_code
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
println!(
|
||||
"Removing key: {} ({})",
|
||||
key_to_remove.name, key_to_remove.fingerprint
|
||||
);
|
||||
|
||||
match delete_ssh_key(&client, &configs, &key_to_remove.id, code).await {
|
||||
Ok(true) => {
|
||||
println!("SSH key '{}' removed successfully!", key_to_remove.name);
|
||||
Ok(())
|
||||
}
|
||||
Ok(false) => {
|
||||
bail!("Failed to remove SSH key");
|
||||
}
|
||||
Err(e) => {
|
||||
// Check if it's a 2FA error
|
||||
let err_str = e.to_string().to_lowercase();
|
||||
if err_str.contains("two factor")
|
||||
|| err_str.contains("two-factor")
|
||||
|| err_str.contains("2fa")
|
||||
|| err_str.contains("verification")
|
||||
{
|
||||
if std::io::stdin().is_terminal() {
|
||||
let code = prompt_text("Enter 2FA code")?;
|
||||
delete_ssh_key(&client, &configs, &key_to_remove.id, Some(code)).await?;
|
||||
println!("SSH key '{}' removed successfully!", key_to_remove.name);
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("2FA code required. Use --2fa-code option.");
|
||||
}
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn import_github_keys() -> Result<()> {
|
||||
let configs = Configs::new()?;
|
||||
let client = GQLClient::new_authorized(&configs)?;
|
||||
|
||||
println!("Fetching SSH keys from GitHub...");
|
||||
|
||||
let github_keys = get_github_ssh_keys(&client, &configs).await?;
|
||||
|
||||
if github_keys.is_empty() {
|
||||
println!("No SSH keys found in your GitHub account.");
|
||||
println!();
|
||||
println!("Add SSH keys to GitHub at: https://github.com/settings/keys");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let registered_keys = get_registered_ssh_keys(&client, &configs).await?;
|
||||
|
||||
// Filter to keys not already registered (compare by fingerprint)
|
||||
let unregistered: Vec<_> = github_keys
|
||||
.iter()
|
||||
.filter(|gh| {
|
||||
let gh_fingerprint = compute_fingerprint_from_pubkey(&gh.key).unwrap_or_default();
|
||||
!registered_keys
|
||||
.iter()
|
||||
.any(|r| r.fingerprint == gh_fingerprint)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if unregistered.is_empty() {
|
||||
println!("All GitHub SSH keys are already registered with Railway.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"Found {} GitHub key(s) not yet registered:",
|
||||
unregistered.len()
|
||||
);
|
||||
for key in &unregistered {
|
||||
let parts: Vec<&str> = key.key.split_whitespace().collect();
|
||||
let key_type = parts.first().unwrap_or(&"unknown");
|
||||
let fingerprint = compute_fingerprint_from_pubkey(&key.key).unwrap_or_default();
|
||||
println!(" - {} ({}) {}", key.title, key_type, fingerprint);
|
||||
}
|
||||
println!();
|
||||
|
||||
// Select key to import
|
||||
let key_to_import = if unregistered.len() == 1 {
|
||||
unregistered[0].clone()
|
||||
} else if std::io::stdin().is_terminal() {
|
||||
let options: Vec<GitHubKeyOption> = unregistered
|
||||
.into_iter()
|
||||
.map(|k| GitHubKeyOption(k.clone()))
|
||||
.collect();
|
||||
let selected = prompt_options("Select a key to import", options)?;
|
||||
selected.0
|
||||
} else {
|
||||
// Non-interactive: import first key
|
||||
unregistered[0].clone()
|
||||
};
|
||||
|
||||
println!("Importing key: {}", key_to_import.title);
|
||||
|
||||
register_ssh_key(&client, &configs, &key_to_import.title, &key_to_import.key).await?;
|
||||
|
||||
println!("SSH key '{}' imported successfully!", key_to_import.title);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -18,28 +18,33 @@ pub const SSH_MAX_CONNECT_ATTEMPTS: u32 = 3;
|
|||
pub const SSH_MAX_CONNECT_ATTEMPTS_PERSISTENT: u32 = 20;
|
||||
|
||||
mod common;
|
||||
mod keys;
|
||||
mod native;
|
||||
mod platform;
|
||||
|
||||
use common::*;
|
||||
use platform::*;
|
||||
|
||||
/// Connect to a service via SSH
|
||||
/// Connect to a service via SSH or manage SSH keys
|
||||
#[derive(Parser, Clone)]
|
||||
pub struct Args {
|
||||
#[clap(subcommand)]
|
||||
subcommand: Option<Commands>,
|
||||
|
||||
/// Project to connect to (defaults to linked project)
|
||||
#[clap(short, long)]
|
||||
project: Option<String>,
|
||||
|
||||
#[clap(short, long)]
|
||||
/// Service to connect to (defaults to linked service)
|
||||
#[clap(short, long)]
|
||||
service: Option<String>,
|
||||
|
||||
#[clap(short, long)]
|
||||
/// Environment to connect to (defaults to linked environment)
|
||||
#[clap(short, long)]
|
||||
environment: Option<String>,
|
||||
|
||||
#[clap(short, long)]
|
||||
/// Deployment instance ID to connect to (defaults to first active instance)
|
||||
#[clap(short, long)]
|
||||
#[arg(long = "deployment-instance", value_name = "deployment-instance-id")]
|
||||
deployment_instance: Option<String>,
|
||||
|
||||
|
|
@ -47,16 +52,87 @@ pub struct Args {
|
|||
#[clap(long, value_name = "SESSION_NAME", default_missing_value = "railway", num_args = 0..=1)]
|
||||
session: Option<String>,
|
||||
|
||||
/// Use native SSH client (requires SSH key setup)
|
||||
#[clap(long)]
|
||||
native: bool,
|
||||
|
||||
/// Command to execute instead of starting an interactive shell
|
||||
#[clap(trailing_var_arg = true)]
|
||||
command: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Clone)]
|
||||
enum Commands {
|
||||
/// Manage SSH keys registered with Railway
|
||||
Keys(keys::Args),
|
||||
}
|
||||
|
||||
pub async fn command(args: Args) -> Result<()> {
|
||||
// Handle subcommands first
|
||||
if let Some(Commands::Keys(keys_args)) = args.subcommand {
|
||||
return keys::command(keys_args).await;
|
||||
}
|
||||
|
||||
let configs = Configs::new()?;
|
||||
let client = GQLClient::new_authorized(&configs)?;
|
||||
|
||||
let params = get_ssh_connect_params(args.clone(), &configs, &client).await?;
|
||||
// Use native SSH only when explicitly requested with --native flag
|
||||
if args.native {
|
||||
// Native SSH doesn't support tmux sessions (requires relay for session management)
|
||||
if args.session.is_some() {
|
||||
anyhow::bail!(
|
||||
"Native SSH does not support tmux sessions.\n\
|
||||
Remove the --native flag to use --session via the relay."
|
||||
);
|
||||
}
|
||||
if !native::native_ssh_available() {
|
||||
anyhow::bail!(
|
||||
"No SSH keys found in ~/.ssh/\n\n\
|
||||
Generate one with:\n ssh-keygen -t ed25519\n\n\
|
||||
Then run this command again."
|
||||
);
|
||||
}
|
||||
return command_native(args, &configs, &client).await;
|
||||
}
|
||||
|
||||
// Use WebSocket relay by default
|
||||
command_relay(args, &configs, &client).await
|
||||
}
|
||||
|
||||
/// Native SSH command using ssh <serviceInstanceId>@ssh.railway.com
|
||||
async fn command_native(args: Args, configs: &Configs, client: &reqwest::Client) -> Result<()> {
|
||||
// Ensure SSH key is registered
|
||||
native::ensure_ssh_key(client, configs).await?;
|
||||
|
||||
// Get connection params to resolve service instance ID
|
||||
let params = get_ssh_connect_params(args.clone(), configs, client).await?;
|
||||
|
||||
// Get the service instance ID
|
||||
let service_instance_id = native::get_service_instance_id(
|
||||
client,
|
||||
configs,
|
||||
¶ms.environment_id,
|
||||
¶ms.service_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Run native SSH with optional command
|
||||
let command = if args.command.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(args.command.as_slice())
|
||||
};
|
||||
let exit_code = native::run_native_ssh(&service_instance_id, command)?;
|
||||
if exit_code != 0 {
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// WebSocket relay command (legacy/fallback)
|
||||
async fn command_relay(args: Args, configs: &Configs, client: &reqwest::Client) -> Result<()> {
|
||||
let params = get_ssh_connect_params(args.clone(), configs, client).await?;
|
||||
|
||||
if let Some(name) = args.session {
|
||||
run_persistent_session(¶ms, name).await?;
|
||||
|
|
|
|||
152
src/commands/ssh/native.rs
Normal file
152
src/commands/ssh/native.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
use anyhow::{Context, Result, bail};
|
||||
use is_terminal::IsTerminal;
|
||||
use reqwest::Client;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::client::post_graphql;
|
||||
use crate::config::Configs;
|
||||
use crate::controllers::ssh_keys::{find_local_ssh_keys, register_ssh_key};
|
||||
use crate::gql::queries::{ServiceInstance, service_instance};
|
||||
use crate::util::prompt::{prompt_confirm_with_default, prompt_select};
|
||||
|
||||
const SSH_HOST: &str = "ssh.railway.com";
|
||||
|
||||
/// Get the service instance ID for a service in an environment
|
||||
pub async fn get_service_instance_id(
|
||||
client: &Client,
|
||||
configs: &Configs,
|
||||
environment_id: &str,
|
||||
service_id: &str,
|
||||
) -> Result<String> {
|
||||
let vars = service_instance::Variables {
|
||||
environment_id: environment_id.to_string(),
|
||||
service_id: service_id.to_string(),
|
||||
};
|
||||
|
||||
let response =
|
||||
post_graphql::<ServiceInstance, _>(client, configs.get_backboard(), vars).await?;
|
||||
|
||||
Ok(response.service_instance.id)
|
||||
}
|
||||
|
||||
/// Ensure SSH key is registered, prompting user if needed
|
||||
pub async fn ensure_ssh_key(client: &Client, configs: &Configs) -> Result<()> {
|
||||
let local_keys = find_local_ssh_keys()?;
|
||||
|
||||
if local_keys.is_empty() {
|
||||
bail!(
|
||||
"No SSH keys found in ~/.ssh/\n\n\
|
||||
Generate one with:\n ssh-keygen -t ed25519\n\n\
|
||||
Then run this command again."
|
||||
);
|
||||
}
|
||||
|
||||
// Check which local keys are registered
|
||||
let registered_keys =
|
||||
crate::controllers::ssh_keys::get_registered_ssh_keys(client, configs).await?;
|
||||
|
||||
// Find a local key that's already registered
|
||||
let registered_local = local_keys.iter().find(|local| {
|
||||
registered_keys
|
||||
.iter()
|
||||
.any(|r| r.fingerprint == local.fingerprint)
|
||||
});
|
||||
|
||||
if let Some(key) = registered_local {
|
||||
// Already registered - just use it
|
||||
eprintln!("Using SSH key: {}", key.path.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// No local key is registered - need to register one
|
||||
if !std::io::stdin().is_terminal() {
|
||||
bail!(
|
||||
"No registered SSH keys found. Register one with:\n railway ssh keys add\n\n\
|
||||
Or import from GitHub:\n railway ssh keys github"
|
||||
);
|
||||
}
|
||||
|
||||
println!("No SSH keys registered with Railway.");
|
||||
|
||||
let key_to_register = if local_keys.len() == 1 {
|
||||
&local_keys[0]
|
||||
} else {
|
||||
// Let the user pick which key to register
|
||||
use std::fmt;
|
||||
struct KeyOption<'a>(&'a crate::controllers::ssh_keys::LocalSshKey);
|
||||
impl fmt::Display for KeyOption<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{} ({})", self.0.path.display(), self.0.fingerprint)
|
||||
}
|
||||
}
|
||||
let options: Vec<KeyOption> = local_keys.iter().map(KeyOption).collect();
|
||||
let selected = prompt_select("Which SSH key would you like to register?", options)?;
|
||||
selected.0
|
||||
};
|
||||
|
||||
println!(
|
||||
"Key: {} ({})",
|
||||
key_to_register.path.display(),
|
||||
key_to_register.fingerprint
|
||||
);
|
||||
println!();
|
||||
|
||||
let should_register = prompt_confirm_with_default("Register this SSH key with Railway?", true)?;
|
||||
|
||||
if !should_register {
|
||||
bail!(
|
||||
"SSH key registration required for native SSH access.\n\
|
||||
You can also register your key at: https://railway.com/account/ssh-keys"
|
||||
);
|
||||
}
|
||||
|
||||
let key_name = key_to_register
|
||||
.path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("ssh-key")
|
||||
.to_string();
|
||||
|
||||
register_ssh_key(client, configs, &key_name, &key_to_register.public_key).await?;
|
||||
|
||||
println!("SSH key registered successfully!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if native SSH is available (local SSH key exists)
|
||||
pub fn native_ssh_available() -> bool {
|
||||
find_local_ssh_keys()
|
||||
.map(|keys| !keys.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Run SSH command with the given service instance ID
|
||||
/// Optionally executes a command instead of starting an interactive shell
|
||||
pub fn run_native_ssh(service_instance_id: &str, command: Option<&[String]>) -> Result<i32> {
|
||||
let target = format!("{}@{}", service_instance_id, SSH_HOST);
|
||||
|
||||
let mut ssh_cmd = Command::new("ssh");
|
||||
ssh_cmd.arg(&target);
|
||||
|
||||
if let Some(cmd_args) = command {
|
||||
// Pass command as SSH args (exec channel)
|
||||
for arg in cmd_args {
|
||||
ssh_cmd.arg(arg);
|
||||
}
|
||||
ssh_cmd.stdin(Stdio::inherit());
|
||||
ssh_cmd.stdout(Stdio::inherit());
|
||||
ssh_cmd.stderr(Stdio::inherit());
|
||||
|
||||
let status = ssh_cmd.status().context("Failed to execute ssh command")?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
} else {
|
||||
// Interactive shell - inherit everything
|
||||
ssh_cmd.stdin(Stdio::inherit());
|
||||
ssh_cmd.stdout(Stdio::inherit());
|
||||
ssh_cmd.stderr(Stdio::inherit());
|
||||
|
||||
let status = ssh_cmd.status().context("Failed to execute ssh command")?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ pub mod local_override;
|
|||
pub mod project;
|
||||
pub mod regions;
|
||||
pub mod service;
|
||||
pub mod ssh_keys;
|
||||
pub mod terminal;
|
||||
pub mod upload;
|
||||
pub mod user;
|
||||
|
|
|
|||
222
src/controllers/ssh_keys.rs
Normal file
222
src/controllers/ssh_keys.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
use anyhow::{Context, Result, bail};
|
||||
use reqwest::Client;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::client::post_graphql;
|
||||
use crate::config::Configs;
|
||||
use crate::gql::mutations::{
|
||||
SshPublicKeyCreate, SshPublicKeyDelete, ValidateTwoFactor, ssh_public_key_create,
|
||||
ssh_public_key_delete, validate_two_factor,
|
||||
};
|
||||
use crate::gql::queries::{GitHubSshKeys, SshPublicKeys, git_hub_ssh_keys, ssh_public_keys};
|
||||
|
||||
/// Local SSH key info
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalSshKey {
|
||||
pub path: PathBuf,
|
||||
pub public_key: String,
|
||||
pub fingerprint: String,
|
||||
pub key_type: String,
|
||||
}
|
||||
|
||||
/// Supported SSH key types (in order of preference)
|
||||
const SUPPORTED_KEY_TYPES: &[&str] = &[
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
];
|
||||
|
||||
/// Find local SSH keys by scanning ~/.ssh/ for .pub files
|
||||
pub fn find_local_ssh_keys() -> Result<Vec<LocalSshKey>> {
|
||||
let home = dirs::home_dir().context("Could not find home directory")?;
|
||||
let ssh_dir = home.join(".ssh");
|
||||
|
||||
if !ssh_dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut keys = Vec::new();
|
||||
|
||||
// Scan for all .pub files in ~/.ssh/
|
||||
if let Ok(entries) = std::fs::read_dir(&ssh_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|ext| ext == "pub") {
|
||||
if let Ok(key) = read_ssh_key(&path) {
|
||||
// Only include supported key types
|
||||
if SUPPORTED_KEY_TYPES
|
||||
.iter()
|
||||
.any(|t| key.key_type.starts_with(t))
|
||||
{
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by key type preference (ed25519 first, then ecdsa, then rsa, then dss)
|
||||
keys.sort_by_key(|k| {
|
||||
SUPPORTED_KEY_TYPES
|
||||
.iter()
|
||||
.position(|t| k.key_type.starts_with(t))
|
||||
.unwrap_or(usize::MAX)
|
||||
});
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Read and parse an SSH public key file
|
||||
fn read_ssh_key(path: &Path) -> Result<LocalSshKey> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let parts: Vec<&str> = content.split_whitespace().collect();
|
||||
|
||||
if parts.len() < 2 {
|
||||
bail!("Invalid SSH key format");
|
||||
}
|
||||
|
||||
let key_type = parts[0].to_string();
|
||||
let public_key = content.trim().to_string();
|
||||
|
||||
// Compute fingerprint using ssh-keygen
|
||||
let fingerprint = compute_fingerprint(path)?;
|
||||
|
||||
Ok(LocalSshKey {
|
||||
path: path.to_path_buf(),
|
||||
public_key,
|
||||
fingerprint,
|
||||
key_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute SHA256 fingerprint of an SSH key file
|
||||
pub fn compute_fingerprint(key_path: &Path) -> Result<String> {
|
||||
let output = Command::new("ssh-keygen")
|
||||
.args(["-lf", key_path.to_str().unwrap(), "-E", "sha256"])
|
||||
.output()
|
||||
.context("Failed to run ssh-keygen")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"ssh-keygen failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
// Format: "256 SHA256:xxxxx comment (TYPE)"
|
||||
// We want "SHA256:xxxxx"
|
||||
let parts: Vec<&str> = output_str.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
Ok(parts[1].to_string())
|
||||
} else {
|
||||
bail!("Could not parse fingerprint from ssh-keygen output");
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute SHA256 fingerprint from a public key string
|
||||
pub fn compute_fingerprint_from_pubkey(pubkey: &str) -> Result<String> {
|
||||
use std::io::Write;
|
||||
use std::process::Stdio;
|
||||
|
||||
let mut child = Command::new("ssh-keygen")
|
||||
.args(["-lf", "-", "-E", "sha256"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("Failed to run ssh-keygen")?;
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(pubkey.as_bytes())?;
|
||||
}
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"ssh-keygen failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
let parts: Vec<&str> = output_str.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
Ok(parts[1].to_string())
|
||||
} else {
|
||||
bail!("Could not parse fingerprint from ssh-keygen output");
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all SSH public keys registered for the current user
|
||||
pub async fn get_registered_ssh_keys(
|
||||
client: &Client,
|
||||
configs: &Configs,
|
||||
) -> Result<Vec<ssh_public_keys::SshPublicKeysSshPublicKeysEdgesNode>> {
|
||||
let vars = ssh_public_keys::Variables {};
|
||||
let response = post_graphql::<SshPublicKeys, _>(client, configs.get_backboard(), vars).await?;
|
||||
|
||||
let keys: Vec<_> = response
|
||||
.ssh_public_keys
|
||||
.edges
|
||||
.into_iter()
|
||||
.map(|e| e.node)
|
||||
.collect();
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Register an SSH public key with Railway
|
||||
pub async fn register_ssh_key(
|
||||
client: &Client,
|
||||
configs: &Configs,
|
||||
name: &str,
|
||||
public_key: &str,
|
||||
) -> Result<ssh_public_key_create::SshPublicKeyCreateSshPublicKeyCreate> {
|
||||
let vars = ssh_public_key_create::Variables {
|
||||
input: ssh_public_key_create::SshPublicKeyCreateInput {
|
||||
name: name.to_string(),
|
||||
public_key: public_key.to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let response =
|
||||
post_graphql::<SshPublicKeyCreate, _>(client, configs.get_backboard(), vars).await?;
|
||||
|
||||
Ok(response.ssh_public_key_create)
|
||||
}
|
||||
|
||||
/// Delete an SSH public key from Railway
|
||||
pub async fn delete_ssh_key(
|
||||
client: &Client,
|
||||
configs: &Configs,
|
||||
id: &str,
|
||||
two_factor_code: Option<String>,
|
||||
) -> Result<bool> {
|
||||
if let Some(token) = two_factor_code {
|
||||
let vars = validate_two_factor::Variables { token };
|
||||
post_graphql::<ValidateTwoFactor, _>(client, configs.get_backboard(), vars).await?;
|
||||
}
|
||||
|
||||
let vars = ssh_public_key_delete::Variables { id: id.to_string() };
|
||||
let response =
|
||||
post_graphql::<SshPublicKeyDelete, _>(client, configs.get_backboard(), vars).await?;
|
||||
|
||||
Ok(response.ssh_public_key_delete)
|
||||
}
|
||||
|
||||
/// Get SSH public keys from the user's GitHub account
|
||||
pub async fn get_github_ssh_keys(
|
||||
client: &Client,
|
||||
configs: &Configs,
|
||||
) -> Result<Vec<git_hub_ssh_keys::GitHubSshKeysGitHubSshKeys>> {
|
||||
let vars = git_hub_ssh_keys::Variables {};
|
||||
let response = post_graphql::<GitHubSshKeys, _>(client, configs.get_backboard(), vars).await?;
|
||||
|
||||
Ok(response.git_hub_ssh_keys)
|
||||
}
|
||||
|
|
@ -298,6 +298,22 @@ pub struct EnvironmentStageChanges;
|
|||
)]
|
||||
pub struct ServiceInstanceUpdate;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "src/gql/schema.json",
|
||||
query_path = "src/gql/mutations/strings/SshPublicKeyCreate.graphql",
|
||||
response_derives = "Debug, Serialize, Clone"
|
||||
)]
|
||||
pub struct SshPublicKeyCreate;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "src/gql/schema.json",
|
||||
query_path = "src/gql/mutations/strings/SshPublicKeyDelete.graphql",
|
||||
response_derives = "Debug, Serialize, Clone"
|
||||
)]
|
||||
pub struct SshPublicKeyDelete;
|
||||
|
||||
impl std::fmt::Display for custom_domain_create::DNSRecordType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
|
|
|
|||
8
src/gql/mutations/strings/SshPublicKeyCreate.graphql
Normal file
8
src/gql/mutations/strings/SshPublicKeyCreate.graphql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
mutation SshPublicKeyCreate($input: SshPublicKeyCreateInput!) {
|
||||
sshPublicKeyCreate(input: $input) {
|
||||
id
|
||||
name
|
||||
publicKey
|
||||
fingerprint
|
||||
}
|
||||
}
|
||||
3
src/gql/mutations/strings/SshPublicKeyDelete.graphql
Normal file
3
src/gql/mutations/strings/SshPublicKeyDelete.graphql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
mutation SshPublicKeyDelete($id: String!) {
|
||||
sshPublicKeyDelete(id: $id)
|
||||
}
|
||||
|
|
@ -199,6 +199,14 @@ pub struct WorkflowStatus;
|
|||
)]
|
||||
pub struct Metrics;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "src/gql/schema.json",
|
||||
query_path = "src/gql/queries/strings/SshPublicKeys.graphql",
|
||||
response_derives = "Debug, Serialize, Clone"
|
||||
)]
|
||||
pub struct SshPublicKeys;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "src/gql/schema.json",
|
||||
|
|
@ -208,6 +216,22 @@ pub struct Metrics;
|
|||
)]
|
||||
pub struct Templates;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "src/gql/schema.json",
|
||||
query_path = "src/gql/queries/strings/GitHubSshKeys.graphql",
|
||||
response_derives = "Debug, Serialize, Clone"
|
||||
)]
|
||||
pub struct GitHubSshKeys;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "src/gql/schema.json",
|
||||
query_path = "src/gql/queries/strings/ServiceInstance.graphql",
|
||||
response_derives = "Debug, Serialize, Clone"
|
||||
)]
|
||||
pub struct ServiceInstance;
|
||||
|
||||
type SubscriptionDeploymentStatus = super::subscriptions::deployment::DeploymentStatus;
|
||||
impl From<project::DeploymentStatus> for SubscriptionDeploymentStatus {
|
||||
fn from(value: project::DeploymentStatus) -> Self {
|
||||
|
|
|
|||
7
src/gql/queries/strings/GitHubSshKeys.graphql
Normal file
7
src/gql/queries/strings/GitHubSshKeys.graphql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
query GitHubSshKeys {
|
||||
gitHubSshKeys {
|
||||
id
|
||||
key
|
||||
title
|
||||
}
|
||||
}
|
||||
5
src/gql/queries/strings/ServiceInstance.graphql
Normal file
5
src/gql/queries/strings/ServiceInstance.graphql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
query ServiceInstance($environmentId: String!, $serviceId: String!) {
|
||||
serviceInstance(environmentId: $environmentId, serviceId: $serviceId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
12
src/gql/queries/strings/SshPublicKeys.graphql
Normal file
12
src/gql/queries/strings/SshPublicKeys.graphql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
query SshPublicKeys {
|
||||
sshPublicKeys(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
publicKey
|
||||
fingerprint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -76,8 +76,6 @@ pub fn format_attr_log_string<T: LogLike>(log: &T, show_all_attributes: bool) ->
|
|||
/// Formatting mode for log output
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum LogFormat {
|
||||
/// Just the raw message, no formatting
|
||||
Simple,
|
||||
/// Level indicator only (e.g. [ERRO]), no other attributes - good for build logs
|
||||
LevelOnly,
|
||||
/// Full formatting with all attributes - good for deploy logs
|
||||
|
|
@ -114,7 +112,6 @@ where
|
|||
serde_json::to_string(&map).unwrap()
|
||||
} else {
|
||||
match format {
|
||||
LogFormat::Simple => log.message().to_string(),
|
||||
LogFormat::LevelOnly => format_attr_log_string(&log, false),
|
||||
LogFormat::Full => format_attr_log_string(&log, true),
|
||||
}
|
||||
|
|
@ -376,98 +373,12 @@ mod tests {
|
|||
],
|
||||
};
|
||||
|
||||
// Test JSON output mode
|
||||
let output = format_log_string(log, true, LogFormat::Simple);
|
||||
// Test JSON output mode (format param is ignored for JSON)
|
||||
let output = format_log_string(log, true, LogFormat::Full);
|
||||
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(json["message"], "Test message");
|
||||
assert_eq!(json["timestamp"], "2025-01-01T00:00:00Z");
|
||||
assert_eq!(json["level"], "warn");
|
||||
assert_eq!(json["count"], 42); // This parses as a number
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_print_log_simple_mode() {
|
||||
let log = TestLog {
|
||||
message: "Test message".to_string(),
|
||||
timestamp: "2025-01-01T00:00:00Z".to_string(),
|
||||
attributes: vec![("level".to_string(), "info".to_string())],
|
||||
};
|
||||
|
||||
// Test simple output mode
|
||||
let output = format_log_string(log, false, LogFormat::Simple);
|
||||
assert_eq!(output, "Test message");
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TestHttpLog {
|
||||
timestamp: String,
|
||||
method: String,
|
||||
path: String,
|
||||
http_status: i64,
|
||||
total_duration: i64,
|
||||
request_id: String,
|
||||
}
|
||||
|
||||
impl HttpLogLike for TestHttpLog {
|
||||
fn timestamp(&self) -> &str {
|
||||
&self.timestamp
|
||||
}
|
||||
fn method(&self) -> &str {
|
||||
&self.method
|
||||
}
|
||||
fn path(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
fn http_status(&self) -> i64 {
|
||||
self.http_status
|
||||
}
|
||||
fn total_duration(&self) -> i64 {
|
||||
self.total_duration
|
||||
}
|
||||
fn request_id(&self) -> &str {
|
||||
&self.request_id
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_http_log_plain_text() {
|
||||
let log = TestHttpLog {
|
||||
timestamp: "2025-01-01T00:00:00Z".to_string(),
|
||||
method: "GET".to_string(),
|
||||
path: "/healthz".to_string(),
|
||||
http_status: 200,
|
||||
total_duration: 5,
|
||||
request_id: "req-123".to_string(),
|
||||
};
|
||||
|
||||
let output = format_http_log_string(&log, false);
|
||||
assert!(output.contains("2025-01-01T00:00:00Z"));
|
||||
assert!(output.contains("GET"));
|
||||
assert!(output.contains("/healthz"));
|
||||
assert!(output.contains("200"));
|
||||
assert!(output.contains("5ms"));
|
||||
assert!(output.contains("req-123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_http_log_json() {
|
||||
let log = TestHttpLog {
|
||||
timestamp: "2025-01-01T00:00:00Z".to_string(),
|
||||
method: "POST".to_string(),
|
||||
path: "/form/verify".to_string(),
|
||||
http_status: 200,
|
||||
total_duration: 4,
|
||||
request_id: "req-456".to_string(),
|
||||
};
|
||||
|
||||
let output = format_http_log_string(&log, true);
|
||||
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(json["timestamp"], "2025-01-01T00:00:00Z");
|
||||
assert_eq!(json["method"], "POST");
|
||||
assert_eq!(json["path"], "/form/verify");
|
||||
assert_eq!(json["httpStatus"], 200);
|
||||
assert_eq!(json["totalDuration"], 4);
|
||||
assert_eq!(json["requestId"], "req-456");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue