From 7fec3592f5f0050e4365e7f7271dbc00988869e5 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:13:47 -0400 Subject: [PATCH] Honor more SSH config in native auth --- src/bin/dosh-client.rs | 187 +++++++++++++++++++++++++++++++++++------ 1 file changed, 159 insertions(+), 28 deletions(-) diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 1526799..98d3982 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -231,6 +231,7 @@ async fn main() -> Result<()> { let allow_cache = !args.no_cache && !forwarding_requested; let started = Instant::now(); + let mut resolved_ssh_config = None; let target_udp_host = if let Some(host) = args .dosh_host .clone() @@ -241,14 +242,25 @@ async fn main() -> Result<()> { } else if args.local_auth { "127.0.0.1".to_string() } else { - ssh_config_hostname(&server, ssh_port).unwrap_or_else(|err| { - log_debug( - args.verbose, - 2, - &format!("dosh could not resolve SSH config hostname, using {server}: {err:#}"), - ); - ssh_destination_host(&server) - }) + match ssh_config(&server, ssh_port) { + Ok(parsed) => { + let hostname = parsed + .hostname + .clone() + .unwrap_or_else(|| ssh_destination_host(&server)); + resolved_ssh_config = Some(parsed); + hostname + } + Err(err) => { + log_debug( + args.verbose, + 2, + &format!("dosh could not resolve SSH config hostname, using {server}: {err:#}"), + ); + resolved_ssh_config = Some(SshConfig::default()); + ssh_destination_host(&server) + } + } }; let credential = if allow_cache { @@ -348,6 +360,7 @@ async fn main() -> Result<()> { cols, rows, forwarding_requests(&local_forwards, &remote_forwards, &dynamic_forwards), + resolved_ssh_config.as_ref(), ) .await { @@ -512,21 +525,31 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) -> .unwrap_or(config.dosh_port); println!("Dosh doctor for {requested}"); - match ssh_config(&server, ssh_port) { + let parsed_ssh_config = match ssh_config(&server, ssh_port) { Ok(parsed) => { let hostname = parsed .hostname .clone() .unwrap_or_else(|| ssh_destination_host(&server)); println!( - "[ok] ssh config: host={} user={} port={}", + "[ok] ssh config: host={} user={} port={} identities_only={} proxy={}", hostname, parsed.user.as_deref().unwrap_or(""), - parsed.port.unwrap_or(ssh_port.unwrap_or(22)) + parsed.port.unwrap_or(ssh_port.unwrap_or(22)), + parsed.identities_only, + parsed + .proxy_jump + .as_deref() + .or(parsed.proxy_command.as_deref()) + .unwrap_or("") ); + parsed } - Err(err) => println!("[fail] ssh config: {err:#}"), - } + Err(err) => { + println!("[fail] ssh config: {err:#}"); + SshConfig::default() + } + }; match ssh_fallback_probe( &server, @@ -544,7 +567,10 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) -> .or_else(|| host_config.dosh_host.clone()) .or_else(|| config.dosh_host.clone()) .unwrap_or_else(|| { - ssh_config_hostname(&server, ssh_port).unwrap_or_else(|_| ssh_destination_host(&server)) + parsed_ssh_config + .hostname + .clone() + .unwrap_or_else(|| ssh_destination_host(&server)) }); println!("[info] native endpoint: {udp_host}:{dosh_port}"); let socket = UdpSocket::bind("0.0.0.0:0").await?; @@ -557,6 +583,7 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) -> &udp_host, dosh_port, args.ssh_key.as_deref(), + Some(&parsed_ssh_config), ) .await { @@ -1142,18 +1169,16 @@ fn ssh_username(server: &str) -> Option { } } -fn ssh_config_hostname(server: &str, ssh_port: Option) -> Result { - ssh_config(server, ssh_port)? - .hostname - .ok_or_else(|| anyhow!("ssh -G did not print hostname")) -} - #[derive(Debug, Clone, Default)] struct SshConfig { hostname: Option, port: Option, user: Option, identity_files: Vec, + user_known_hosts_file: Option, + identities_only: bool, + proxy_jump: Option, + proxy_command: Option, } fn ssh_config(server: &str, ssh_port: Option) -> Result { @@ -1184,19 +1209,31 @@ fn parse_ssh_config(raw: &str) -> SshConfig { continue; }; let value = value.trim(); - if key.eq_ignore_ascii_case("hostname") && !value.is_empty() { + 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") && !value.is_empty() { + } 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") && !value.is_empty() { + } 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("userknownhostsfile") && !empty_or_none(value) { + config.user_known_hosts_file = Some(value.to_string()); + } else if key.eq_ignore_ascii_case("identitiesonly") { + config.identities_only = value.eq_ignore_ascii_case("yes"); + } else if key.eq_ignore_ascii_case("proxyjump") && !empty_or_none(value) { + config.proxy_jump = Some(value.to_string()); + } else if key.eq_ignore_ascii_case("proxycommand") && !empty_or_none(value) { + config.proxy_command = Some(value.to_string()); } } config } +fn empty_or_none(value: &str) -> bool { + value.is_empty() || value.eq_ignore_ascii_case("none") +} + fn resolve_addr(host: &str, port: u16) -> Result { (host, port) .to_socket_addrs() @@ -1219,9 +1256,16 @@ async fn try_native_auth( cols: u16, rows: u16, requested_forwardings: Vec, + ssh_config_hint: Option<&SshConfig>, ) -> Result<(Frame, CachedCredential)> { let addr = resolve_addr(udp_host, udp_port)?; - let ssh_config = ssh_config(server, ssh_port).unwrap_or_default(); + let owned_ssh_config; + let ssh_config = if let Some(ssh_config) = ssh_config_hint { + ssh_config + } else { + owned_ssh_config = ssh_config(server, ssh_port).unwrap_or_default(); + &owned_ssh_config + }; let requested_user = ssh_username(server) .or(ssh_config.user.clone()) .unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string())); @@ -1367,9 +1411,16 @@ async fn try_native_auth_check( udp_host: &str, udp_port: u16, cli_identity: Option<&Path>, + ssh_config_hint: Option<&SshConfig>, ) -> Result { let addr = resolve_addr(udp_host, udp_port)?; - let ssh_config = ssh_config(server, ssh_port).unwrap_or_default(); + let owned_ssh_config; + let ssh_config = if let Some(ssh_config) = ssh_config_hint { + ssh_config + } else { + owned_ssh_config = ssh_config(server, ssh_port).unwrap_or_default(); + &owned_ssh_config + }; let requested_user = ssh_username(server) .or(ssh_config.user.clone()) .unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string())); @@ -1493,7 +1544,7 @@ fn sign_native_user_auth( requested_forwardings: Vec, ) -> Result { let mut errors = Vec::new(); - if config.use_ssh_agent { + if config.use_ssh_agent && !ssh_config.identities_only { match ssh_agent::sign_user_auth_with_agent( hello, server_hello, @@ -1502,6 +1553,8 @@ fn sign_native_user_auth( Ok(auth) => return Ok(auth), Err(err) => errors.push(format!("ssh-agent: {err:#}")), } + } else if config.use_ssh_agent && ssh_config.identities_only { + errors.push("ssh-agent: skipped because SSH config sets IdentitiesOnly=yes".to_string()); } match load_first_native_identity(config, cli_identity, ssh_config) { @@ -1545,8 +1598,10 @@ where for path in &ssh_config.identity_files { push_identity_path(&mut paths, expand_tilde(path)); } - for path in &config.identity_files { - push_identity_path(&mut paths, expand_tilde(path)); + if !ssh_config.identities_only { + for path in &config.identity_files { + push_identity_path(&mut paths, expand_tilde(path)); + } } let mut errors = Vec::new(); @@ -2786,6 +2841,29 @@ mod tests { ); } + #[test] + fn parses_ssh_config_native_parity_options() { + let parsed = parse_ssh_config( + "userknownhostsfile ~/.ssh/known_hosts ~/.ssh/work_hosts\n\ + identitiesonly yes\n\ + proxyjump bastion\n\ + proxycommand ssh bastion -W %h:%p\n\ + identityfile none\n", + ); + + assert_eq!( + parsed.user_known_hosts_file, + Some("~/.ssh/known_hosts ~/.ssh/work_hosts".to_string()) + ); + assert!(parsed.identities_only); + assert_eq!(parsed.proxy_jump, Some("bastion".to_string())); + assert_eq!( + parsed.proxy_command, + Some("ssh bastion -W %h:%p".to_string()) + ); + assert!(parsed.identity_files.is_empty()); + } + #[test] fn detects_existing_imported_host_tables() { assert!(raw_contains_host_table( @@ -2857,6 +2935,59 @@ mod tests { assert!(err.to_string().contains("no usable ssh-ed25519 identity")); } + #[test] + fn identities_only_skips_dosh_config_identity_fallback() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("id_ed25519"); + let signing = SigningKey::from_bytes(&[63u8; 32]); + write_identity(&path, &signing); + let mut config = ClientConfig::default(); + config.identity_files = vec![path.display().to_string()]; + let ssh_config = SshConfig { + identities_only: true, + ..SshConfig::default() + }; + let err = load_first_native_identity_with_prompt(&config, None, &ssh_config, |_| { + unreachable!("unencrypted key should not prompt") + }) + .unwrap_err(); + + assert!(err.to_string().contains("no usable ssh-ed25519 identity")); + } + + #[test] + fn identities_only_still_allows_ssh_config_identity_files() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("id_ed25519"); + let signing = SigningKey::from_bytes(&[64u8; 32]); + write_identity(&path, &signing); + let mut config = ClientConfig::default(); + config.identity_files.clear(); + let ssh_config = SshConfig { + identity_files: vec![path.display().to_string()], + identities_only: true, + ..SshConfig::default() + }; + let loaded = load_first_native_identity_with_prompt(&config, None, &ssh_config, |_| { + unreachable!("unencrypted key should not prompt") + }) + .unwrap(); + + assert_eq!( + VerifyingKey::from(&loaded).to_bytes(), + VerifyingKey::from(&signing).to_bytes() + ); + } + + fn write_identity(path: &std::path::Path, signing: &SigningKey) { + let keypair = ssh_key::private::Ed25519Keypair::from(signing); + let private = + ssh_key::PrivateKey::new(ssh_key::private::KeypairData::from(keypair), "test").unwrap(); + private + .write_openssh_file(path, ssh_key::LineEnding::LF) + .unwrap(); + } + fn write_encrypted_identity(path: &std::path::Path, signing: &SigningKey, passphrase: &str) { let keypair = ssh_key::private::Ed25519Keypair::from(signing); let private =