Honor more SSH config in native auth
This commit is contained in:
+159
-28
@@ -231,6 +231,7 @@ async fn main() -> Result<()> {
|
|||||||
let allow_cache = !args.no_cache && !forwarding_requested;
|
let allow_cache = !args.no_cache && !forwarding_requested;
|
||||||
|
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
|
let mut resolved_ssh_config = None;
|
||||||
let target_udp_host = if let Some(host) = args
|
let target_udp_host = if let Some(host) = args
|
||||||
.dosh_host
|
.dosh_host
|
||||||
.clone()
|
.clone()
|
||||||
@@ -241,14 +242,25 @@ async fn main() -> Result<()> {
|
|||||||
} else if args.local_auth {
|
} else if args.local_auth {
|
||||||
"127.0.0.1".to_string()
|
"127.0.0.1".to_string()
|
||||||
} else {
|
} else {
|
||||||
ssh_config_hostname(&server, ssh_port).unwrap_or_else(|err| {
|
match ssh_config(&server, ssh_port) {
|
||||||
log_debug(
|
Ok(parsed) => {
|
||||||
args.verbose,
|
let hostname = parsed
|
||||||
2,
|
.hostname
|
||||||
&format!("dosh could not resolve SSH config hostname, using {server}: {err:#}"),
|
.clone()
|
||||||
);
|
.unwrap_or_else(|| ssh_destination_host(&server));
|
||||||
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 {
|
let credential = if allow_cache {
|
||||||
@@ -348,6 +360,7 @@ async fn main() -> Result<()> {
|
|||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
forwarding_requests(&local_forwards, &remote_forwards, &dynamic_forwards),
|
forwarding_requests(&local_forwards, &remote_forwards, &dynamic_forwards),
|
||||||
|
resolved_ssh_config.as_ref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -512,21 +525,31 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) ->
|
|||||||
.unwrap_or(config.dosh_port);
|
.unwrap_or(config.dosh_port);
|
||||||
println!("Dosh doctor for {requested}");
|
println!("Dosh doctor for {requested}");
|
||||||
|
|
||||||
match ssh_config(&server, ssh_port) {
|
let parsed_ssh_config = match ssh_config(&server, ssh_port) {
|
||||||
Ok(parsed) => {
|
Ok(parsed) => {
|
||||||
let hostname = parsed
|
let hostname = parsed
|
||||||
.hostname
|
.hostname
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| ssh_destination_host(&server));
|
.unwrap_or_else(|| ssh_destination_host(&server));
|
||||||
println!(
|
println!(
|
||||||
"[ok] ssh config: host={} user={} port={}",
|
"[ok] ssh config: host={} user={} port={} identities_only={} proxy={}",
|
||||||
hostname,
|
hostname,
|
||||||
parsed.user.as_deref().unwrap_or("<default>"),
|
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(
|
match ssh_fallback_probe(
|
||||||
&server,
|
&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(|| host_config.dosh_host.clone())
|
||||||
.or_else(|| config.dosh_host.clone())
|
.or_else(|| config.dosh_host.clone())
|
||||||
.unwrap_or_else(|| {
|
.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}");
|
println!("[info] native endpoint: {udp_host}:{dosh_port}");
|
||||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
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,
|
&udp_host,
|
||||||
dosh_port,
|
dosh_port,
|
||||||
args.ssh_key.as_deref(),
|
args.ssh_key.as_deref(),
|
||||||
|
Some(&parsed_ssh_config),
|
||||||
)
|
)
|
||||||
.await
|
.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)]
|
#[derive(Debug, Clone, Default)]
|
||||||
struct SshConfig {
|
struct SshConfig {
|
||||||
hostname: Option<String>,
|
hostname: Option<String>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
user: Option<String>,
|
user: Option<String>,
|
||||||
identity_files: Vec<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> {
|
fn ssh_config(server: &str, ssh_port: Option<u16>) -> Result<SshConfig> {
|
||||||
@@ -1184,19 +1209,31 @@ fn parse_ssh_config(raw: &str) -> SshConfig {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let value = value.trim();
|
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());
|
config.hostname = Some(value.to_string());
|
||||||
} else if key.eq_ignore_ascii_case("port") {
|
} else if key.eq_ignore_ascii_case("port") {
|
||||||
config.port = value.parse().ok();
|
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());
|
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());
|
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
|
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> {
|
fn resolve_addr(host: &str, port: u16) -> Result<SocketAddr> {
|
||||||
(host, port)
|
(host, port)
|
||||||
.to_socket_addrs()
|
.to_socket_addrs()
|
||||||
@@ -1219,9 +1256,16 @@ async fn try_native_auth(
|
|||||||
cols: u16,
|
cols: u16,
|
||||||
rows: u16,
|
rows: u16,
|
||||||
requested_forwardings: Vec<ForwardingRequest>,
|
requested_forwardings: Vec<ForwardingRequest>,
|
||||||
|
ssh_config_hint: Option<&SshConfig>,
|
||||||
) -> Result<(Frame, CachedCredential)> {
|
) -> Result<(Frame, CachedCredential)> {
|
||||||
let addr = resolve_addr(udp_host, udp_port)?;
|
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)
|
let requested_user = ssh_username(server)
|
||||||
.or(ssh_config.user.clone())
|
.or(ssh_config.user.clone())
|
||||||
.unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()));
|
.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_host: &str,
|
||||||
udp_port: u16,
|
udp_port: u16,
|
||||||
cli_identity: Option<&Path>,
|
cli_identity: Option<&Path>,
|
||||||
|
ssh_config_hint: Option<&SshConfig>,
|
||||||
) -> Result<NativeAuthCheckOkBody> {
|
) -> Result<NativeAuthCheckOkBody> {
|
||||||
let addr = resolve_addr(udp_host, udp_port)?;
|
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)
|
let requested_user = ssh_username(server)
|
||||||
.or(ssh_config.user.clone())
|
.or(ssh_config.user.clone())
|
||||||
.unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()));
|
.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>,
|
requested_forwardings: Vec<ForwardingRequest>,
|
||||||
) -> Result<dosh::native::NativeUserAuth> {
|
) -> Result<dosh::native::NativeUserAuth> {
|
||||||
let mut errors = Vec::new();
|
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(
|
match ssh_agent::sign_user_auth_with_agent(
|
||||||
hello,
|
hello,
|
||||||
server_hello,
|
server_hello,
|
||||||
@@ -1502,6 +1553,8 @@ fn sign_native_user_auth(
|
|||||||
Ok(auth) => return Ok(auth),
|
Ok(auth) => return Ok(auth),
|
||||||
Err(err) => errors.push(format!("ssh-agent: {err:#}")),
|
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) {
|
match load_first_native_identity(config, cli_identity, ssh_config) {
|
||||||
@@ -1545,8 +1598,10 @@ where
|
|||||||
for path in &ssh_config.identity_files {
|
for path in &ssh_config.identity_files {
|
||||||
push_identity_path(&mut paths, expand_tilde(path));
|
push_identity_path(&mut paths, expand_tilde(path));
|
||||||
}
|
}
|
||||||
for path in &config.identity_files {
|
if !ssh_config.identities_only {
|
||||||
push_identity_path(&mut paths, expand_tilde(path));
|
for path in &config.identity_files {
|
||||||
|
push_identity_path(&mut paths, expand_tilde(path));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
@@ -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]
|
#[test]
|
||||||
fn detects_existing_imported_host_tables() {
|
fn detects_existing_imported_host_tables() {
|
||||||
assert!(raw_contains_host_table(
|
assert!(raw_contains_host_table(
|
||||||
@@ -2857,6 +2935,59 @@ mod tests {
|
|||||||
assert!(err.to_string().contains("no usable ssh-ed25519 identity"));
|
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) {
|
fn write_encrypted_identity(path: &std::path::Path, signing: &SigningKey, passphrase: &str) {
|
||||||
let keypair = ssh_key::private::Ed25519Keypair::from(signing);
|
let keypair = ssh_key::private::Ed25519Keypair::from(signing);
|
||||||
let private =
|
let private =
|
||||||
|
|||||||
Reference in New Issue
Block a user