From f66de64b3f7e698ce4bc6df3ba1b4514c45dd2c5 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:32:45 -0400 Subject: [PATCH] Support host-config login users --- docs/NATIVE_V1_SPEC.md | 2 +- src/bin/dosh-client.rs | 43 +++++++++++++++++++++++++++++++++++++----- src/config.rs | 1 + 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/docs/NATIVE_V1_SPEC.md b/docs/NATIVE_V1_SPEC.md index bde2365..55825d1 100644 --- a/docs/NATIVE_V1_SPEC.md +++ b/docs/NATIVE_V1_SPEC.md @@ -69,7 +69,7 @@ Must work in v1: - Encrypted OpenSSH private-key authentication with prompt. - `~/.ssh/config` host aliases for `Host`, `HostName`, `User`, `Port`, `IdentityFile`, `ProxyJump`, `UserKnownHostsFile`, and `IdentitiesOnly`. -- Dosh host config overrides for Dosh-specific UDP host/port/auth policy. +- Dosh host config overrides for user, Dosh-specific UDP host/port/auth policy. - Clear host-key trust and mismatch errors. - Clear auth failure errors that name the attempted key source. - Stable reconnect after sleep, network switch, NAT rebinding, and server packet loss. diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 1e751d7..7d54774 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -173,7 +173,8 @@ async fn main() -> Result<()> { .get(&requested_server) .cloned() .unwrap_or_default(); - let server = host.ssh.clone().unwrap_or_else(|| requested_server.clone()); + let raw_server = host.ssh.clone().unwrap_or_else(|| requested_server.clone()); + let server = ssh_with_user(&raw_server, host.user.as_deref()); let startup_command = startup_command(&args.command).or_else(|| { host.default_command .as_deref() @@ -490,7 +491,8 @@ fn run_trust_command(config: &dosh::config::ClientConfig, args: &Args) -> Result let hosts = load_hosts_config(None).unwrap_or_default(); let host_config = hosts.hosts.get(host).cloned().unwrap_or_default(); - let server = host_config.ssh.clone().unwrap_or_else(|| host.to_string()); + let raw_server = host_config.ssh.clone().unwrap_or_else(|| host.to_string()); + let server = ssh_with_user(&raw_server, host_config.user.as_deref()); let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port); let ssh_auth_command = args .ssh_auth_command @@ -531,7 +533,8 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) -> .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 raw_server = host_config.ssh.clone().unwrap_or_else(|| requested.clone()); + let server = ssh_with_user(&raw_server, host_config.user.as_deref()); let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port); let dosh_port = args .dosh_port @@ -994,6 +997,9 @@ fn run_import_ssh(config: &dosh::config::ClientConfig, aliases: &[String]) -> Re raw.push_str(&format!("ssh = {}\n", toml_string(alias))); raw.push_str(&format!("dosh_host = {}\n", toml_string(&udp_host))); raw.push_str(&format!("port = {}\n", config.dosh_port)); + if let Some(user) = &ssh_config.user { + raw.push_str(&format!("user = {}\n", toml_string(user))); + } if let Some(port) = ssh_config.port && port != 22 { @@ -1183,6 +1189,17 @@ fn ssh_username(server: &str) -> Option { } } +fn ssh_with_user(server: &str, user: Option<&str>) -> String { + let Some(user) = user else { + return server.to_string(); + }; + if user.is_empty() || ssh_username(server).is_some() || server.starts_with("ssh://") { + server.to_string() + } else { + format!("{user}@{server}") + } +} + #[derive(Debug, Clone, Default)] struct SshConfig { hostname: Option, @@ -2964,8 +2981,8 @@ mod tests { DynamicForward, FrameBuffer, LocalForward, Predictor, RemoteForward, SshConfig, auth_allows, load_first_native_identity_with_prompt, parse_dynamic_forward, parse_local_forward, parse_remote_forward, parse_ssh_config, raw_contains_host_table, - recv_response_until, requested_env, ssh_destination_host, ssh_username, startup_command, - toml_bare_key_or_quoted, + recv_response_until, requested_env, ssh_destination_host, ssh_username, ssh_with_user, + startup_command, toml_bare_key_or_quoted, }; use dosh::config::{ClientConfig, HostConfig}; use dosh::native::EnvVar; @@ -3108,6 +3125,22 @@ mod tests { assert_eq!(ssh_username("example.com"), None); } + #[test] + fn applies_host_config_user_to_plain_ssh_destination() { + assert_eq!( + ssh_with_user("example.com", Some("palav")), + "palav@example.com" + ); + assert_eq!( + ssh_with_user("root@example.com", Some("palav")), + "root@example.com" + ); + assert_eq!( + ssh_with_user("ssh://example.com", Some("palav")), + "ssh://example.com" + ); + } + #[test] fn auth_preference_accepts_auto_or_named_method() { assert!(auth_allows("native,ssh", "native")); diff --git a/src/config.rs b/src/config.rs index 5b8f8f9..c51bf75 100644 --- a/src/config.rs +++ b/src/config.rs @@ -106,6 +106,7 @@ pub struct ClientConfig { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct HostConfig { pub ssh: Option, + pub user: Option, pub dosh_host: Option, pub port: Option, pub ssh_port: Option,