Add native doctor auth diagnostics
ci / test (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
DuProcess
2026-06-13 22:01:27 -04:00
parent 6d1e9d3065
commit 0575cf678f
4 changed files with 325 additions and 6 deletions
+246 -3
View File
@@ -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 <host>"))?;
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("<default>"),
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<u16>,
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<u16>,
@@ -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<u16>,
udp_host: &str,
udp_port: u16,
cli_identity: Option<&Path>,
) -> Result<NativeAuthCheckOkBody> {
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>,
+36 -3
View File
@@ -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],