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:
Paulo Cabral Sanz 2026-03-18 13:57:37 -03:00 committed by GitHub
parent b24f48db41
commit 81cd7d0ada
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 975 additions and 96 deletions

442
src/commands/ssh/keys.rs Normal file
View 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 &registered_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(())
}

View file

@ -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,
&params.environment_id,
&params.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(&params, name).await?;

152
src/commands/ssh/native.rs Normal file
View 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))
}
}

View file

@ -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
View 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)
}

View file

@ -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 {

View file

@ -0,0 +1,8 @@
mutation SshPublicKeyCreate($input: SshPublicKeyCreateInput!) {
sshPublicKeyCreate(input: $input) {
id
name
publicKey
fingerprint
}
}

View file

@ -0,0 +1,3 @@
mutation SshPublicKeyDelete($id: String!) {
sshPublicKeyDelete(id: $id)
}

View file

@ -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 {

View file

@ -0,0 +1,7 @@
query GitHubSshKeys {
gitHubSshKeys {
id
key
title
}
}

View file

@ -0,0 +1,5 @@
query ServiceInstance($environmentId: String!, $serviceId: String!) {
serviceInstance(environmentId: $environmentId, serviceId: $serviceId) {
id
}
}

View file

@ -0,0 +1,12 @@
query SshPublicKeys {
sshPublicKeys(first: 100) {
edges {
node {
id
name
publicKey
fingerprint
}
}
}
}

View file

@ -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");
}
}