Add native doctor auth diagnostics
This commit is contained in:
+246
-3
@@ -16,9 +16,10 @@ use dosh::native::{
|
|||||||
};
|
};
|
||||||
use dosh::protocol::{
|
use dosh::protocol::{
|
||||||
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
|
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
|
||||||
NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind,
|
NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody,
|
||||||
Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose, StreamData, StreamOpen, StreamOpenOk,
|
NativeUserAuthBody, PacketKind, Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose,
|
||||||
StreamOpenReject, TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope,
|
StreamData, StreamOpen, StreamOpenOk, StreamOpenReject, TicketAttachBody, TicketAttachEnvelope,
|
||||||
|
TicketAttachOkEnvelope,
|
||||||
};
|
};
|
||||||
use dosh::ssh_agent;
|
use dosh::ssh_agent;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -162,6 +163,9 @@ async fn main() -> Result<()> {
|
|||||||
if args.server.as_deref() == Some("trust") {
|
if args.server.as_deref() == Some("trust") {
|
||||||
return run_trust_command(&config, &args);
|
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 requested_server = args.server.clone().unwrap_or_else(|| config.server.clone());
|
||||||
let hosts = load_hosts_config(None).unwrap_or_default();
|
let hosts = load_hosts_config(None).unwrap_or_default();
|
||||||
let host = hosts
|
let host = hosts
|
||||||
@@ -492,6 +496,119 @@ fn run_trust_command(config: &dosh::config::ClientConfig, args: &Args) -> Result
|
|||||||
Ok(())
|
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(
|
fn fetch_host_key_over_ssh(
|
||||||
server: &str,
|
server: &str,
|
||||||
ssh_port: Option<u16>,
|
ssh_port: Option<u16>,
|
||||||
@@ -1241,6 +1358,132 @@ async fn try_native_auth(
|
|||||||
Ok((frame, cred))
|
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(
|
fn sign_native_user_auth(
|
||||||
config: &dosh::config::ClientConfig,
|
config: &dosh::config::ClientConfig,
|
||||||
cli_identity: Option<&Path>,
|
cli_identity: Option<&Path>,
|
||||||
|
|||||||
+36
-3
@@ -13,9 +13,10 @@ use dosh::native::{
|
|||||||
};
|
};
|
||||||
use dosh::protocol::{
|
use dosh::protocol::{
|
||||||
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
|
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
|
||||||
NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind,
|
NativeAuthCheckOkBody, NativeAuthOkBody, NativeClientHelloBody, NativeServerHelloBody,
|
||||||
ReplayWindow, Resize, ResumeRequest, SERVER_TO_CLIENT, StreamClose, StreamData, StreamOpen,
|
NativeUserAuthBody, PacketKind, ReplayWindow, Resize, ResumeRequest, SERVER_TO_CLIENT,
|
||||||
StreamOpenOk, StreamOpenReject, TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope,
|
StreamClose, StreamData, StreamOpen, StreamOpenOk, StreamOpenReject, TicketAttachBody,
|
||||||
|
TicketAttachEnvelope, TicketAttachOkEnvelope,
|
||||||
};
|
};
|
||||||
use dosh::pty::{PtyHandle, PtyOutput, spawn_pty_session};
|
use dosh::pty::{PtyHandle, PtyOutput, spawn_pty_session};
|
||||||
use std::collections::{HashMap, VecDeque};
|
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 body = protocol::decrypt_body(packet, &pending.session_key, CLIENT_TO_SERVER)?;
|
||||||
let req: NativeUserAuthBody = protocol::from_body(&body)?;
|
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<(
|
let attached: Result<(
|
||||||
[u8; 16],
|
[u8; 16],
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ pub enum PacketKind {
|
|||||||
StreamWindowAdjust = 22,
|
StreamWindowAdjust = 22,
|
||||||
StreamEof = 23,
|
StreamEof = 23,
|
||||||
StreamClose = 24,
|
StreamClose = 24,
|
||||||
|
NativeAuthCheckOk = 25,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<u8> for PacketKind {
|
impl TryFrom<u8> for PacketKind {
|
||||||
@@ -69,6 +70,7 @@ impl TryFrom<u8> for PacketKind {
|
|||||||
22 => Self::StreamWindowAdjust,
|
22 => Self::StreamWindowAdjust,
|
||||||
23 => Self::StreamEof,
|
23 => Self::StreamEof,
|
||||||
24 => Self::StreamClose,
|
24 => Self::StreamClose,
|
||||||
|
25 => Self::NativeAuthCheckOk,
|
||||||
_ => bail!("unknown packet kind {value}"),
|
_ => bail!("unknown packet kind {value}"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -276,6 +278,16 @@ pub struct NativeAuthOkBody {
|
|||||||
pub ok: NativeAuthOk,
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AttachOk {
|
pub struct AttachOk {
|
||||||
pub client_id: [u8; 16],
|
pub client_id: [u8; 16],
|
||||||
|
|||||||
@@ -574,6 +574,37 @@ fn native_attach_only_smoke_uses_ssh_agent() {
|
|||||||
assert!(stderr.contains("terminal_ready"), "stderr={stderr}");
|
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]
|
#[test]
|
||||||
fn native_local_forward_echo_smoke() {
|
fn native_local_forward_echo_smoke() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user