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 anyhow::{Context, Result, anyhow, bail}; use std::net::{SocketAddr, ToSocketAddrs}; use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::net::UdpSocket; #[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 requested_user = self .user .clone() .or_else(|| host_config.user.clone()) .or_else(|| user_from_destination(&raw_server)) .or_else(|| std::env::var("USER").ok()) .unwrap_or_else(|| "unknown".to_string()); let udp_host = self .udp_host .clone() .or_else(|| host_config.dosh_host.clone()) .or_else(|| self.client.config.dosh_host.clone()) .unwrap_or_else(|| destination_host(&raw_server)); let udp_port = self .udp_port .or(host_config.port) .unwrap_or(self.client.config.dosh_port); let peer_addr = resolve_addr(&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_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 socket = UdpSocket::bind("0.0.0.0:0").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: self.host.clone(), requested_user, requested_session: session.clone(), 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: self.env, }; let packet = protocol::encode_plain( PacketKind::NativeClientHello, [0u8; 16], 1, 0, &protocol::to_body(&NativeClientHelloBody { hello: hello.clone(), })?, )?; socket.send_to(&packet, peer_addr).await?; let mut buf = vec![0u8; 65535]; let (n, _) = tokio::time::timeout(timeout, socket.recv_from(&mut buf)).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( &self.client.config, &self.host, &server_hello.hello.host_key, self.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( &self.client.config, &host_config, &hello, &server_hello.hello, requested_forwardings, self.identity_files, self.use_ssh_agent, )?; 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 })?, )?; socket.send_to(&auth_packet, peer_addr).await?; let (n, _) = tokio::time::timeout(timeout, socket.recv_from(&mut buf)).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: self.host, session: ok.ok.session, transport, }) } } pub struct ConnectedDoshClient { pub host: String, pub session: String, pub transport: DoshTransport, } impl ConnectedDoshClient { pub fn into_transport(self) -> DoshTransport { self.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}" )), } } fn sign_auth( config: &ClientConfig, host_config: &HostConfig, hello: &NativeClientHello, server_hello: &native::NativeServerHello, requested_forwardings: Vec, explicit_identity_files: Vec, use_ssh_agent: Option, ) -> Result { let use_agent = use_ssh_agent.unwrap_or(config.use_ssh_agent); let mut errors = Vec::new(); if use_agent { 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:#}")), } } 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() { 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())), } } let _ = host_config; Err(anyhow!( "native auth found no usable identity: {}", errors.join("; ") )) } fn default_identity_paths() -> Vec { ["~/.ssh/id_ed25519", "~/.ssh/id_ecdsa", "~/.ssh/id_rsa"] .into_iter() .map(expand_tilde) .collect() } fn resolve_addr(host: &str, port: u16) -> Result { (host, port) .to_socket_addrs() .with_context(|| format!("resolve UDP target {host}:{port}"))? .next() .ok_or_else(|| anyhow!("no UDP address resolved for {host}:{port}")) } fn destination_host(destination: &str) -> String { destination .rsplit('@') .next() .unwrap_or(destination) .split(':') .next() .unwrap_or(destination) .to_string() } fn user_from_destination(destination: &str) -> Option { destination .rsplit_once('@') .map(|(user, _)| user.to_string()) .filter(|user| !user.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!(destination_host("palav@example.com"), "example.com"); assert_eq!(destination_host("example.com:2222"), "example.com"); } #[test] fn default_identity_paths_are_expanded() { assert!( default_identity_paths() .iter() .any(|path| path.ends_with(".ssh/id_ed25519")) ); } }