Add native doctor auth diagnostics
This commit is contained in:
+246
-3
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user