Honor more SSH config in native auth
This commit is contained in:
+150
-19
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user