From 3563c4459845167d530d5307525ba9a3e8a30eb6 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:13:14 -0400 Subject: [PATCH] Honor SSH config identities in SDK client --- src/client.rs | 269 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 254 insertions(+), 15 deletions(-) diff --git a/src/client.rs b/src/client.rs index a85dfd7..5dc4d48 100644 --- a/src/client.rs +++ b/src/client.rs @@ -17,6 +17,7 @@ use crate::udp::{bind_udp_for_peer, recv_udp_retrying_transient, send_udp_retryi use anyhow::{Context, Result, anyhow, bail}; use std::net::{SocketAddr, ToSocketAddrs}; use std::path::PathBuf; +use std::process::Command; use std::time::{Duration, SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone)] @@ -153,13 +154,6 @@ impl DoshClientBuilder { .cloned() .unwrap_or_default(); let raw_server = host_config.ssh.clone().unwrap_or_else(|| self.host.clone()); - let requested_user = self - .user - .clone() - .or_else(|| host_config.user.clone()) - .or_else(|| user_from_destination(&raw_server)) - .or_else(local_username) - .unwrap_or_else(|| "unknown".to_string()); let udp_host = self .udp_host .clone() @@ -170,6 +164,17 @@ impl DoshClientBuilder { .udp_port .or(host_config.port) .unwrap_or(self.client.config.dosh_port); + let ssh_port = host_config.ssh_port.or(self.client.config.ssh_port); + let ssh_config = + load_sdk_ssh_config(host_config.ssh_config.as_deref(), ssh_port).unwrap_or_default(); + let requested_user = self + .user + .clone() + .or_else(|| host_config.user.clone()) + .or_else(|| user_from_destination(&raw_server)) + .or_else(|| ssh_config.user.clone()) + .or_else(local_username) + .unwrap_or_else(|| "unknown".to_string()); let peer_addrs = resolve_addrs(&udp_host, udp_port)?; let timeout = self.timeout.unwrap_or_else(|| { Duration::from_millis(self.client.config.native_auth_timeout_ms.max(1)) @@ -195,6 +200,9 @@ impl DoshClientBuilder { &self.client.config, &host_config, &self.host, + &raw_server, + ssh_port, + &ssh_config, &requested_user, &session, &requested_forwardings, @@ -235,6 +243,9 @@ async fn connect_sdk_peer( config: &ClientConfig, host_config: &HostConfig, host: &str, + server: &str, + ssh_port: Option, + ssh_config: &SdkSshConfig, requested_user: &str, session: &str, requested_forwardings: &[ForwardingRequest], @@ -305,6 +316,9 @@ async fn connect_sdk_peer( requested_forwardings.to_vec(), identity_files.to_vec(), use_ssh_agent, + server, + ssh_port, + ssh_config, )?; let mut pending_id = [0u8; 16]; pending_id.copy_from_slice(&server_hello.hello.auth_challenge[..16]); @@ -372,16 +386,19 @@ fn verify_or_trust_host( fn sign_auth( config: &ClientConfig, - host_config: &HostConfig, + _host_config: &HostConfig, hello: &NativeClientHello, server_hello: &native::NativeServerHello, requested_forwardings: Vec, explicit_identity_files: Vec, use_ssh_agent: Option, + server: &str, + ssh_port: Option, + ssh_config: &SdkSshConfig, ) -> Result { let use_agent = use_ssh_agent.unwrap_or(config.use_ssh_agent); let mut errors = Vec::new(); - if use_agent { + if use_agent && !ssh_config.identities_only { match ssh_agent::sign_user_auth_with_agent( hello, server_hello, @@ -390,13 +407,13 @@ fn sign_auth( Ok(auth) => return Ok(auth), Err(err) => errors.push(format!("ssh-agent: {err:#}")), } + } else if use_agent && ssh_config.identities_only { + errors.push("ssh-agent: skipped because SSH config sets IdentitiesOnly=yes".to_string()); } - let mut paths = explicit_identity_files; - if paths.is_empty() { - paths.extend(config.identity_files.iter().map(|path| expand_tilde(path))); - } - if paths.is_empty() { + let token_context = sdk_ssh_path_token_context(server, ssh_port, ssh_config); + let mut paths = sdk_identity_paths(config, explicit_identity_files, ssh_config, &token_context); + if paths.is_empty() && !ssh_config.identities_only { paths.extend(default_identity_paths()); } for path in paths { @@ -412,13 +429,45 @@ fn sign_auth( Err(err) => errors.push(format!("{}: {err:#}", path.display())), } } - let _ = host_config; Err(anyhow!( "native auth found no usable identity: {}", errors.join("; ") )) } +fn sdk_identity_paths( + config: &ClientConfig, + explicit_identity_files: Vec, + ssh_config: &SdkSshConfig, + token_context: &SshPathTokenContext, +) -> Vec { + let mut paths = Vec::new(); + for path in explicit_identity_files { + push_identity_path(&mut paths, path); + } + for path in &ssh_config.identity_files { + push_identity_path( + &mut paths, + expand_tilde(&expand_ssh_path_tokens(path, token_context)), + ); + } + if !ssh_config.identities_only { + for path in &config.identity_files { + push_identity_path( + &mut paths, + expand_tilde(&expand_ssh_path_tokens(path, token_context)), + ); + } + } + paths +} + +fn push_identity_path(paths: &mut Vec, path: PathBuf) { + if !paths.iter().any(|existing| existing == &path) { + paths.push(path); + } +} + fn default_identity_paths() -> Vec { ["~/.ssh/id_ed25519", "~/.ssh/id_ecdsa", "~/.ssh/id_rsa"] .into_iter() @@ -426,6 +475,131 @@ fn default_identity_paths() -> Vec { .collect() } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct SdkSshConfig { + hostname: Option, + port: Option, + user: Option, + identity_files: Vec, + identities_only: bool, +} + +fn load_sdk_ssh_config(alias: Option<&str>, ssh_port: Option) -> Result { + let Some(alias) = alias else { + return Ok(SdkSshConfig::default()); + }; + let mut command = Command::new("ssh"); + command.arg("-G"); + if let Some(ssh_port) = ssh_port { + command.arg("-p").arg(ssh_port.to_string()); + } + let output = command + .arg(alias) + .output() + .with_context(|| format!("run ssh -G {alias}"))?; + if !output.status.success() { + bail!("ssh -G failed: {}", String::from_utf8_lossy(&output.stderr)); + } + Ok(parse_sdk_ssh_config(&String::from_utf8(output.stdout)?)) +} + +fn parse_sdk_ssh_config(raw: &str) -> SdkSshConfig { + let mut config = SdkSshConfig::default(); + for line in raw.lines() { + let line = line.trim(); + let Some((key, value)) = line.split_once(char::is_whitespace) else { + continue; + }; + let value = value.trim(); + if key.eq_ignore_ascii_case("hostname") && !empty_or_none(value) { + config.hostname = Some(value.to_string()); + } else if key.eq_ignore_ascii_case("port") { + config.port = value.parse().ok(); + } else if key.eq_ignore_ascii_case("user") && !empty_or_none(value) { + config.user = Some(value.to_string()); + } else if key.eq_ignore_ascii_case("identityfile") && !empty_or_none(value) { + config.identity_files.push(value.to_string()); + } else if key.eq_ignore_ascii_case("identitiesonly") { + config.identities_only = value.eq_ignore_ascii_case("yes"); + } + } + config +} + +fn empty_or_none(value: &str) -> bool { + value.is_empty() || value.eq_ignore_ascii_case("none") +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SshPathTokenContext { + original_host: String, + hostname: String, + port: u16, + remote_user: String, + local_user: String, + home_dir: Option, +} + +fn sdk_ssh_path_token_context( + server: &str, + ssh_port: Option, + ssh_config: &SdkSshConfig, +) -> SshPathTokenContext { + let original_host = destination_host(server); + let hostname = ssh_config + .hostname + .clone() + .unwrap_or_else(|| original_host.clone()); + let port = ssh_config.port.or(ssh_port).unwrap_or(22); + let remote_user = user_from_destination(server) + .or_else(|| ssh_config.user.clone()) + .or_else(local_username) + .unwrap_or_else(|| "unknown".to_string()); + let local_user = local_username().unwrap_or_else(|| "unknown".to_string()); + let home_dir = dirs::home_dir().map(|path| path.to_string_lossy().to_string()); + SshPathTokenContext { + original_host, + hostname, + port, + remote_user, + local_user, + home_dir, + } +} + +fn expand_ssh_path_tokens(raw: &str, context: &SshPathTokenContext) -> String { + let mut out = String::with_capacity(raw.len()); + let mut chars = raw.chars(); + while let Some(ch) = chars.next() { + if ch != '%' { + out.push(ch); + continue; + } + match chars.next() { + Some('%') => out.push('%'), + Some('d') => { + if let Some(home_dir) = &context.home_dir { + out.push_str(home_dir); + } else { + out.push('%'); + out.push('d'); + } + } + Some('h') => out.push_str(&context.hostname), + Some('n') => out.push_str(&context.original_host), + Some('p') => out.push_str(&context.port.to_string()), + Some('r') => out.push_str(&context.remote_user), + Some('u') => out.push_str(&context.local_user), + Some(other) => { + out.push('%'); + out.push(other); + } + None => out.push('%'), + } + } + out +} + fn resolve_addrs(host: &str, port: u16) -> Result> { let addrs = (host, port) .to_socket_addrs() @@ -543,6 +717,71 @@ mod tests { ); } + #[test] + fn sdk_parses_ssh_config_identity_settings() { + let parsed = parse_sdk_ssh_config( + "hostname 10.0.0.5\n\ + user deploy\n\ + port 2222\n\ + identityfile ~/.ssh/work\n\ + identityfile none\n\ + identitiesonly yes\n", + ); + + assert_eq!(parsed.hostname.as_deref(), Some("10.0.0.5")); + assert_eq!(parsed.user.as_deref(), Some("deploy")); + assert_eq!(parsed.port, Some(2222)); + assert_eq!(parsed.identity_files, vec!["~/.ssh/work"]); + assert!(parsed.identities_only); + } + + #[test] + fn sdk_identity_paths_follow_cli_precedence_and_identities_only() { + let dir = tempfile::tempdir().unwrap(); + let explicit = dir.path().join("explicit"); + let config_identity = dir.path().join("config"); + let ssh_config = SdkSshConfig { + hostname: Some("10.0.0.5".to_string()), + port: Some(2222), + user: Some("deploy".to_string()), + identity_files: vec![format!("{}/%r_%h_%p", dir.path().display())], + identities_only: true, + }; + let config = ClientConfig { + identity_files: vec![config_identity.display().to_string()], + ..ClientConfig::default() + }; + let token_context = sdk_ssh_path_token_context("deploy@prod", None, &ssh_config); + + let paths = + sdk_identity_paths(&config, vec![explicit.clone()], &ssh_config, &token_context); + + assert_eq!( + paths, + vec![explicit, dir.path().join("deploy_10.0.0.5_2222")] + ); + } + + #[test] + fn sdk_identity_paths_include_dosh_config_when_not_identities_only() { + let dir = tempfile::tempdir().unwrap(); + let config_identity = dir.path().join("config"); + let ssh_config = SdkSshConfig { + identity_files: vec![dir.path().join("ssh").display().to_string()], + identities_only: false, + ..SdkSshConfig::default() + }; + let config = ClientConfig { + identity_files: vec![config_identity.display().to_string()], + ..ClientConfig::default() + }; + let token_context = sdk_ssh_path_token_context("prod", Some(22), &ssh_config); + + let paths = sdk_identity_paths(&config, Vec::new(), &ssh_config, &token_context); + + assert_eq!(paths, vec![dir.path().join("ssh"), config_identity]); + } + #[test] fn default_identity_paths_are_expanded() { assert!(