From 0575cf678f39c9405ce5bf8c7dc1ff7e90c7b47c Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:01:27 -0400 Subject: [PATCH] Add native doctor auth diagnostics --- src/bin/dosh-client.rs | 249 ++++++++++++++++++++++++++++++++++++- src/bin/dosh-server.rs | 39 +++++- src/protocol.rs | 12 ++ tests/integration_smoke.rs | 31 +++++ 4 files changed, 325 insertions(+), 6 deletions(-) diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index c90fbf2..1526799 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -16,9 +16,10 @@ use dosh::native::{ }; use dosh::protocol::{ self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, - NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind, - Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose, StreamData, StreamOpen, StreamOpenOk, - StreamOpenReject, TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope, + NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, + NativeUserAuthBody, PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose, + StreamData, StreamOpen, StreamOpenOk, StreamOpenReject, TicketAttachBody, TicketAttachEnvelope, + TicketAttachOkEnvelope, }; use dosh::ssh_agent; use serde::{Deserialize, Serialize}; @@ -162,6 +163,9 @@ async fn main() -> Result<()> { if args.server.as_deref() == Some("trust") { return run_trust_command(&config, &args); } + if args.server.as_deref() == Some("doctor") { + return run_doctor_command(&config, &args).await; + } let requested_server = args.server.clone().unwrap_or_else(|| config.server.clone()); let hosts = load_hosts_config(None).unwrap_or_default(); let host = hosts @@ -492,6 +496,119 @@ fn run_trust_command(config: &dosh::config::ClientConfig, args: &Args) -> Result Ok(()) } +async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { + let requested = args + .command + .first() + .cloned() + .ok_or_else(|| anyhow!("usage: dosh doctor "))?; + let hosts = load_hosts_config(None).unwrap_or_default(); + let host_config = hosts.hosts.get(&requested).cloned().unwrap_or_default(); + let server = host_config.ssh.clone().unwrap_or_else(|| requested.clone()); + let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port); + let dosh_port = args + .dosh_port + .or(host_config.port) + .unwrap_or(config.dosh_port); + println!("Dosh doctor for {requested}"); + + 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={}", + hostname, + parsed.user.as_deref().unwrap_or(""), + parsed.port.unwrap_or(ssh_port.unwrap_or(22)) + ); + } + Err(err) => println!("[fail] ssh config: {err:#}"), + } + + match ssh_fallback_probe( + &server, + ssh_port, + args.ssh_key.as_deref(), + args.ssh_known_hosts.as_deref(), + ) { + Ok(()) => println!("[ok] ssh fallback: reachable"), + Err(err) => println!("[warn] ssh fallback: {err:#}"), + } + + let udp_host = args + .dosh_host + .clone() + .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)) + }); + println!("[info] native endpoint: {udp_host}:{dosh_port}"); + let socket = UdpSocket::bind("0.0.0.0:0").await?; + match try_native_auth_check( + &socket, + config, + &requested, + &server, + ssh_port, + &udp_host, + dosh_port, + args.ssh_key.as_deref(), + ) + .await + { + Ok(check) => { + println!("[ok] native udp: reachable"); + println!("[ok] known host: trusted"); + println!("[ok] native auth: authorized as {}", check.requested_user); + println!( + "[ok] forwarding policy: tcp={} remote={} agent={}", + check.allow_tcp_forwarding, + check.allow_remote_forwarding, + check.allow_agent_forwarding + ); + Ok(()) + } + Err(err) => { + println!("[fail] native/auth check: {err:#}"); + Err(err.context("dosh doctor failed")) + } + } +} + +fn ssh_fallback_probe( + server: &str, + ssh_port: Option, + ssh_key: Option<&Path>, + ssh_known_hosts: Option<&Path>, +) -> Result<()> { + let mut command = Command::new("ssh"); + command.arg("-o").arg("BatchMode=yes"); + command.arg("-o").arg("ConnectTimeout=5"); + command.arg("-T"); + if let Some(ssh_port) = ssh_port { + command.arg("-p").arg(ssh_port.to_string()); + } + if let Some(key) = ssh_key { + command.arg("-i").arg(key); + } + if let Some(known_hosts) = ssh_known_hosts { + command + .arg("-o") + .arg(format!("UserKnownHostsFile={}", known_hosts.display())); + } + let output = command.arg(server).arg("true").output()?; + anyhow::ensure!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} + fn fetch_host_key_over_ssh( server: &str, ssh_port: Option, @@ -1241,6 +1358,132 @@ async fn try_native_auth( Ok((frame, cred)) } +async fn try_native_auth_check( + socket: &UdpSocket, + config: &dosh::config::ClientConfig, + requested_server: &str, + server: &str, + ssh_port: Option, + udp_host: &str, + udp_port: u16, + cli_identity: Option<&Path>, +) -> Result { + let addr = resolve_addr(udp_host, udp_port)?; + let ssh_config = ssh_config(server, ssh_port).unwrap_or_default(); + let requested_user = ssh_username(server) + .or(ssh_config.user.clone()) + .unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string())); + let (client_secret, client_public) = generate_native_ephemeral(); + let hello = dosh::native::NativeClientHello { + protocol_version: dosh::native::NATIVE_PROTOCOL_VERSION, + client_random: crypto::random_32(), + client_ephemeral_public: client_public, + requested_host: requested_server.to_string(), + requested_user, + requested_session: "doctor".to_string(), + requested_mode: "doctor".to_string(), + terminal_size: (80, 24), + supported_aead: vec!["chacha20poly1305".to_string()], + supported_user_key_algorithms: vec!["ssh-ed25519".to_string()], + cached_host_key_fingerprint: None, + attach_ticket_envelope: None, + }; + let packet = protocol::encode_plain( + PacketKind::NativeClientHello, + [0u8; 16], + 1, + 0, + &protocol::to_body(&NativeClientHelloBody { + hello: hello.clone(), + })?, + )?; + socket.send_to(&packet, addr).await?; + + let mut buf = vec![0u8; 65535]; + let (n, _) = tokio::time::timeout( + Duration::from_millis(config.native_auth_timeout_ms.max(1)), + 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)?; + return Err(anyhow!("native doctor rejected: {}", reject.reason)); + } + return Err(anyhow!("native doctor received unexpected server response")); + } + let server_hello: NativeServerHelloBody = protocol::from_body(&packet.body)?; + verify_server_hello(&hello, &server_hello.hello)?; + + let known_hosts = expand_tilde(&config.known_hosts); + match verify_known_host(&known_hosts, requested_server, &server_hello.hello.host_key)? { + KnownHostStatus::Trusted => {} + KnownHostStatus::Unknown if config.trust_on_first_use => { + trust_host( + &known_hosts, + requested_server, + &server_hello.hello.host_key, + "tofu", + false, + )?; + } + KnownHostStatus::Unknown => { + return Err(anyhow!( + "Dosh host key for {requested_server} is not trusted; run `dosh trust {requested_server}` first" + )); + } + KnownHostStatus::Mismatch { expected, actual } => { + return Err(anyhow!( + "Dosh host key mismatch for {requested_server}: expected {expected}, got {actual}" + )); + } + } + + let session_key = derive_native_session_key( + &client_secret, + server_hello.hello.server_ephemeral_public, + &hello, + &server_hello.hello, + )?; + let auth = sign_native_user_auth( + config, + cli_identity, + &ssh_config, + &hello, + &server_hello.hello, + Vec::new(), + )?; + let mut pending_id = [0u8; 16]; + pending_id.copy_from_slice(&server_hello.hello.auth_challenge[..16]); + let packet = protocol::encode_encrypted( + PacketKind::NativeUserAuth, + pending_id, + 2, + 1, + &session_key, + CLIENT_TO_SERVER, + &protocol::to_body(&NativeUserAuthBody { auth })?, + )?; + socket.send_to(&packet, addr).await?; + + let (n, _) = tokio::time::timeout( + Duration::from_millis(config.native_auth_timeout_ms.max(1)), + socket.recv_from(&mut buf), + ) + .await??; + let packet = protocol::decode(&buf[..n])?; + if packet.header.kind != PacketKind::NativeAuthCheckOk { + if packet.header.kind == PacketKind::AttachReject { + let reject: AttachReject = protocol::from_body(&packet.body)?; + return Err(anyhow!("native doctor rejected: {}", reject.reason)); + } + return Err(anyhow!("native doctor received unexpected auth response")); + } + let plain = protocol::decrypt_body(&packet, &session_key, SERVER_TO_CLIENT)?; + protocol::from_body(&plain) +} + fn sign_native_user_auth( config: &dosh::config::ClientConfig, cli_identity: Option<&Path>, diff --git a/src/bin/dosh-server.rs b/src/bin/dosh-server.rs index 00a0ff6..732bfda 100644 --- a/src/bin/dosh-server.rs +++ b/src/bin/dosh-server.rs @@ -13,9 +13,10 @@ use dosh::native::{ }; use dosh::protocol::{ self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, - NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind, - ReplayWindow, Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose, StreamData, StreamOpen, - StreamOpenOk, StreamOpenReject, TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope, + NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, + NativeUserAuthBody, PacketKind, ReplayWindow, Resize, ResumeRequest, SERVER_TO_CLIENT, + StreamClose, StreamData, StreamOpen, StreamOpenOk, StreamOpenReject, TicketAttachBody, + TicketAttachEnvelope, TicketAttachOkEnvelope, }; use dosh::pty::{PtyHandle, PtyOutput, spawn_pty_session}; use std::collections::{HashMap, VecDeque}; @@ -402,6 +403,38 @@ async fn handle_native_user_auth( } let body = protocol::decrypt_body(packet, &pending.session_key, CLIENT_TO_SERVER)?; let req: NativeUserAuthBody = protocol::from_body(&body)?; + if pending.client.requested_mode == "doctor" { + let check = { + let locked = state.lock().expect("server state poisoned"); + verify_native_user_auth_from_config( + &locked.config, + &pending.client, + &pending.server, + &req.auth, + ) + .context("verify native user auth")?; + NativeAuthCheckOkBody { + requested_user: pending.client.requested_user.clone(), + native_auth_enabled: locked.config.native_auth, + allow_tcp_forwarding: locked.config.allow_tcp_forwarding, + allow_remote_forwarding: locked.config.allow_remote_forwarding, + allow_agent_forwarding: locked.config.allow_agent_forwarding, + policy_flags: Vec::new(), + } + }; + let body = protocol::to_body(&check)?; + let out = protocol::encode_encrypted( + PacketKind::NativeAuthCheckOk, + packet.header.conn_id, + 1, + packet.header.seq, + &pending.session_key, + SERVER_TO_CLIENT, + &body, + )?; + socket.send_to(&out, peer).await?; + return Ok(()); + } let attached: Result<( [u8; 16], diff --git a/src/protocol.rs b/src/protocol.rs index ac54fb9..ad0899b 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -38,6 +38,7 @@ pub enum PacketKind { StreamWindowAdjust = 22, StreamEof = 23, StreamClose = 24, + NativeAuthCheckOk = 25, } impl TryFrom for PacketKind { @@ -69,6 +70,7 @@ impl TryFrom for PacketKind { 22 => Self::StreamWindowAdjust, 23 => Self::StreamEof, 24 => Self::StreamClose, + 25 => Self::NativeAuthCheckOk, _ => bail!("unknown packet kind {value}"), }) } @@ -276,6 +278,16 @@ pub struct NativeAuthOkBody { pub ok: NativeAuthOk, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NativeAuthCheckOkBody { + pub requested_user: String, + pub native_auth_enabled: bool, + pub allow_tcp_forwarding: bool, + pub allow_remote_forwarding: bool, + pub allow_agent_forwarding: bool, + pub policy_flags: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AttachOk { pub client_id: [u8; 16], diff --git a/tests/integration_smoke.rs b/tests/integration_smoke.rs index e248b9c..07ccdc6 100644 --- a/tests/integration_smoke.rs +++ b/tests/integration_smoke.rs @@ -574,6 +574,37 @@ fn native_attach_only_smoke_uses_ssh_agent() { assert!(stderr.contains("terminal_ready"), "stderr={stderr}"); } +#[test] +fn native_doctor_checks_auth_without_opening_terminal() { + let dir = tempfile::tempdir().unwrap(); + let port = free_udp_port(); + let config = write_server_config(&dir, port); + write_native_client_auth(&dir, &config); + let mut server = start_server(&dir, &config); + let client = env!("CARGO_BIN_EXE_dosh-client"); + let output = Command::new(client) + .arg("doctor") + .arg("--auth") + .arg("native") + .arg("--dosh-host") + .arg("127.0.0.1") + .arg("--dosh-port") + .arg(port.to_string()) + .arg("local") + .env("HOME", dir.path()) + .output() + .unwrap(); + let _ = server.kill(); + let _ = server.wait(); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "stdout={stdout}\nstderr={stderr}"); + assert!(stdout.contains("[ok] native udp"), "stdout={stdout}"); + assert!(stdout.contains("[ok] native auth"), "stdout={stdout}"); + assert!(stdout.contains("[ok] forwarding policy"), "stdout={stdout}"); +} + #[test] fn native_local_forward_echo_smoke() { let dir = tempfile::tempdir().unwrap();