Honor SSH config identities in SDK client
ci / test (push) Canceled after 0s
ci / fuzz-smoke (push) Canceled after 0s
ci / macos-client (macos-aarch64, macos-14) (push) Canceled after 0s
ci / macos-client (macos-x86_64, macos-13) (push) Canceled after 0s
ci / windows-client (push) Canceled after 0s
ci / package-release (linux-x86_64, ubuntu-latest, , , ) (push) Canceled after 0s
ci / package-release (macos-aarch64, macos-14, , , ) (push) Canceled after 0s
ci / package-release (macos-x86_64, macos-13, , , ) (push) Canceled after 0s
ci / package-release (windows-aarch64, windows-latest, aarch64, windows, aarch64-pc-windows-msvc) (push) Canceled after 0s
ci / package-release (windows-x86_64, windows-latest, , , ) (push) Canceled after 0s
ci / remote-bench (push) Canceled after 0s
ci / publish-gitea-release (push) Canceled after 0s

This commit is contained in:
DuProcess
2026-07-16 21:13:14 -04:00
parent e36f1fa936
commit 3563c44598
+254 -15
View File
@@ -17,6 +17,7 @@ use crate::udp::{bind_udp_for_peer, recv_udp_retrying_transient, send_udp_retryi
use anyhow::{Context, Result, anyhow, bail};
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone)]
@@ -153,13 +154,6 @@ impl DoshClientBuilder {
.cloned()
.unwrap_or_default();
let raw_server = host_config.ssh.clone().unwrap_or_else(|| self.host.clone());
let requested_user = self
.user
.clone()
.or_else(|| host_config.user.clone())
.or_else(|| user_from_destination(&raw_server))
.or_else(local_username)
.unwrap_or_else(|| "unknown".to_string());
let udp_host = self
.udp_host
.clone()
@@ -170,6 +164,17 @@ impl DoshClientBuilder {
.udp_port
.or(host_config.port)
.unwrap_or(self.client.config.dosh_port);
let ssh_port = host_config.ssh_port.or(self.client.config.ssh_port);
let ssh_config =
load_sdk_ssh_config(host_config.ssh_config.as_deref(), ssh_port).unwrap_or_default();
let requested_user = self
.user
.clone()
.or_else(|| host_config.user.clone())
.or_else(|| user_from_destination(&raw_server))
.or_else(|| ssh_config.user.clone())
.or_else(local_username)
.unwrap_or_else(|| "unknown".to_string());
let peer_addrs = resolve_addrs(&udp_host, udp_port)?;
let timeout = self.timeout.unwrap_or_else(|| {
Duration::from_millis(self.client.config.native_auth_timeout_ms.max(1))
@@ -195,6 +200,9 @@ impl DoshClientBuilder {
&self.client.config,
&host_config,
&self.host,
&raw_server,
ssh_port,
&ssh_config,
&requested_user,
&session,
&requested_forwardings,
@@ -235,6 +243,9 @@ async fn connect_sdk_peer(
config: &ClientConfig,
host_config: &HostConfig,
host: &str,
server: &str,
ssh_port: Option<u16>,
ssh_config: &SdkSshConfig,
requested_user: &str,
session: &str,
requested_forwardings: &[ForwardingRequest],
@@ -305,6 +316,9 @@ async fn connect_sdk_peer(
requested_forwardings.to_vec(),
identity_files.to_vec(),
use_ssh_agent,
server,
ssh_port,
ssh_config,
)?;
let mut pending_id = [0u8; 16];
pending_id.copy_from_slice(&server_hello.hello.auth_challenge[..16]);
@@ -372,16 +386,19 @@ fn verify_or_trust_host(
fn sign_auth(
config: &ClientConfig,
host_config: &HostConfig,
_host_config: &HostConfig,
hello: &NativeClientHello,
server_hello: &native::NativeServerHello,
requested_forwardings: Vec<ForwardingRequest>,
explicit_identity_files: Vec<PathBuf>,
use_ssh_agent: Option<bool>,
server: &str,
ssh_port: Option<u16>,
ssh_config: &SdkSshConfig,
) -> Result<native::NativeUserAuth> {
let use_agent = use_ssh_agent.unwrap_or(config.use_ssh_agent);
let mut errors = Vec::new();
if use_agent {
if use_agent && !ssh_config.identities_only {
match ssh_agent::sign_user_auth_with_agent(
hello,
server_hello,
@@ -390,13 +407,13 @@ fn sign_auth(
Ok(auth) => return Ok(auth),
Err(err) => errors.push(format!("ssh-agent: {err:#}")),
}
} else if use_agent && ssh_config.identities_only {
errors.push("ssh-agent: skipped because SSH config sets IdentitiesOnly=yes".to_string());
}
let mut paths = explicit_identity_files;
if paths.is_empty() {
paths.extend(config.identity_files.iter().map(|path| expand_tilde(path)));
}
if paths.is_empty() {
let token_context = sdk_ssh_path_token_context(server, ssh_port, ssh_config);
let mut paths = sdk_identity_paths(config, explicit_identity_files, ssh_config, &token_context);
if paths.is_empty() && !ssh_config.identities_only {
paths.extend(default_identity_paths());
}
for path in paths {
@@ -412,13 +429,45 @@ fn sign_auth(
Err(err) => errors.push(format!("{}: {err:#}", path.display())),
}
}
let _ = host_config;
Err(anyhow!(
"native auth found no usable identity: {}",
errors.join("; ")
))
}
fn sdk_identity_paths(
config: &ClientConfig,
explicit_identity_files: Vec<PathBuf>,
ssh_config: &SdkSshConfig,
token_context: &SshPathTokenContext,
) -> Vec<PathBuf> {
let mut paths = Vec::new();
for path in explicit_identity_files {
push_identity_path(&mut paths, path);
}
for path in &ssh_config.identity_files {
push_identity_path(
&mut paths,
expand_tilde(&expand_ssh_path_tokens(path, token_context)),
);
}
if !ssh_config.identities_only {
for path in &config.identity_files {
push_identity_path(
&mut paths,
expand_tilde(&expand_ssh_path_tokens(path, token_context)),
);
}
}
paths
}
fn push_identity_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
if !paths.iter().any(|existing| existing == &path) {
paths.push(path);
}
}
fn default_identity_paths() -> Vec<PathBuf> {
["~/.ssh/id_ed25519", "~/.ssh/id_ecdsa", "~/.ssh/id_rsa"]
.into_iter()
@@ -426,6 +475,131 @@ fn default_identity_paths() -> Vec<PathBuf> {
.collect()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct SdkSshConfig {
hostname: Option<String>,
port: Option<u16>,
user: Option<String>,
identity_files: Vec<String>,
identities_only: bool,
}
fn load_sdk_ssh_config(alias: Option<&str>, ssh_port: Option<u16>) -> Result<SdkSshConfig> {
let Some(alias) = alias else {
return Ok(SdkSshConfig::default());
};
let mut command = Command::new("ssh");
command.arg("-G");
if let Some(ssh_port) = ssh_port {
command.arg("-p").arg(ssh_port.to_string());
}
let output = command
.arg(alias)
.output()
.with_context(|| format!("run ssh -G {alias}"))?;
if !output.status.success() {
bail!("ssh -G failed: {}", String::from_utf8_lossy(&output.stderr));
}
Ok(parse_sdk_ssh_config(&String::from_utf8(output.stdout)?))
}
fn parse_sdk_ssh_config(raw: &str) -> SdkSshConfig {
let mut config = SdkSshConfig::default();
for line in raw.lines() {
let line = line.trim();
let Some((key, value)) = line.split_once(char::is_whitespace) else {
continue;
};
let value = value.trim();
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") && !empty_or_none(value) {
config.user = Some(value.to_string());
} 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("identitiesonly") {
config.identities_only = value.eq_ignore_ascii_case("yes");
}
}
config
}
fn empty_or_none(value: &str) -> bool {
value.is_empty() || value.eq_ignore_ascii_case("none")
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SshPathTokenContext {
original_host: String,
hostname: String,
port: u16,
remote_user: String,
local_user: String,
home_dir: Option<String>,
}
fn sdk_ssh_path_token_context(
server: &str,
ssh_port: Option<u16>,
ssh_config: &SdkSshConfig,
) -> SshPathTokenContext {
let original_host = destination_host(server);
let hostname = ssh_config
.hostname
.clone()
.unwrap_or_else(|| original_host.clone());
let port = ssh_config.port.or(ssh_port).unwrap_or(22);
let remote_user = user_from_destination(server)
.or_else(|| ssh_config.user.clone())
.or_else(local_username)
.unwrap_or_else(|| "unknown".to_string());
let local_user = local_username().unwrap_or_else(|| "unknown".to_string());
let home_dir = dirs::home_dir().map(|path| path.to_string_lossy().to_string());
SshPathTokenContext {
original_host,
hostname,
port,
remote_user,
local_user,
home_dir,
}
}
fn expand_ssh_path_tokens(raw: &str, context: &SshPathTokenContext) -> String {
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars();
while let Some(ch) = chars.next() {
if ch != '%' {
out.push(ch);
continue;
}
match chars.next() {
Some('%') => out.push('%'),
Some('d') => {
if let Some(home_dir) = &context.home_dir {
out.push_str(home_dir);
} else {
out.push('%');
out.push('d');
}
}
Some('h') => out.push_str(&context.hostname),
Some('n') => out.push_str(&context.original_host),
Some('p') => out.push_str(&context.port.to_string()),
Some('r') => out.push_str(&context.remote_user),
Some('u') => out.push_str(&context.local_user),
Some(other) => {
out.push('%');
out.push(other);
}
None => out.push('%'),
}
}
out
}
fn resolve_addrs(host: &str, port: u16) -> Result<Vec<SocketAddr>> {
let addrs = (host, port)
.to_socket_addrs()
@@ -543,6 +717,71 @@ mod tests {
);
}
#[test]
fn sdk_parses_ssh_config_identity_settings() {
let parsed = parse_sdk_ssh_config(
"hostname 10.0.0.5\n\
user deploy\n\
port 2222\n\
identityfile ~/.ssh/work\n\
identityfile none\n\
identitiesonly yes\n",
);
assert_eq!(parsed.hostname.as_deref(), Some("10.0.0.5"));
assert_eq!(parsed.user.as_deref(), Some("deploy"));
assert_eq!(parsed.port, Some(2222));
assert_eq!(parsed.identity_files, vec!["~/.ssh/work"]);
assert!(parsed.identities_only);
}
#[test]
fn sdk_identity_paths_follow_cli_precedence_and_identities_only() {
let dir = tempfile::tempdir().unwrap();
let explicit = dir.path().join("explicit");
let config_identity = dir.path().join("config");
let ssh_config = SdkSshConfig {
hostname: Some("10.0.0.5".to_string()),
port: Some(2222),
user: Some("deploy".to_string()),
identity_files: vec![format!("{}/%r_%h_%p", dir.path().display())],
identities_only: true,
};
let config = ClientConfig {
identity_files: vec![config_identity.display().to_string()],
..ClientConfig::default()
};
let token_context = sdk_ssh_path_token_context("deploy@prod", None, &ssh_config);
let paths =
sdk_identity_paths(&config, vec![explicit.clone()], &ssh_config, &token_context);
assert_eq!(
paths,
vec![explicit, dir.path().join("deploy_10.0.0.5_2222")]
);
}
#[test]
fn sdk_identity_paths_include_dosh_config_when_not_identities_only() {
let dir = tempfile::tempdir().unwrap();
let config_identity = dir.path().join("config");
let ssh_config = SdkSshConfig {
identity_files: vec![dir.path().join("ssh").display().to_string()],
identities_only: false,
..SdkSshConfig::default()
};
let config = ClientConfig {
identity_files: vec![config_identity.display().to_string()],
..ClientConfig::default()
};
let token_context = sdk_ssh_path_token_context("prod", Some(22), &ssh_config);
let paths = sdk_identity_paths(&config, Vec::new(), &ssh_config, &token_context);
assert_eq!(paths, vec![dir.path().join("ssh"), config_identity]);
}
#[test]
fn default_identity_paths_are_expanded() {
assert!(