feat: Add support for SSH agents in addition to raw keyfiles#901
feat: Add support for SSH agents in addition to raw keyfiles#901KazWolfe wants to merge 8 commits into
Conversation
| if !comment.is_empty() { | ||
| println!(" Comment: {}", comment); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Pull request overview
Adds support for SSH-agent–resident keys to railway ssh so users without on-disk *.pub files (forwarded agents, 1Password, hardware tokens, etc.) can register and use keys. find_local_ssh_keys is reworked to merge keys from ssh-add -L and ~/.ssh/*.pub, deduplicated by fingerprint with agent keys winning. LocalSshKey becomes a unified file-or-agent representation with new key_name() / key_source() helpers, and the CLI display/registration paths are updated to use them. The previous "Hostname" field in listings is renamed to "Comment" and now captures everything after the second whitespace split.
Changes:
- New
fetch_keys_from_ssh_agent()and refactoredfind_local_ssh_keys()that merges agent + file keys. LocalSshKey.pathbecomesOption<PathBuf>, gainskey_comment, and exposeskey_name()/key_source()helpers, with all callers updated.- Renamed displayed "Hostname" → "Comment" and switched to a full
parts[2..].join(" ")capture inkeys.rs.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/controllers/ssh_keys.rs | Core changes: LocalSshKey made agent-capable, agent fetch added, find_local_ssh_keys merges sources by fingerprint. |
| src/commands/ssh/native.rs | Uses key_name() / key_source() in prompts, registration, and "Using SSH key" message; no longer assumes a file path. |
| src/commands/ssh/keys.rs | List/add flows updated for agent keys: comment instead of hostname, key_source() for Source line, path matching guarded by Option. |
Comments suppressed due to low confidence (3)
src/controllers/ssh_keys.rs:61
fetch_keys_from_ssh_agentpropagates errors with?tofind_local_ssh_keys, which means any environment withoutssh-addinstalled, withoutSSH_AUTH_SOCKset, with the agent unreachable, or simply with no identities loaded will causefind_local_ssh_keys()to fail entirely. Previously this function would succeed and return the on-disk keys. This regresses every caller —ensure_ssh_key,list_keys,add_key, andpreflight_db_stats_ssh— for users who don't run an SSH agent (a very common case on Linux desktops and CI). The agent fetch should degrade gracefully: whenssh-addis missing, exits non-zero (e.g. exit code 1 = "agent has no identities", exit code 2 = "cannot connect to agent"), or the command can't be spawned, treat it as "no agent keys" and continue to scan~/.ssh/, rather than bailing.
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);
}
src/controllers/ssh_keys.rs:133
parts[0]andparts[2..]are indexed without verifying the line has at least one (or two) whitespace-separated fields.read_ssh_keyperforms an explicitparts.len() < 2check before doing the same indexing — this function should do the same. Whilessh-add -Loutput is normally well-formed, a stray blank/whitespace-only line (e.g. trailing whitespace after the final newline that survivesfilter(|s| !s.is_empty())because it contains only spaces) or any future agent quirk would panic the process. Also noteparts[2..]panics whenparts.len() < 2, not when it equals 2.
.map(|s| {
let parts: Vec<_> = s.split_whitespace().collect();
let fingerprint = compute_fingerprint_from_pubkey(s)?;
Ok(LocalSshKey {
path: None,
public_key: s.trim().to_string(),
fingerprint,
key_type: parts[0].to_string(),
key_comment: parts[2..].join(" ").into(),
})
src/controllers/ssh_keys.rs:30
path.file_stem().unwrap()will panic if the path terminates in..or otherwise has no file stem. Although unlikely in practice for~/.ssh/*.pub, this is a hard panic in a user-facing code path; consider falling back to the fingerprint (as the final arm already does) whenfile_stem()isNone.
(_, Some(path)) => path.file_stem().unwrap().to_string_lossy(),
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
src/controllers/ssh_keys.rs:151
- Same
Some("")-vs-Noneissue as infetch_keys_from_ssh_agent: for a key file that contains just<type> <base64>(no trailing comment),parts.get(2..)returnsSome(&[])andkey_commentbecomesSome("").key_name()will then resolve to the empty string instead of falling back to the file stem. Filter empty joined comments down toNone.
let key_comment = parts.get(2..).map(|p| p.join(" "));
- fix some typing quirks with key_comment
bca8d17 to
6f15d76
Compare
| for public_key in find_local_ssh_keys()? { | ||
| if let Some(private_key_path) = private_key_path_for_public_key(&public_key.path) { | ||
| for public_key in find_ssh_key_files()? { | ||
| if public_key.path.is_none() { |
There was a problem hiding this comment.
This was added in after I started working on this, so for now I just patched things such that this only works with SSH keyfiles. It'll take a bit more work in general to get agent support working properly, and it'd really be better to refactor all of the key discovery mechanism(s) now that we have russh accessible to us.
(As an aside, this is a very fragile way of doing key discovery, but eh, it works.)
| String::from_utf8_lossy(&output.stdout) | ||
| .split("\n") | ||
| .filter(|s| !s.is_empty()) | ||
| .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() |
There was a problem hiding this comment.
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!)
- replace collect with pattern matching
Simple-ish attempt to fix #870 since it started to become a headache for me. I'll preface this PR by warning everyone that I am not a Rust developer, so I'm pretty sure a lot of things in here are wrong or deeply suboptimal (though I did have some people who are much better at Rust than me look at it and tell me to do arcane things). I'd be more than willing to bring in any fixes.
Changes:
ssh-add -Lto query the agent, then read keys from~/.ssh.LocalSshKey#key_nameand#key_sourceto encapsulate some logic.hostnamewithcommentfor semantic clarity.High level demo:
https://asciinema.org/a/wj2g7MpQf2AC82Wz