Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 31 additions & 42 deletions src/commands/ssh/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,7 @@ 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
)
write!(f, "{} ({})", self.0.key_name(), self.0.fingerprint)
}
}

Expand Down Expand Up @@ -80,7 +71,7 @@ enum Commands {
/// Add/register a local SSH key with Railway
#[clap(visible_alias = "create", visible_alias = "register")]
Add {
/// Path to the public key file (defaults to auto-detect)
/// Path, fingerprint, or comment of the key to add (defaults to auto-detect)
#[clap(long, short)]
key: Option<String>,

Expand Down Expand Up @@ -176,18 +167,21 @@ async fn list_keys(workspace_id: Option<String>) -> Result<()> {
// 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(&"");
let comment = parts.get(2..).unwrap_or_default().join(" ");

println!(" {}", key.name);
println!(" Fingerprint: {}", key.fingerprint);
if !key_type.is_empty() {
println!(" Type: {}", key_type);
}
if !hostname.is_empty() {
println!(" Hostname: {}", hostname);
if !comment.is_empty() {
println!(" Comment: {}", comment);
}
if local_match.is_some() {
println!(" Source: local (~/.ssh/)");
println!(
" Source: local ({})",
local_match.unwrap().key_source()
);
}
println!();
}
Expand All @@ -199,7 +193,7 @@ async fn list_keys(workspace_id: Option<String>) -> Result<()> {
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 comment = parts.get(2..).unwrap_or_default().join(" ");
let fingerprint = compute_fingerprint_from_pubkey(&key.key).unwrap_or_default();

// Check if already registered
Expand All @@ -210,8 +204,8 @@ async fn list_keys(workspace_id: Option<String>) -> Result<()> {
println!(" Fingerprint: {}", fingerprint);
}
println!(" Type: {}", key_type);
if !hostname.is_empty() {
println!(" Hostname: {}", hostname);
if !comment.is_empty() {
println!(" Comment: {}", comment);
}
if is_registered {
println!(" Status: registered");
Expand Down Expand Up @@ -257,20 +251,15 @@ async fn list_keys(workspace_id: Option<String>) -> Result<()> {
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(&"");
let comment = key.key_comment.as_deref().unwrap_or_default();

println!(
" {}",
key.path.file_name().unwrap_or_default().to_string_lossy()
);
println!(" {}", key.key_name());
println!(" Fingerprint: {}", key.fingerprint);
println!(" Type: {}", key.key_type);
if !hostname.is_empty() {
println!(" Hostname: {}", hostname);
if !comment.is_empty() {
println!(" Comment: {}", comment);
Comment on lines +259 to +260
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semantics, I would prefer if we didn't change this- such that we can keep things consistent. I would prefer that we add a separate method so that we don't overwrite existing behavior.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will fix the lint aside, however the semantics of Hostname are wrong (imo) here - SSH key comments are significantly more broad and cover things like card serial numbers, generating user/host (as probably implied), etc. I can revert if preferred, but the intent is to follow how SSH itself wants to work.

}
println!(" Path: {}", key.path.display());
println!(" Source: {}", key.key_source());
println!();
}
println!("Add with:\n railway ssh keys add");
Expand All @@ -290,7 +279,7 @@ async fn add_key(
let local_keys = find_local_ssh_keys()?;
if local_keys.is_empty() {
bail!(
"No SSH keys found in ~/.ssh/\n\n\
"No SSH keys found in your SSH agent or ~/.ssh/\n\n\
Generate one with:\n ssh-keygen -t ed25519\n\n\
Then run this command again."
);
Expand All @@ -314,12 +303,18 @@ async fn add_key(
}

// Select key to add
let key_to_add = if let Some(path) = key_path {
let key_to_add = if let Some(arg) = 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))?
.find(|k| {
k.path
.as_ref()
.is_some_and(|p| p.to_string_lossy().contains(&arg))
|| k.fingerprint.contains(&arg)
|| k.key_comment.as_ref().is_some_and(|c| c.contains(&arg))
})
.ok_or_else(|| anyhow::anyhow!("Key not found: {}", arg))?
.clone()
} else if unregistered.len() == 1 {
unregistered[0].clone()
Expand All @@ -336,18 +331,12 @@ async fn add_key(
};

// 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()
});
let key_name = name.unwrap_or_else(|| key_to_add.key_name().to_string());

println!(
"Registering key: {} ({})",
key_to_add.path.display(),
"Registering key from {}: {} ({})",
key_to_add.key_source(),
key_to_add.key_name(),
key_to_add.fingerprint
);

Expand Down
24 changes: 12 additions & 12 deletions src/commands/ssh/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ pub async fn ensure_ssh_key(client: &Client, configs: &Configs) -> Result<()> {

if local_keys.is_empty() {
bail!(
"No SSH keys found in ~/.ssh/\n\n\
"No SSH keys found in your SSH agent or ~/.ssh/\n\n\
Generate one with:\n ssh-keygen -t ed25519\n\n\
Then run this command again."
);
Expand All @@ -61,7 +61,14 @@ pub async fn ensure_ssh_key(client: &Client, configs: &Configs) -> Result<()> {
});

if let Some(key) = registered_local {
eprintln!("Using SSH key: {}", key.path.display());
match key.path.as_ref() {
Some(path) => eprintln!(
"Using SSH key from file {}: {}",
path.display(),
key.key_name()
),
None => eprintln!("Using SSH key from agent: {}", key.key_name()),
}
return Ok(());
}

Expand All @@ -83,7 +90,7 @@ pub async fn ensure_ssh_key(client: &Client, configs: &Configs) -> Result<()> {
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)
write!(f, "{} ({})", self.0.key_name(), self.0.fingerprint)
}
}
let options: Vec<KeyOption> = local_keys.iter().map(KeyOption).collect();
Expand All @@ -93,7 +100,7 @@ pub async fn ensure_ssh_key(client: &Client, configs: &Configs) -> Result<()> {

println!(
"Key: {} ({})",
key_to_register.path.display(),
key_to_register.key_name(),
key_to_register.fingerprint
);
println!();
Expand All @@ -107,17 +114,10 @@ pub async fn ensure_ssh_key(client: &Client, configs: &Configs) -> Result<()> {
);
}

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.key_name(),
&key_to_register.public_key,
None,
)
Expand Down
12 changes: 7 additions & 5 deletions src/commands/volume/ssh_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::{
use anyhow::{Context, Result, bail};
use russh::keys::{Algorithm, HashAlg, PrivateKeyWithHashAlg};

use crate::{controllers::ssh_keys::find_local_ssh_keys, telemetry};
use crate::{controllers::ssh_keys::find_ssh_key_files, telemetry};

pub(super) async fn authenticate<H>(
session: &mut russh::client::Handle<H>,
Expand Down Expand Up @@ -89,10 +89,12 @@ fn load_secret_key(path: &Path) -> Result<Result<russh::keys::PrivateKey, anyhow
fn discover_private_key_paths() -> Result<Vec<PathBuf>> {
let mut paths = Vec::new();

for public_key in find_local_ssh_keys()? {
if let Some(private_key_path) = private_key_path_for_public_key(&public_key.path) {
if private_key_path.is_file() {
paths.push(private_key_path);
for public_key in find_ssh_key_files()? {
if let Some(public_key_path) = public_key.path {
if let Some(private_key_path) = private_key_path_for_public_key(&public_key_path) {
if private_key_path.is_file() {
paths.push(private_key_path);
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/controllers/db_stats/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const SSH_HOST: &str = "ssh.railway.com";
pub fn preflight_db_stats_ssh() -> Result<(), String> {
match find_local_ssh_keys() {
Ok(keys) if keys.is_empty() => Err(
"no local SSH key found in ~/.ssh\n \
"no SSH keys found in your SSH agent or ~/.ssh/\n\n\
generate one with `ssh-keygen -t ed25519`, then register it with `railway ssh keys add`"
.to_string(),
),
Expand Down
99 changes: 88 additions & 11 deletions src/controllers/ssh_keys.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use anyhow::{Context, Result, bail};
use reqwest::Client;
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::process::Command;

Expand All @@ -14,10 +15,31 @@ use crate::gql::queries::{GitHubSshKeys, SshPublicKeys, git_hub_ssh_keys, ssh_pu
/// Local SSH key info
#[derive(Debug, Clone)]
pub struct LocalSshKey {
pub path: PathBuf,
pub path: Option<PathBuf>,
pub public_key: String,
pub fingerprint: String,
pub key_type: String,
pub key_comment: Option<String>,
}

impl LocalSshKey {
pub fn key_name(&self) -> Cow<'_, str> {
match (self.key_comment.as_ref(), self.path.as_ref()) {
(Some(comment), _) => comment.into(),
(_, Some(path)) => path
.file_stem()
.map(|stem| stem.to_string_lossy())
.unwrap_or_else(|| (&self.fingerprint).into()),
(None, None) => (&self.fingerprint).into(),
}
}
Comment thread
KazWolfe marked this conversation as resolved.

pub fn key_source(&self) -> Cow<'_, str> {
self.path
.as_ref()
.map(|p| p.to_string_lossy())
.unwrap_or_else(|| "SSH Agent".into())
}
}

/// Supported SSH key types (in order of preference)
Expand All @@ -30,8 +52,29 @@ const SUPPORTED_KEY_TYPES: &[&str] = &[
"ssh-dss",
];

/// Find local SSH keys by scanning ~/.ssh/ for .pub files
pub fn find_local_ssh_keys() -> Result<Vec<LocalSshKey>> {
let mut seen = std::collections::HashMap::new();
for key in fetch_keys_from_ssh_agent()? {
seen.entry(key.fingerprint.clone()).or_insert(key);
}

for key in find_ssh_key_files()? {
seen.entry(key.fingerprint.clone()).or_insert(key);
}

let mut keys = seen.into_values().collect::<Vec<_>>();
keys.sort_by_key(|k| {
SUPPORTED_KEY_TYPES
.iter()
.position(|t| k.key_type.starts_with(t))
.unwrap_or(usize::MAX)
});

Ok(keys)
}

/// Find local SSH keys by scanning ~/.ssh/ for .pub files
pub fn find_ssh_key_files() -> Result<Vec<LocalSshKey>> {
let home = dirs::home_dir().context("Could not find home directory")?;
let ssh_dir = home.join(".ssh");

Expand Down Expand Up @@ -59,17 +102,46 @@ pub fn find_local_ssh_keys() -> Result<Vec<LocalSshKey>> {
}
}

// 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)
}

// Pull SSH keys from the agent directly.
pub fn fetch_keys_from_ssh_agent() -> Result<Vec<LocalSshKey>> {
let output = match Command::new("ssh-add").arg("-L").output() {
Ok(output) => output,
Err(_) => return Ok(vec![]),
};

if !output.status.success() {
// If we successfully run but can't find keys, it's probably best to just pretend like the
// SSH agent doesn't exist at all.

return Ok(vec![]);
}

String::from_utf8_lossy(&output.stdout)
.split("\n")
.filter(|s| !s.is_empty())
.filter(|s| SUPPORTED_KEY_TYPES.iter().any(|kt| s.starts_with(kt)))
.map(|s| {
let parts: Vec<_> = s.split_whitespace().collect();
let fingerprint = compute_fingerprint_from_pubkey(s)?;
let key_comment = parts
.get(2..)
.map(|p| p.join(" "))
.filter(|s| !s.is_empty());

Ok(LocalSshKey {
path: None,
public_key: s.trim().to_string(),
fingerprint,
key_type: parts[0].to_string(),
key_comment,
})
})
.collect()
Comment on lines +122 to +142
Copy link
Copy Markdown
Author

@KazWolfe KazWolfe May 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pre-existing - any such explosion on file-based keys would do same. This is another good candidate for "rewrite the entire thing using russh." (which only became a thing after I opened this PR!)

Comment thread
KazWolfe marked this conversation as resolved.
}
Comment thread
KazWolfe marked this conversation as resolved.

/// Read and parse an SSH public key file
fn read_ssh_key(path: &Path) -> Result<LocalSshKey> {
let content = std::fs::read_to_string(path)?;
Expand All @@ -81,15 +153,20 @@ fn read_ssh_key(path: &Path) -> Result<LocalSshKey> {

let key_type = parts[0].to_string();
let public_key = content.trim().to_string();
let key_comment = parts
.get(2..)
.map(|p| p.join(" "))
.filter(|s| !s.is_empty());

// Compute fingerprint using ssh-keygen
let fingerprint = compute_fingerprint(path)?;

Ok(LocalSshKey {
path: path.to_path_buf(),
path: Some(path.into()),
public_key,
fingerprint,
key_type,
key_comment,
})
}

Expand Down