use serde::Serialize; use crate::models::connection::SshTunnelConfig; use crate::path_utils::expand_tilde; /// Legacy sentinel values used by connection payloads that predate the /// explicit SSH authentication method field. Current connection forms always /// set `auth_method`, so their user and port values must be treated as /// explicit, including the valid defaults `root` and `22`. const DEFAULT_USER_SENTINEL: &str = "root"; const DEFAULT_PORT_SENTINEL: u16 = 22; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct SshConfigHostEntry { pub alias: String, pub host_name: Option, pub port: Option, pub user: Option, pub identity_file: Option, /// The `ProxyJump` alias, if any. Only a single hop is parsed (OpenSSH's /// comma-separated multi-jump form is not supported); the jump itself /// may declare its own `ProxyJump`, which `resolve_ssh_tunnel_chain` /// follows recursively. pub proxy_jump: Option, } /// Reads and parses `~/.ssh/config`. Returns an empty list (not an error) if /// the file does not exist, since that's a normal state for users without /// an SSH config. pub fn list_hosts() -> Result, String> { let path = expand_tilde("~/.ssh/config"); match std::fs::read_to_string(&path) { Ok(content) => Ok(parse_ssh_config(&content)), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), Err(err) => Err(format!("Failed to read {path}: {err}")), } } pub fn find_host(alias: &str) -> Option { list_hosts().ok()?.into_iter().find(|entry| entry.alias == alias) } /// Fills in `host`, `user`, `port`, and `key_path` from a matching `~/.ssh/config` /// `Host` block, without overwriting values the user has explicitly set. /// /// Only `ssh.host` is matched against config aliases; `user`/`port`/`key_path` /// are filled in from that same matched entry. Older payloads without an /// `auth_method` retain the legacy default-filling behavior so imported /// connections continue to resolve their aliases. Payloads produced by the /// current connection form keep all user and port values as entered. pub fn resolve_ssh_tunnel_config(ssh: &SshTunnelConfig) -> SshTunnelConfig { match find_host(&ssh.host) { Some(entry) => apply_host_entry(ssh, entry), None => ssh.clone(), } } /// Resolves `ssh.host` plus, when its `~/.ssh/config` entry (or an ancestor /// reached by following `ProxyJump`) declares `ProxyJump`, every jump host /// leading to it. The returned list is in connection order — outermost jump /// first, `ssh`'s own resolved config last — and always has at least one /// element. A chain of length 1 means there is no `ProxyJump` to honor, and /// behaves exactly like [`resolve_ssh_tunnel_config`]. /// /// Jump hosts have no dedicated credentials field in DBX's connection form, /// so each one inherits `ssh`'s password/key/agent settings; only the /// host/port/user/identity file come from its own `~/.ssh/config` entry. pub fn resolve_ssh_tunnel_chain(ssh: &SshTunnelConfig) -> Vec { resolve_chain_from_entries(ssh, &list_hosts().unwrap_or_default()) } fn resolve_chain_from_entries(ssh: &SshTunnelConfig, entries: &[SshConfigHostEntry]) -> Vec { let find = |alias: &str| entries.iter().find(|entry| entry.alias == alias).cloned(); let Some(target_entry) = find(&ssh.host) else { return vec![ssh.clone()]; }; let resolved_target = apply_host_entry(ssh, target_entry.clone()); let mut jump_hops = Vec::new(); let mut seen_aliases = std::collections::HashSet::new(); seen_aliases.insert(ssh.host.clone()); let mut next_jump = target_entry.proxy_jump.clone(); while let Some(alias) = next_jump { // Guard against a cycle in `~/.ssh/config` (e.g. two hosts naming // each other as ProxyJump) rather than looping forever. if !seen_aliases.insert(alias.clone()) { break; } let Some(entry) = find(&alias) else { break }; next_jump = entry.proxy_jump.clone(); jump_hops.push(jump_hop_from_entry(ssh, entry)); } jump_hops.reverse(); jump_hops.push(resolved_target); jump_hops } /// Builds a synthetic hop for a `ProxyJump` host. Unlike the leaf host in /// `ssh`, there's no user-filled form for this host, so its identity comes /// entirely from `~/.ssh/config` (falling back to the leaf's own settings /// for anything the config entry doesn't specify). fn jump_hop_from_entry(leaf: &SshTunnelConfig, entry: SshConfigHostEntry) -> SshTunnelConfig { SshTunnelConfig { id: format!("{}::proxyjump::{}", leaf.id, entry.alias), name: entry.alias.clone(), enabled: true, host: entry.host_name.unwrap_or_else(|| entry.alias.clone()), port: entry.port.unwrap_or(DEFAULT_PORT_SENTINEL), user: entry.user.unwrap_or_else(|| leaf.user.clone()), password: leaf.password.clone(), key_path: entry.identity_file.unwrap_or_else(|| leaf.key_path.clone()), key_passphrase: leaf.key_passphrase.clone(), connect_timeout_secs: leaf.connect_timeout_secs, expose_lan: false, use_ssh_agent: leaf.use_ssh_agent, ssh_agent_sock_path: leaf.ssh_agent_sock_path.clone(), auth_method: leaf.auth_method.clone(), allow_exec_channel_proxy: leaf.allow_exec_channel_proxy, profile_id: String::new(), } } /// Applies a resolved `~/.ssh/config` entry onto `ssh`, without overwriting /// values the user has explicitly set. The empty `auth_method` identifies /// legacy payloads where default values cannot be distinguished from fields /// that were left blank in the old form. fn apply_host_entry(ssh: &SshTunnelConfig, entry: SshConfigHostEntry) -> SshTunnelConfig { let mut resolved = ssh.clone(); let is_legacy_payload = ssh.auth_method.is_empty(); if let Some(host_name) = entry.host_name { resolved.host = host_name; } if is_legacy_payload && resolved.user == DEFAULT_USER_SENTINEL { if let Some(user) = entry.user { resolved.user = user; } } if is_legacy_payload && resolved.port == DEFAULT_PORT_SENTINEL { if let Some(port) = entry.port { resolved.port = port; } } if resolved.key_path.is_empty() { if let Some(identity_file) = entry.identity_file { resolved.key_path = identity_file; // If the SSH config supplied the only usable credential, make the // backend use it even when an older/default UI payload still says // "password" with an empty password. if resolved.auth_method.is_empty() || (resolved.auth_method == "password" && resolved.password.is_empty()) { resolved.auth_method = "key".to_string(); } } } resolved } /// Parses a minimal subset of OpenSSH client config syntax: `Host`, `HostName`, /// `Port`, `User`, `IdentityFile`, `ProxyJump`. Wildcard host patterns /// (containing `*` or `?`) are skipped since they aren't usable as a literal /// alias in the host field. `Include` and other directives are not /// supported, and `ProxyJump`'s comma-separated multi-hop form is read as a /// single alias (its first hop). fn parse_ssh_config(content: &str) -> Vec { let mut entries: Vec = Vec::new(); let mut current_aliases: Vec = Vec::new(); for raw_line in content.lines() { let line = strip_comment(raw_line).trim(); if line.is_empty() { continue; } let Some((keyword, value)) = split_directive(line) else { continue; }; match keyword.to_ascii_lowercase().as_str() { "host" => { current_aliases = value .split_whitespace() .filter(|alias| !alias.contains('*') && !alias.contains('?')) .map(str::to_string) .collect(); for alias in ¤t_aliases { entries.push(SshConfigHostEntry { alias: alias.clone(), host_name: None, port: None, user: None, identity_file: None, proxy_jump: None, }); } } "hostname" => set_current_field(&mut entries, ¤t_aliases, |entry| { entry.host_name = Some(value.to_string()); }), "port" => { if let Ok(port) = value.parse::() { set_current_field(&mut entries, ¤t_aliases, |entry| { entry.port = Some(port); }); } } "user" => set_current_field(&mut entries, ¤t_aliases, |entry| { entry.user = Some(value.to_string()); }), "identityfile" => set_current_field(&mut entries, ¤t_aliases, |entry| { entry.identity_file = Some(value.to_string()); }), "proxyjump" => { if let Some(first_hop) = value.split(',').next().map(str::trim).filter(|hop| !hop.is_empty()) { // A `user@host:port` jump target names an alias-incompatible // literal host; only a plain alias is resolvable here. if !first_hop.contains('@') && !first_hop.contains(':') { let first_hop = first_hop.to_string(); set_current_field(&mut entries, ¤t_aliases, |entry| { entry.proxy_jump = Some(first_hop.clone()); }); } } } _ => {} } } entries } fn set_current_field( entries: &mut [SshConfigHostEntry], current_aliases: &[String], apply: impl Fn(&mut SshConfigHostEntry), ) { for entry in entries.iter_mut() { if current_aliases.contains(&entry.alias) { apply(entry); } } } fn strip_comment(line: &str) -> &str { match line.find('#') { Some(index) => &line[..index], None => line, } } /// Splits a config line into `(keyword, value)`. OpenSSH allows the keyword /// and value to be separated by whitespace or a single `=`. fn split_directive(line: &str) -> Option<(&str, &str)> { let line = line.trim(); let split_index = line.find(|c: char| c.is_whitespace() || c == '=')?; let keyword = &line[..split_index]; let value = line[split_index..].trim_start_matches(|c: char| c.is_whitespace() || c == '=').trim(); if keyword.is_empty() || value.is_empty() { return None; } Some((keyword, value)) } #[cfg(test)] mod tests { use super::*; fn config(host: &str) -> SshTunnelConfig { SshTunnelConfig { profile_id: String::new(), id: "1".to_string(), name: String::new(), enabled: true, host: host.to_string(), port: DEFAULT_PORT_SENTINEL, user: DEFAULT_USER_SENTINEL.to_string(), password: String::new(), key_path: String::new(), key_passphrase: String::new(), connect_timeout_secs: 5, expose_lan: false, use_ssh_agent: false, ssh_agent_sock_path: String::new(), auth_method: String::new(), allow_exec_channel_proxy: false, } } #[test] fn parses_basic_host_block() { let entries = parse_ssh_config( "Host myserver\n HostName 10.0.0.5\n Port 2222\n User deploy\n IdentityFile ~/.ssh/id_ed25519\n", ); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!(entry.alias, "myserver"); assert_eq!(entry.host_name, Some("10.0.0.5".to_string())); assert_eq!(entry.port, Some(2222)); assert_eq!(entry.user, Some("deploy".to_string())); assert_eq!(entry.identity_file, Some("~/.ssh/id_ed25519".to_string())); } #[test] fn one_line_can_declare_multiple_aliases() { let entries = parse_ssh_config("Host prod prod-alias\n HostName 10.0.0.9\n"); assert_eq!(entries.len(), 2); assert!(entries.iter().all(|entry| entry.host_name == Some("10.0.0.9".to_string()))); assert_eq!(entries[0].alias, "prod"); assert_eq!(entries[1].alias, "prod-alias"); } #[test] fn skips_wildcard_host_patterns() { let entries = parse_ssh_config("Host *.example.com\n User git\nHost real\n User deploy\n"); assert_eq!(entries.len(), 1); assert_eq!(entries[0].alias, "real"); } #[test] fn ignores_comments_and_blank_lines() { let entries = parse_ssh_config("# a comment\n\nHost myserver # inline comment\n User deploy\n"); assert_eq!(entries.len(), 1); assert_eq!(entries[0].user, Some("deploy".to_string())); } #[test] fn parses_proxy_jump() { let entries = parse_ssh_config("Host target\n HostName 1.1.1.2\n ProxyJump gateway\n"); assert_eq!(entries[0].proxy_jump, Some("gateway".to_string())); } #[test] fn proxy_jump_multi_hop_form_reads_only_the_first_hop() { let entries = parse_ssh_config("Host target\n ProxyJump gw1,gw2\n"); assert_eq!(entries[0].proxy_jump, Some("gw1".to_string())); } #[test] fn proxy_jump_user_at_host_form_is_not_resolvable_as_an_alias() { let entries = parse_ssh_config("Host target\n ProxyJump jumper@1.1.1.1\n"); assert_eq!(entries[0].proxy_jump, None); } fn entry(alias: &str) -> SshConfigHostEntry { SshConfigHostEntry { alias: alias.to_string(), host_name: Some("10.0.0.5".to_string()), port: Some(2222), user: Some("deploy".to_string()), identity_file: Some("~/.ssh/id_ed25519".to_string()), proxy_jump: None, } } #[test] fn resolve_fills_unset_fields_from_matching_alias() { let ssh = config("myserver"); let resolved = apply_host_entry(&ssh, entry("myserver")); assert_eq!(resolved.host, "10.0.0.5"); assert_eq!(resolved.port, 2222); assert_eq!(resolved.user, "deploy"); assert_eq!(resolved.key_path, "~/.ssh/id_ed25519"); assert_eq!(resolved.auth_method, "key"); } #[test] fn resolve_keeps_password_auth_when_password_is_present() { let mut ssh = config("myserver"); ssh.password = "secret".to_string(); ssh.auth_method = "password".to_string(); let resolved = apply_host_entry(&ssh, entry("myserver")); assert_eq!(resolved.key_path, "~/.ssh/id_ed25519"); assert_eq!(resolved.auth_method, "password"); } #[test] fn resolve_keeps_default_user_and_port_for_current_payloads() { let mut ssh = config("myserver"); ssh.auth_method = "password".to_string(); let resolved = apply_host_entry(&ssh, entry("myserver")); assert_eq!(resolved.host, "10.0.0.5"); assert_eq!(resolved.user, DEFAULT_USER_SENTINEL); assert_eq!(resolved.port, DEFAULT_PORT_SENTINEL); } #[test] fn resolve_preserves_key_plus_password_method() { let mut ssh = config("myserver"); ssh.auth_method = "key+password".to_string(); ssh.password = "secret".to_string(); let resolved = apply_host_entry(&ssh, entry("myserver")); assert_eq!(resolved.key_path, "~/.ssh/id_ed25519"); assert_eq!(resolved.auth_method, "key+password"); } #[test] fn resolve_does_not_override_explicit_values() { let mut ssh = config("myserver"); ssh.user = "alice".to_string(); ssh.port = 9999; ssh.key_path = "/explicit/key".to_string(); let resolved = apply_host_entry(&ssh, entry("myserver")); assert_eq!(resolved.host, "10.0.0.5"); assert_eq!(resolved.user, "alice"); assert_eq!(resolved.port, 9999); assert_eq!(resolved.key_path, "/explicit/key"); } #[test] fn resolve_is_noop_when_host_does_not_match_any_alias() { // `resolve_ssh_tunnel_config` looks up the real `~/.ssh/config`; an // alias this unlikely to exist on a test machine exercises the // "no match found" branch without needing to mock the filesystem. let ssh = config("dbx-test-alias-that-should-never-exist-anywhere"); let resolved = resolve_ssh_tunnel_config(&ssh); assert_eq!(resolved, ssh); } fn jump_entry(alias: &str, host_name: &str, proxy_jump: Option<&str>) -> SshConfigHostEntry { SshConfigHostEntry { alias: alias.to_string(), host_name: Some(host_name.to_string()), port: None, user: None, identity_file: None, proxy_jump: proxy_jump.map(str::to_string), } } #[test] fn chain_is_single_hop_without_proxy_jump() { let ssh = config("myserver"); let chain = resolve_chain_from_entries(&ssh, std::slice::from_ref(&entry("myserver"))); assert_eq!(chain.len(), 1); assert_eq!(chain[0].host, "10.0.0.5"); } #[test] fn chain_prepends_the_proxy_jump_hop_in_connection_order() { // Mirrors issue #7706: `target` (ProxyJump gateway) resolves to // [gateway, target], gateway dialed first. let ssh = config("target"); let entries = [jump_entry("gateway", "1.1.1.1", None), jump_entry("target", "1.1.1.2", Some("gateway"))]; let chain = resolve_chain_from_entries(&ssh, &entries); assert_eq!(chain.len(), 2); assert_eq!(chain[0].host, "1.1.1.1"); assert_eq!(chain[0].name, "gateway"); assert_eq!(chain[1].host, "1.1.1.2"); } #[test] fn chain_jump_hop_inherits_leaf_credentials_when_config_has_no_identity_file() { let mut ssh = config("target"); ssh.auth_method = "key".to_string(); ssh.key_path = "/home/user/.ssh/id_rsa".to_string(); let entries = [jump_entry("gateway", "1.1.1.1", None), jump_entry("target", "1.1.1.2", Some("gateway"))]; let chain = resolve_chain_from_entries(&ssh, &entries); assert_eq!(chain[0].auth_method, "key"); assert_eq!(chain[0].key_path, "/home/user/.ssh/id_rsa"); } #[test] fn chain_follows_multiple_proxy_jump_hops_recursively() { let ssh = config("target"); let entries = [ jump_entry("gateway-outer", "1.1.1.1", None), jump_entry("gateway-inner", "1.1.1.2", Some("gateway-outer")), jump_entry("target", "1.1.1.3", Some("gateway-inner")), ]; let chain = resolve_chain_from_entries(&ssh, &entries); assert_eq!( chain.iter().map(|hop| hop.host.as_str()).collect::>(), vec!["1.1.1.1", "1.1.1.2", "1.1.1.3"] ); } #[test] fn chain_breaks_a_proxy_jump_cycle_instead_of_looping_forever() { let ssh = config("a"); let entries = [jump_entry("a", "1.1.1.1", Some("b")), jump_entry("b", "1.1.1.2", Some("a"))]; let chain = resolve_chain_from_entries(&ssh, &entries); // Must terminate; the exact truncation point is an implementation // detail of cycle detection, not a behavior callers rely on. assert!(chain.len() <= entries.len()); assert_eq!(chain.last().unwrap().host, "1.1.1.1"); } }