use crate::config::{ ClientConfig, HostConfig, expand_tilde, load_client_config, load_hosts_config, }; use crate::crypto; use crate::native::{ self, EnvVar, ForwardingKind, ForwardingRequest, KnownHostStatus, NativeClientHello, derive_native_session_key, generate_native_ephemeral, sign_user_auth_with_private_key, supported_user_key_algorithms, trust_host, verify_known_host, verify_server_hello, }; use crate::protocol::{ self, AttachReject, CLIENT_TO_SERVER, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind, SERVER_TO_CLIENT, }; use crate::ssh_agent; use crate::transport::{DoshTransport, SessionRole, SessionTransportConfig, TransportConfig}; use crate::udp::{bind_udp_for_peer, recv_udp_retrying_transient, send_udp_retrying_transient}; use anyhow::{Context, Result, anyhow, bail}; use std::collections::BTreeMap; use std::net::{SocketAddr, ToSocketAddrs}; use std::path::PathBuf; use std::process::Command; use std::time::{Duration, SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone)] pub struct DoshClient { config: ClientConfig, hosts: crate::config::HostsConfig, } impl DoshClient { pub fn load() -> Result { Ok(Self { config: load_client_config(None)?, hosts: load_hosts_config(None)?, }) } pub fn load_from_paths( client_config: Option, hosts_config: Option, ) -> Result { Ok(Self { config: load_client_config(client_config)?, hosts: load_hosts_config(hosts_config)?, }) } pub fn with_config(config: ClientConfig, hosts: crate::config::HostsConfig) -> Self { Self { config, hosts } } pub fn connect(&self, host: impl Into) -> DoshClientBuilder { DoshClientBuilder::new(self.clone(), host.into()) } } #[derive(Debug, Clone)] pub struct DoshClientBuilder { client: DoshClient, host: String, services: Vec, identity_files: Vec, session: Option, user: Option, udp_host: Option, udp_port: Option, trust_on_first_use: Option, use_ssh_agent: Option, timeout: Option, env: Vec, } impl DoshClientBuilder { pub fn new(client: DoshClient, host: String) -> Self { Self { client, host, services: Vec::new(), identity_files: Vec::new(), session: None, user: None, udp_host: None, udp_port: None, trust_on_first_use: None, use_ssh_agent: None, timeout: None, env: Vec::new(), } } pub fn service(mut self, name: impl Into) -> Self { self.services.push(name.into()); self } pub fn services(mut self, names: impl IntoIterator>) -> Self { self.services.extend(names.into_iter().map(Into::into)); self } pub fn identity_file(mut self, path: impl Into) -> Self { self.identity_files.push(path.into()); self } pub fn session(mut self, session: impl Into) -> Self { self.session = Some(session.into()); self } pub fn user(mut self, user: impl Into) -> Self { self.user = Some(user.into()); self } pub fn udp_host(mut self, host: impl Into) -> Self { self.udp_host = Some(host.into()); self } pub fn udp_port(mut self, port: u16) -> Self { self.udp_port = Some(port); self } pub fn trust_on_first_use(mut self, trust: bool) -> Self { self.trust_on_first_use = Some(trust); self } pub fn use_ssh_agent(mut self, value: bool) -> Self { self.use_ssh_agent = Some(value); self } pub fn timeout(mut self, timeout: Duration) -> Self { self.timeout = Some(timeout); self } pub fn env(mut self, name: impl Into, value: impl Into) -> Self { self.env.push(EnvVar { name: name.into(), value: value.into(), }); self } pub async fn connect(self) -> Result { let host_config = self .client .hosts .hosts .get(&self.host) .cloned() .unwrap_or_default(); let raw_server = host_config.ssh.clone().unwrap_or_else(|| self.host.clone()); let ssh_port = host_config.ssh_port.or(self.client.config.ssh_port); let ssh_config = load_sdk_ssh_config_for_target(&host_config, &raw_server, ssh_port)?; let udp_host = selected_sdk_udp_host( self.udp_host.as_deref(), &host_config, &self.client.config, &raw_server, &ssh_config, )?; let udp_port = self .udp_port .or(host_config.port) .unwrap_or(self.client.config.dosh_port); 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)) }); let session = self.session.unwrap_or_else(default_sdk_session); let requested_env = sdk_requested_env(&self.client.config, &host_config, &ssh_config, self.env); let requested_forwardings = self .services .iter() .map(|service| { Ok(ForwardingRequest { kind: ForwardingKind::Local, bind_host: None, listen_port: 0, target_host: Some(crate::transport::service_target(service)?), target_port: Some(0), }) }) .collect::>>()?; let mut errors = Vec::new(); for peer_addr in peer_addrs { match connect_sdk_peer( peer_addr, &self.client.config, &self.host, &raw_server, ssh_port, &ssh_config, &requested_user, &session, &requested_forwardings, &self.identity_files, self.use_ssh_agent, self.trust_on_first_use, &requested_env, timeout, ) .await { Ok(client) => return Ok(client), Err(err) => errors.push(format!("{peer_addr}: {err:#}")), } } Err(anyhow!( "native auth failed for all resolved UDP addresses: {}", errors.join("; ") )) } } pub struct ConnectedDoshClient { pub host: String, pub session: String, pub transport: DoshTransport, } impl ConnectedDoshClient { pub fn into_transport(self) -> DoshTransport { self.transport } } #[allow(clippy::too_many_arguments)] async fn connect_sdk_peer( peer_addr: SocketAddr, config: &ClientConfig, host: &str, server: &str, ssh_port: Option, ssh_config: &SdkSshConfig, requested_user: &str, session: &str, requested_forwardings: &[ForwardingRequest], identity_files: &[PathBuf], use_ssh_agent: Option, trust_on_first_use: Option, requested_env: &[EnvVar], timeout: Duration, ) -> Result { let socket = bind_udp_for_peer(peer_addr).await?; let (client_secret, client_public) = generate_native_ephemeral(); let hello = NativeClientHello { protocol_version: native::NATIVE_PROTOCOL_VERSION, client_random: crypto::random_32(), client_ephemeral_public: client_public, requested_host: host.to_string(), requested_user: requested_user.to_string(), requested_session: session.to_string(), requested_mode: "forward-only".to_string(), terminal_size: (80, 24), supported_aead: vec!["chacha20poly1305".to_string()], supported_user_key_algorithms: supported_user_key_algorithms(), cached_host_key_fingerprint: None, attach_ticket_envelope: None, requested_env: requested_env.to_vec(), }; let packet = protocol::encode_plain( PacketKind::NativeClientHello, [0u8; 16], 1, 0, &protocol::to_body(&NativeClientHelloBody { hello: hello.clone(), })?, )?; send_udp_retrying_transient(&socket, &packet, peer_addr, timeout).await?; let mut buf = vec![0u8; 65535]; let (n, _) = recv_udp_retrying_transient(&socket, &mut buf, timeout).await?; let packet = protocol::decode(&buf[..n])?; if packet.header.kind != PacketKind::NativeServerHello { if packet.header.kind == PacketKind::AttachReject { let reject: AttachReject = protocol::from_body(&packet.body)?; bail!("native auth rejected: {}", reject.reason); } bail!("native auth received unexpected server response"); } let server_hello: NativeServerHelloBody = protocol::from_body(&packet.body)?; verify_server_hello(&hello, &server_hello.hello)?; verify_or_trust_host( config, host, &server_hello.hello.host_key, trust_on_first_use, )?; let session_key = derive_native_session_key( &client_secret, server_hello.hello.server_ephemeral_public, &hello, &server_hello.hello, )?; let auth = sign_auth(NativeAuthSignRequest { config, hello: &hello, server_hello: &server_hello.hello, requested_forwardings: requested_forwardings.to_vec(), explicit_identity_files: 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]); let auth_packet = protocol::encode_encrypted( PacketKind::NativeUserAuth, pending_id, 2, 1, &session_key, CLIENT_TO_SERVER, &protocol::to_body(&NativeUserAuthBody { auth })?, )?; send_udp_retrying_transient(&socket, &auth_packet, peer_addr, timeout).await?; let (n, _) = recv_udp_retrying_transient(&socket, &mut buf, timeout).await?; let packet = protocol::decode(&buf[..n])?; if packet.header.kind != PacketKind::NativeAuthOk { if packet.header.kind == PacketKind::AttachReject { let reject: AttachReject = protocol::from_body(&packet.body)?; bail!("native auth rejected: {}", reject.reason); } bail!("native auth received unexpected auth response"); } let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT)?; let ok: NativeAuthOkBody = protocol::from_body(&plain)?; let transport = DoshTransport::new_owned( socket, SessionTransportConfig { role: SessionRole::Client, conn_id: ok.ok.client_id, session_key: ok.ok.session_key, peer_addr, initial_send_seq: 2, initial_ack: ok.ok.initial_seq, stream: TransportConfig::default(), }, ); Ok(ConnectedDoshClient { host: host.to_string(), session: ok.ok.session, transport, }) } fn verify_or_trust_host( config: &ClientConfig, host: &str, host_key: &native::HostPublicKey, trust_override: Option, ) -> Result<()> { let known_hosts = expand_tilde(&config.known_hosts); match verify_known_host(&known_hosts, host, host_key)? { KnownHostStatus::Trusted => Ok(()), KnownHostStatus::Unknown if trust_override.unwrap_or(config.trust_on_first_use) => { trust_host(&known_hosts, host, host_key, "sdk-tofu", false)?; Ok(()) } KnownHostStatus::Unknown => Err(anyhow!( "Dosh host key for {host} is not trusted; run `dosh trust {host}` first or enable trust_on_first_use" )), KnownHostStatus::Mismatch { expected, actual } => Err(anyhow!( "Dosh host key mismatch for {host}: expected {expected}, got {actual}" )), } } struct NativeAuthSignRequest<'a> { config: &'a ClientConfig, hello: &'a NativeClientHello, server_hello: &'a native::NativeServerHello, requested_forwardings: Vec, explicit_identity_files: Vec, use_ssh_agent: Option, server: &'a str, ssh_port: Option, ssh_config: &'a SdkSshConfig, } fn sign_auth(request: NativeAuthSignRequest<'_>) -> Result { let NativeAuthSignRequest { config, hello, server_hello, requested_forwardings, explicit_identity_files, use_ssh_agent, server, ssh_port, ssh_config, } = request; let use_agent = use_ssh_agent.unwrap_or(config.use_ssh_agent); let mut errors = Vec::new(); if use_agent && !ssh_config.identities_only { match ssh_agent::sign_user_auth_with_agent( hello, server_hello, requested_forwardings.clone(), ) { 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 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 { match native::load_native_identity(&path).and_then(|identity| { sign_user_auth_with_private_key( &identity, hello, server_hello, requested_forwardings.clone(), ) }) { Ok(auth) => return Ok(auth), Err(err) => errors.push(format!("{}: {err:#}", path.display())), } } 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); } 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)), ); } } for path in &ssh_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() .map(expand_tilde) .collect() } #[derive(Debug, Clone, Default, PartialEq, Eq)] struct SdkSshConfig { hostname: Option, port: Option, user: Option, identity_files: Vec, identities_only: bool, send_env: Vec, set_env: Vec, } fn load_sdk_ssh_config(alias: Option<&str>, ssh_port: Option) -> Result { let Some(alias) = alias else { return Ok(SdkSshConfig::default()); }; let alias = alias.trim(); if alias.is_empty() { 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 load_sdk_ssh_config_for_target( host_config: &HostConfig, raw_server: &str, ssh_port: Option, ) -> Result { let (alias, explicit) = sdk_ssh_config_lookup(host_config, raw_server); match load_sdk_ssh_config(Some(alias), ssh_port) { Ok(config) => Ok(config), Err(_) if !explicit => Ok(SdkSshConfig::default()), Err(err) => Err(err), } } fn sdk_ssh_config_lookup<'a>(host_config: &'a HostConfig, raw_server: &'a str) -> (&'a str, bool) { match host_config.ssh_config.as_deref().map(str::trim) { Some(alias) if !alias.is_empty() => (alias, true), _ => (raw_server, false), } } 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"); } else if key.eq_ignore_ascii_case("sendenv") && !empty_or_none(value) { config .send_env .extend(value.split_whitespace().map(ToString::to_string)); } else if key.eq_ignore_ascii_case("setenv") && !empty_or_none(value) { config.set_env.extend(parse_set_env_values(value)); } } config } fn sdk_requested_env( config: &ClientConfig, host: &HostConfig, ssh_config: &SdkSshConfig, explicit_env: Vec, ) -> Vec { let mut values = BTreeMap::new(); let mut patterns = host .send_env .clone() .unwrap_or_else(|| config.send_env.clone()); patterns.extend(ssh_config.send_env.clone()); for (name, value) in std::env::vars() { if valid_env_name(&name) && patterns.iter().any(|pattern| glob_matches(pattern, &name)) { values.insert(name, value); } } for (name, value) in &config.set_env { if valid_env_name(name) && !value.as_bytes().contains(&0) { values.insert(name.clone(), value.clone()); } } for (name, value) in &host.set_env { if valid_env_name(name) && !value.as_bytes().contains(&0) { values.insert(name.clone(), value.clone()); } } for env in &ssh_config.set_env { if valid_env_name(&env.name) && !env.value.as_bytes().contains(&0) { values.insert(env.name.clone(), env.value.clone()); } } for env in explicit_env { if valid_env_name(&env.name) && !env.value.as_bytes().contains(&0) { values.insert(env.name, env.value); } } values .into_iter() .map(|(name, value)| EnvVar { name, value }) .collect() } fn parse_set_env_values(value: &str) -> Vec { value .split_whitespace() .filter_map(|entry| { let (name, value) = entry.split_once('=')?; if valid_env_name(name) && !value.as_bytes().contains(&0) { Some(EnvVar { name: name.to_string(), value: value.to_string(), }) } else { None } }) .collect() } fn valid_env_name(name: &str) -> bool { let mut chars = name.chars(); let Some(first) = chars.next() else { return false; }; if !(first == '_' || first.is_ascii_alphabetic()) { return false; } chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) } fn glob_matches(pattern: &str, value: &str) -> bool { let pattern = pattern.as_bytes(); let value = value.as_bytes(); let (mut p, mut v) = (0, 0); let mut star = None; let mut star_value = 0; while v < value.len() { if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == value[v]) { p += 1; v += 1; } else if p < pattern.len() && pattern[p] == b'*' { star = Some(p); star_value = v; p += 1; } else if let Some(s) = star { p = s + 1; star_value += 1; v = star_value; } else { return false; } } while p < pattern.len() && pattern[p] == b'*' { p += 1; } p == pattern.len() } 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() .with_context(|| format!("resolve UDP target {host}:{port}"))? .collect::>(); if addrs.is_empty() { return Err(anyhow!("no UDP address resolved for {host}:{port}")); } Ok(addrs) } #[cfg(test)] fn first_resolved_addr(addrs: &[SocketAddr], host: &str, port: u16) -> Result { addrs .first() .copied() .ok_or_else(|| anyhow!("no UDP address resolved for {host}:{port}")) } fn destination_host(destination: &str) -> String { let without_user = destination .rsplit_once('@') .map_or(destination, |(_, host)| host); let without_path = without_user .strip_prefix("ssh://") .unwrap_or(without_user) .split('/') .next() .unwrap_or(without_user); if let Some(stripped) = without_path.strip_prefix('[') && let Some((host, _)) = stripped.split_once(']') { return host.to_string(); } without_path .split_once(':') .map_or(without_path, |(host, _)| host) .to_string() } fn selected_sdk_udp_host( explicit: Option<&str>, host: &HostConfig, config: &ClientConfig, raw_server: &str, ssh_config: &SdkSshConfig, ) -> Result { let configured = explicit .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) .or_else(|| host.dosh_host.clone()) .or_else(|| config.dosh_host.clone()); let ssh_host = || { ssh_config .hostname .clone() .unwrap_or_else(|| destination_host(raw_server)) }; let Some(raw) = configured else { return Ok(ssh_host()); }; match raw.trim().to_ascii_lowercase().as_str() { "ssh" | "auto" => Ok(ssh_host()), "localhost" => Ok("127.0.0.1".to_string()), "any" => bail!( "dosh_host={raw:?} is a server bind policy, not a client destination; use ssh/auto or a host/IP" ), _ => Ok(raw), } } fn user_from_destination(destination: &str) -> Option { destination .rsplit_once('@') .map(|(user, _)| user.strip_prefix("ssh://").unwrap_or(user).to_string()) .filter(|user| !user.is_empty()) } fn local_username() -> Option { local_username_from_env(|name| std::env::var(name).ok()) } fn local_username_from_env(get: impl FnMut(&str) -> Option) -> Option { ["USER", "USERNAME"] .into_iter() .filter_map(get) .find(|value| !value.is_empty()) } fn default_sdk_session() -> String { let millis = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis(); format!("sdk-{millis}-{}", std::process::id()) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_user_and_host_from_destination() { assert_eq!( user_from_destination("palav@example.com").as_deref(), Some("palav") ); assert_eq!( user_from_destination("ssh://palav@example.com:2222/srv/app").as_deref(), Some("palav") ); assert_eq!( user_from_destination("ssh://palav@[2001:db8::2]:2222/srv/app").as_deref(), Some("palav") ); assert_eq!(destination_host("palav@example.com"), "example.com"); assert_eq!(destination_host("example.com:2222"), "example.com"); assert_eq!(destination_host("palav@[2001:db8::1]:2222"), "2001:db8::1"); assert_eq!( destination_host("ssh://palav@example.com:2222/srv/app"), "example.com" ); assert_eq!( destination_host("ssh://palav@[2001:db8::2]:2222/srv/app"), "2001:db8::2" ); } #[test] fn local_username_uses_unix_or_windows_environment_names() { assert_eq!( local_username_from_env(|name| match name { "USER" => Some("palav".to_string()), _ => None, }) .as_deref(), Some("palav") ); assert_eq!( local_username_from_env(|name| match name { "USERNAME" => Some("palav-win".to_string()), _ => None, }) .as_deref(), Some("palav-win") ); assert_eq!( local_username_from_env(|name| match name { "USER" => Some(String::new()), "USERNAME" => Some("palav-win".to_string()), _ => None, }) .as_deref(), Some("palav-win") ); } #[test] fn first_resolved_addr_uses_dns_order_and_reports_empty_results() { let addrs = [ "127.0.0.1:50000".parse().unwrap(), "[::1]:50000".parse().unwrap(), ]; assert_eq!( first_resolved_addr(&addrs, "example.test", 50000).unwrap(), addrs[0] ); assert!( first_resolved_addr(&[], "example.test", 50000) .unwrap_err() .to_string() .contains("no UDP address resolved for example.test:50000") ); } #[test] fn sdk_udp_host_follows_ssh_config_hostname_by_default() { let ssh_config = SdkSshConfig { hostname: Some("10.0.0.5".to_string()), ..SdkSshConfig::default() }; assert_eq!( selected_sdk_udp_host( None, &HostConfig::default(), &ClientConfig::default(), "prod", &ssh_config, ) .unwrap(), "10.0.0.5" ); } #[test] fn sdk_udp_host_preserves_bracketed_ipv6_destination_without_ssh_config() { assert_eq!( selected_sdk_udp_host( None, &HostConfig::default(), &ClientConfig::default(), "deploy@[2001:db8::5]:2222", &SdkSshConfig::default(), ) .unwrap(), "2001:db8::5" ); } #[test] fn sdk_udp_host_honors_cli_compatible_special_values() { let ssh_config = SdkSshConfig { hostname: Some("10.0.0.5".to_string()), ..SdkSshConfig::default() }; let host = HostConfig { dosh_host: Some("ssh".to_string()), ..HostConfig::default() }; assert_eq!( selected_sdk_udp_host(None, &host, &ClientConfig::default(), "prod", &ssh_config) .unwrap(), "10.0.0.5" ); assert_eq!( selected_sdk_udp_host( Some("localhost"), &host, &ClientConfig::default(), "prod", &ssh_config, ) .unwrap(), "127.0.0.1" ); assert!( selected_sdk_udp_host( Some("any"), &host, &ClientConfig::default(), "prod", &ssh_config, ) .is_err() ); } #[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\ sendenv LANG LC_*\n\ setenv DOSH_MODE=sdk DOSH_COLOR=true BAD-NAME=nope\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); assert_eq!(parsed.send_env, vec!["LANG", "LC_*"]); assert_eq!( parsed.set_env, vec![ EnvVar { name: "DOSH_MODE".to_string(), value: "sdk".to_string() }, EnvVar { name: "DOSH_COLOR".to_string(), value: "true".to_string() } ] ); } #[test] fn sdk_ssh_config_without_alias_does_not_shell_out() { assert_eq!( load_sdk_ssh_config(None, Some(2222)).unwrap(), SdkSshConfig::default() ); } #[test] fn sdk_ssh_config_lookup_uses_raw_target_by_default() { let host = HostConfig::default(); assert_eq!( sdk_ssh_config_lookup(&host, "deploy@prod"), ("deploy@prod", false) ); } #[test] fn sdk_ssh_config_lookup_prefers_explicit_alias() { let host = HostConfig { ssh_config: Some(" prod ".to_string()), ..HostConfig::default() }; assert_eq!(sdk_ssh_config_lookup(&host, "deploy@raw"), ("prod", true)); } #[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, ..SdkSshConfig::default() }; 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![config_identity, dir.path().join("ssh")]); } #[test] fn sdk_requested_env_merges_host_ssh_and_explicit_overrides() { let mut config = ClientConfig::default(); config.send_env.clear(); config.set_env.insert("DOSH_MODE".into(), "client".into()); config.set_env.insert("DOSH_KEEP".into(), "yes".into()); let mut host = HostConfig::default(); host.set_env.insert("DOSH_MODE".into(), "host".into()); let ssh_config = SdkSshConfig { set_env: vec![EnvVar { name: "DOSH_SSH".to_string(), value: "true".to_string(), }], ..SdkSshConfig::default() }; assert_eq!( sdk_requested_env( &config, &host, &ssh_config, vec![ EnvVar { name: "DOSH_MODE".to_string(), value: "explicit".to_string(), }, EnvVar { name: "BAD-NAME".to_string(), value: "ignored".to_string(), } ], ), vec![ EnvVar { name: "DOSH_KEEP".to_string(), value: "yes".to_string() }, EnvVar { name: "DOSH_MODE".to_string(), value: "explicit".to_string() }, EnvVar { name: "DOSH_SSH".to_string(), value: "true".to_string() } ] ); } #[test] fn sdk_env_globs_match_send_env_patterns() { assert!(glob_matches("LC_*", "LC_ALL")); assert!(glob_matches("TERM", "TERM")); assert!(!glob_matches("LC_*", "LANG")); } #[test] fn default_identity_paths_are_expanded() { assert!( default_identity_paths() .iter() .any(|path| path.ends_with(".ssh/id_ed25519")) ); } }