Honor more SSH config in native auth
ci / test (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
DuProcess
2026-06-13 22:13:47 -04:00
parent af5e9f7956
commit 7fec3592f5
+150 -19
View File
@@ -231,6 +231,7 @@ async fn main() -> Result<()> {
let allow_cache = !args.no_cache && !forwarding_requested;
let started = Instant::now();
let mut resolved_ssh_config = None;
let target_udp_host = if let Some(host) = args
.dosh_host
.clone()
@@ -241,14 +242,25 @@ async fn main() -> Result<()> {
} else if args.local_auth {
"127.0.0.1".to_string()
} else {
ssh_config_hostname(&server, ssh_port).unwrap_or_else(|err| {
match ssh_config(&server, ssh_port) {
Ok(parsed) => {
let hostname = parsed
.hostname
.clone()
.unwrap_or_else(|| ssh_destination_host(&server));
resolved_ssh_config = Some(parsed);
hostname
}
Err(err) => {
log_debug(
args.verbose,
2,
&format!("dosh could not resolve SSH config hostname, using {server}: {err:#}"),
);
resolved_ssh_config = Some(SshConfig::default());
ssh_destination_host(&server)
})
}
}
};
let credential = if allow_cache {
@@ -348,6 +360,7 @@ async fn main() -> Result<()> {
cols,
rows,
forwarding_requests(&local_forwards, &remote_forwards, &dynamic_forwards),
resolved_ssh_config.as_ref(),
)
.await
{
@@ -512,21 +525,31 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) ->
.unwrap_or(config.dosh_port);
println!("Dosh doctor for {requested}");
match ssh_config(&server, ssh_port) {
let parsed_ssh_config = 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={}",
"[ok] ssh config: host={} user={} port={} identities_only={} proxy={}",
hostname,
parsed.user.as_deref().unwrap_or("<default>"),
parsed.port.unwrap_or(ssh_port.unwrap_or(22))
parsed.port.unwrap_or(ssh_port.unwrap_or(22)),
parsed.identities_only,
parsed
.proxy_jump
.as_deref()
.or(parsed.proxy_command.as_deref())
.unwrap_or("<none>")
);
parsed
}
Err(err) => println!("[fail] ssh config: {err:#}"),
Err(err) => {
println!("[fail] ssh config: {err:#}");
SshConfig::default()
}
};
match ssh_fallback_probe(
&server,
@@ -544,7 +567,10 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) ->
.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))
parsed_ssh_config
.hostname
.clone()
.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?;
@@ -557,6 +583,7 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) ->
&udp_host,
dosh_port,
args.ssh_key.as_deref(),
Some(&parsed_ssh_config),
)
.await
{
@@ -1142,18 +1169,16 @@ fn ssh_username(server: &str) -> Option<String> {
}
}
fn ssh_config_hostname(server: &str, ssh_port: Option<u16>) -> Result<String> {
ssh_config(server, ssh_port)?
.hostname
.ok_or_else(|| anyhow!("ssh -G did not print hostname"))
}
#[derive(Debug, Clone, Default)]
struct SshConfig {
hostname: Option<String>,
port: Option<u16>,
user: Option<String>,
identity_files: Vec<String>,
user_known_hosts_file: Option<String>,
identities_only: bool,
proxy_jump: Option<String>,
proxy_command: Option<String>,
}
fn ssh_config(server: &str, ssh_port: Option<u16>) -> Result<SshConfig> {
@@ -1184,19 +1209,31 @@ fn parse_ssh_config(raw: &str) -> SshConfig {
continue;
};
let value = value.trim();
if key.eq_ignore_ascii_case("hostname") && !value.is_empty() {
if key.eq_ignore_ascii_case("hostname") && !empty_or_none(value) {
config.hostname = Some(value.to_string());
} else if key.eq_ignore_ascii_case("port") {
config.port = value.parse().ok();
} else if key.eq_ignore_ascii_case("user") && !value.is_empty() {
} else if key.eq_ignore_ascii_case("user") && !empty_or_none(value) {
config.user = Some(value.to_string());
} else if key.eq_ignore_ascii_case("identityfile") && !value.is_empty() {
} else if key.eq_ignore_ascii_case("identityfile") && !empty_or_none(value) {
config.identity_files.push(value.to_string());
} else if key.eq_ignore_ascii_case("userknownhostsfile") && !empty_or_none(value) {
config.user_known_hosts_file = Some(value.to_string());
} else if key.eq_ignore_ascii_case("identitiesonly") {
config.identities_only = value.eq_ignore_ascii_case("yes");
} else if key.eq_ignore_ascii_case("proxyjump") && !empty_or_none(value) {
config.proxy_jump = Some(value.to_string());
} else if key.eq_ignore_ascii_case("proxycommand") && !empty_or_none(value) {
config.proxy_command = Some(value.to_string());
}
}
config
}
fn empty_or_none(value: &str) -> bool {
value.is_empty() || value.eq_ignore_ascii_case("none")
}
fn resolve_addr(host: &str, port: u16) -> Result<SocketAddr> {
(host, port)
.to_socket_addrs()
@@ -1219,9 +1256,16 @@ async fn try_native_auth(
cols: u16,
rows: u16,
requested_forwardings: Vec<ForwardingRequest>,
ssh_config_hint: Option<&SshConfig>,
) -> Result<(Frame, CachedCredential)> {
let addr = resolve_addr(udp_host, udp_port)?;
let ssh_config = ssh_config(server, ssh_port).unwrap_or_default();
let owned_ssh_config;
let ssh_config = if let Some(ssh_config) = ssh_config_hint {
ssh_config
} else {
owned_ssh_config = ssh_config(server, ssh_port).unwrap_or_default();
&owned_ssh_config
};
let requested_user = ssh_username(server)
.or(ssh_config.user.clone())
.unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()));
@@ -1367,9 +1411,16 @@ async fn try_native_auth_check(
udp_host: &str,
udp_port: u16,
cli_identity: Option<&Path>,
ssh_config_hint: Option<&SshConfig>,
) -> Result<NativeAuthCheckOkBody> {
let addr = resolve_addr(udp_host, udp_port)?;
let ssh_config = ssh_config(server, ssh_port).unwrap_or_default();
let owned_ssh_config;
let ssh_config = if let Some(ssh_config) = ssh_config_hint {
ssh_config
} else {
owned_ssh_config = ssh_config(server, ssh_port).unwrap_or_default();
&owned_ssh_config
};
let requested_user = ssh_username(server)
.or(ssh_config.user.clone())
.unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()));
@@ -1493,7 +1544,7 @@ fn sign_native_user_auth(
requested_forwardings: Vec<ForwardingRequest>,
) -> Result<dosh::native::NativeUserAuth> {
let mut errors = Vec::new();
if config.use_ssh_agent {
if config.use_ssh_agent && !ssh_config.identities_only {
match ssh_agent::sign_user_auth_with_agent(
hello,
server_hello,
@@ -1502,6 +1553,8 @@ fn sign_native_user_auth(
Ok(auth) => return Ok(auth),
Err(err) => errors.push(format!("ssh-agent: {err:#}")),
}
} else if config.use_ssh_agent && ssh_config.identities_only {
errors.push("ssh-agent: skipped because SSH config sets IdentitiesOnly=yes".to_string());
}
match load_first_native_identity(config, cli_identity, ssh_config) {
@@ -1545,9 +1598,11 @@ where
for path in &ssh_config.identity_files {
push_identity_path(&mut paths, expand_tilde(path));
}
if !ssh_config.identities_only {
for path in &config.identity_files {
push_identity_path(&mut paths, expand_tilde(path));
}
}
let mut errors = Vec::new();
for path in paths {
@@ -2786,6 +2841,29 @@ mod tests {
);
}
#[test]
fn parses_ssh_config_native_parity_options() {
let parsed = parse_ssh_config(
"userknownhostsfile ~/.ssh/known_hosts ~/.ssh/work_hosts\n\
identitiesonly yes\n\
proxyjump bastion\n\
proxycommand ssh bastion -W %h:%p\n\
identityfile none\n",
);
assert_eq!(
parsed.user_known_hosts_file,
Some("~/.ssh/known_hosts ~/.ssh/work_hosts".to_string())
);
assert!(parsed.identities_only);
assert_eq!(parsed.proxy_jump, Some("bastion".to_string()));
assert_eq!(
parsed.proxy_command,
Some("ssh bastion -W %h:%p".to_string())
);
assert!(parsed.identity_files.is_empty());
}
#[test]
fn detects_existing_imported_host_tables() {
assert!(raw_contains_host_table(
@@ -2857,6 +2935,59 @@ mod tests {
assert!(err.to_string().contains("no usable ssh-ed25519 identity"));
}
#[test]
fn identities_only_skips_dosh_config_identity_fallback() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("id_ed25519");
let signing = SigningKey::from_bytes(&[63u8; 32]);
write_identity(&path, &signing);
let mut config = ClientConfig::default();
config.identity_files = vec![path.display().to_string()];
let ssh_config = SshConfig {
identities_only: true,
..SshConfig::default()
};
let err = load_first_native_identity_with_prompt(&config, None, &ssh_config, |_| {
unreachable!("unencrypted key should not prompt")
})
.unwrap_err();
assert!(err.to_string().contains("no usable ssh-ed25519 identity"));
}
#[test]
fn identities_only_still_allows_ssh_config_identity_files() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("id_ed25519");
let signing = SigningKey::from_bytes(&[64u8; 32]);
write_identity(&path, &signing);
let mut config = ClientConfig::default();
config.identity_files.clear();
let ssh_config = SshConfig {
identity_files: vec![path.display().to_string()],
identities_only: true,
..SshConfig::default()
};
let loaded = load_first_native_identity_with_prompt(&config, None, &ssh_config, |_| {
unreachable!("unencrypted key should not prompt")
})
.unwrap();
assert_eq!(
VerifyingKey::from(&loaded).to_bytes(),
VerifyingKey::from(&signing).to_bytes()
);
}
fn write_identity(path: &std::path::Path, signing: &SigningKey) {
let keypair = ssh_key::private::Ed25519Keypair::from(signing);
let private =
ssh_key::PrivateKey::new(ssh_key::private::KeypairData::from(keypair), "test").unwrap();
private
.write_openssh_file(path, ssh_key::LineEnding::LF)
.unwrap();
}
fn write_encrypted_identity(path: &std::path::Path, signing: &SigningKey, passphrase: &str) {
let keypair = ssh_key::private::Ed25519Keypair::from(signing);
let private =