From 0bccda193b8de4d2f46701c0434c2b033e6a5bcf Mon Sep 17 00:00:00 2001 From: Melody Ruch <396921921@qq.com> Date: Sun, 6 Sep 2026 10:35:38 +0800 Subject: [PATCH] fix(ssh): follow ssh config ProxyJump hops in tunnels --- crates/dbx-core/src/connection.rs | 66 ++-- .../dbx-core/src/db/transport_layer_tunnel.rs | 285 ++++++++++++------ crates/dbx-core/src/ssh_config.rs | 189 +++++++++++- 3 files changed, 409 insertions(+), 131 deletions(-) diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index ba0517b08c..1570d65b08 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -2906,41 +2906,49 @@ impl AppState { pub async fn test_tunnel_profile(&self, profile: &TransportLayerConfig) -> Result { match profile { TransportLayerConfig::Ssh(ssh) => { - let ssh = crate::ssh_config::resolve_ssh_tunnel_config(ssh); - if ssh.host.trim().is_empty() { + // A `~/.ssh/config` alias declaring `ProxyJump` resolves to more + // than one hop; every other alias (the common case) resolves to + // exactly one, matching `resolve_ssh_tunnel_config`. + let chain = crate::ssh_config::resolve_ssh_tunnel_chain(ssh); + let leaf = chain.last().expect("resolve_ssh_tunnel_chain always returns at least one hop"); + if leaf.host.trim().is_empty() { return Err("SSH host is required.".to_string()); } - let timeout = if ssh.connect_timeout_secs == 0 { - crate::models::connection::default_ssh_connect_timeout_secs() - } else { - ssh.connect_timeout_secs - }; // A throwaway id so the probe never reuses or evicts a live tunnel, and // a sentinel forward target: SSH auth completes on connect, before any // channel to this target is opened, so it need not be reachable. let probe_id = format!("__tunnel_profile_test__:{}", uuid::Uuid::new_v4()); - let result = self - .tunnels - .start_tunnel( - &probe_id, - &ssh.host, - ssh.port, - &ssh.host, - ssh.port, - &ssh.user, - &ssh.password, - &ssh.key_path, - &ssh.key_passphrase, - ssh.use_ssh_agent, - &ssh.ssh_agent_sock_path, - &ssh.auth_method, - timeout, - "127.0.0.1", - 1, - false, - ssh.allow_exec_channel_proxy, - ) - .await; + let result = if chain.len() == 1 { + let timeout = if leaf.connect_timeout_secs == 0 { + crate::models::connection::default_ssh_connect_timeout_secs() + } else { + leaf.connect_timeout_secs + }; + self.tunnels + .start_tunnel( + &probe_id, + &leaf.host, + leaf.port, + &leaf.host, + leaf.port, + &leaf.user, + &leaf.password, + &leaf.key_path, + &leaf.key_passphrase, + leaf.use_ssh_agent, + &leaf.ssh_agent_sock_path, + &leaf.auth_method, + timeout, + "127.0.0.1", + 1, + false, + leaf.allow_exec_channel_proxy, + ) + .await + .map(|_| ()) + } else { + self.tunnels.start_chain(&probe_id, &chain, &leaf.host, leaf.port).await.map(|_| ()) + }; self.tunnels.stop_tunnel(&probe_id).await; result.map(|_| "SSH tunnel connection successful".to_string()) } diff --git a/crates/dbx-core/src/db/transport_layer_tunnel.rs b/crates/dbx-core/src/db/transport_layer_tunnel.rs index 80efcdc11c..c0ff386288 100644 --- a/crates/dbx-core/src/db/transport_layer_tunnel.rs +++ b/crates/dbx-core/src/db/transport_layer_tunnel.rs @@ -1,4 +1,4 @@ -use crate::models::connection::TransportLayerConfig; +use crate::models::connection::{SshTunnelConfig, TransportLayerConfig}; use super::http_tunnel::HttpTunnelManager; use super::proxy_tunnel::ProxyTunnelManager; @@ -22,15 +22,20 @@ impl LayerEndpoint { /// the *next* one in the chain) must see the resolved host/port rather than /// a literal alias string, so resolution happens once up front instead of /// only at the `start_tunnel` call site. -fn resolve_ssh_layers( +/// +/// An SSH layer's alias may itself resolve to more than one hop (its +/// `~/.ssh/config` entry declares `ProxyJump`); every other layer, and every +/// SSH layer without `ProxyJump`, resolves to exactly one element. `None` is +/// returned for non-SSH layers. +fn resolve_ssh_layer_chains( layers: &[TransportLayerConfig], - resolve: impl Fn(&crate::models::connection::SshTunnelConfig) -> crate::models::connection::SshTunnelConfig, -) -> Vec { + resolve: impl Fn(&SshTunnelConfig) -> Vec, +) -> Vec>> { layers .iter() .map(|layer| match layer { - TransportLayerConfig::Ssh(ssh) => TransportLayerConfig::Ssh(resolve(ssh)), - other => other.clone(), + TransportLayerConfig::Ssh(ssh) => Some(resolve(ssh)), + _ => None, }) .collect() } @@ -117,8 +122,30 @@ async fn start_transport_layers_internal( } validate_transport_layers(layers)?; - let layers = resolve_ssh_layers(layers, crate::ssh_config::resolve_ssh_tunnel_config); - let layers = layers.as_slice(); + let ssh_chains = resolve_ssh_layer_chains(layers, crate::ssh_config::resolve_ssh_tunnel_chain); + for (index, chain) in ssh_chains.iter().enumerate() { + // A multi-hop chain only knows how to dial its first hop directly + // (see the `start_chain` call below), so it can't yet sit behind an + // earlier transport layer. + if chain.as_ref().is_some_and(|chain| chain.len() > 1) && index != 0 { + return Err(format!( + "SSH layer {} uses a multi-hop `~/.ssh/config` ProxyJump chain, which DBX only supports as the first transport layer. Add the jump host as its own tunnel layer instead.", + index + 1 + )); + } + } + let entry_endpoint = |index: usize| -> (String, u16) { + match &ssh_chains[index] { + Some(chain) => { + let first = &chain[0]; + (first.host.clone(), first.port) + } + None => { + let (host, port) = layers[index].endpoint(); + (host.to_string(), port) + } + } + }; let mut next_connect_endpoint: Option = None; let mut final_local_port = 0; @@ -126,85 +153,109 @@ async fn start_transport_layers_internal( for (index, layer) in layers.iter().enumerate() { let layer_id = layer_id(connection_id, index); let is_last = index + 1 == layers.len(); - let (layer_host, layer_port) = layer.endpoint(); - let connect_endpoint = next_connect_endpoint - .clone() - .unwrap_or_else(|| LayerEndpoint { host: layer_host.to_string(), port: layer_port }); + let (layer_host, layer_port) = entry_endpoint(index); + let connect_endpoint = + next_connect_endpoint.clone().unwrap_or(LayerEndpoint { host: layer_host, port: layer_port }); let target_endpoint = if is_last { LayerEndpoint { host: remote_host.to_string(), port: remote_port } } else { - let (next_host, next_port) = layers[index + 1].endpoint(); - LayerEndpoint { host: next_host.to_string(), port: next_port } + let (next_host, next_port) = entry_endpoint(index + 1); + LayerEndpoint { host: next_host, port: next_port } }; - let local_port = match layer { - TransportLayerConfig::Ssh(resolved) if is_last && final_ssh_socks5 => ssh_tunnels - .start_socks5_proxy( - &layer_id, - &connect_endpoint.host, - connect_endpoint.port, - &resolved.host, - resolved.port, - &resolved.user, - &resolved.password, - &resolved.key_path, - &resolved.key_passphrase, - resolved.use_ssh_agent, - &resolved.ssh_agent_sock_path, - &resolved.auth_method, - effective_ssh_connect_timeout_secs(resolved.connect_timeout_secs), - resolved.allow_exec_channel_proxy, - ) - .await - .map_err(|err| format!("SSH layer {} failed: {err}", index + 1))?, - TransportLayerConfig::Ssh(resolved) => ssh_tunnels - .start_tunnel_on_local_port( - &layer_id, - &connect_endpoint.host, - connect_endpoint.port, - &resolved.host, - resolved.port, - &resolved.user, - &resolved.password, - &resolved.key_path, - &resolved.key_passphrase, - resolved.use_ssh_agent, - &resolved.ssh_agent_sock_path, - &resolved.auth_method, - effective_ssh_connect_timeout_secs(resolved.connect_timeout_secs), - &target_endpoint.host, - target_endpoint.port, - is_last && resolved.expose_lan, - resolved.allow_exec_channel_proxy, - if is_last { final_ssh_local_port } else { None }, - ) - .await - .map_err(|err| format!("SSH layer {} failed: {err}", index + 1))?, - TransportLayerConfig::Proxy(proxy) => proxy_tunnels - .start_tunnel( - &layer_id, - proxy.proxy_type, - &connect_endpoint.host, - connect_endpoint.port, - &proxy.username, - &proxy.password, - &target_endpoint.host, - target_endpoint.port, - ) - .await - .map_err(|err| format!("Proxy layer {} failed: {err}", index + 1))?, - TransportLayerConfig::HttpTunnel(http) => http_tunnels - .start_tunnel( - &layer_id, - &http.url, - &http.token, - http.connect_timeout_secs, - &target_endpoint.host, - target_endpoint.port, - ) - .await - .map_err(|err| format!("HTTP tunnel layer {} failed: {err}", index + 1))?, - }; + let local_port = + match (layer, &ssh_chains[index]) { + (TransportLayerConfig::Ssh(_), Some(chain)) if chain.len() > 1 => { + if is_last && final_ssh_socks5 { + return Err("Dynamic SOCKS5 routing does not support a multi-hop SSH (ProxyJump) profile yet." + .to_string()); + } + if is_last && final_ssh_local_port.is_some() { + return Err("A fixed local port is not supported for a multi-hop SSH (ProxyJump) profile yet." + .to_string()); + } + ssh_tunnels + .start_chain(&layer_id, chain, &target_endpoint.host, target_endpoint.port) + .await + .map_err(|err| format!("SSH layer {} failed: {err}", index + 1))? + } + (TransportLayerConfig::Ssh(_), Some(chain)) if is_last && final_ssh_socks5 => { + let resolved = &chain[0]; + ssh_tunnels + .start_socks5_proxy( + &layer_id, + &connect_endpoint.host, + connect_endpoint.port, + &resolved.host, + resolved.port, + &resolved.user, + &resolved.password, + &resolved.key_path, + &resolved.key_passphrase, + resolved.use_ssh_agent, + &resolved.ssh_agent_sock_path, + &resolved.auth_method, + effective_ssh_connect_timeout_secs(resolved.connect_timeout_secs), + resolved.allow_exec_channel_proxy, + ) + .await + .map_err(|err| format!("SSH layer {} failed: {err}", index + 1))? + } + (TransportLayerConfig::Ssh(_), Some(chain)) => { + let resolved = &chain[0]; + ssh_tunnels + .start_tunnel_on_local_port( + &layer_id, + &connect_endpoint.host, + connect_endpoint.port, + &resolved.host, + resolved.port, + &resolved.user, + &resolved.password, + &resolved.key_path, + &resolved.key_passphrase, + resolved.use_ssh_agent, + &resolved.ssh_agent_sock_path, + &resolved.auth_method, + effective_ssh_connect_timeout_secs(resolved.connect_timeout_secs), + &target_endpoint.host, + target_endpoint.port, + is_last && resolved.expose_lan, + resolved.allow_exec_channel_proxy, + if is_last { final_ssh_local_port } else { None }, + ) + .await + .map_err(|err| format!("SSH layer {} failed: {err}", index + 1))? + } + (TransportLayerConfig::Proxy(proxy), None) => proxy_tunnels + .start_tunnel( + &layer_id, + proxy.proxy_type, + &connect_endpoint.host, + connect_endpoint.port, + &proxy.username, + &proxy.password, + &target_endpoint.host, + target_endpoint.port, + ) + .await + .map_err(|err| format!("Proxy layer {} failed: {err}", index + 1))?, + (TransportLayerConfig::HttpTunnel(http), None) => http_tunnels + .start_tunnel( + &layer_id, + &http.url, + &http.token, + http.connect_timeout_secs, + &target_endpoint.host, + target_endpoint.port, + ) + .await + .map_err(|err| format!("HTTP tunnel layer {} failed: {err}", index + 1))?, + (TransportLayerConfig::Proxy(_) | TransportLayerConfig::HttpTunnel(_), Some(_)) + | (TransportLayerConfig::Ssh(_), None) => { + unreachable!("resolve_ssh_layer_chains produces Some(_) iff the layer is TransportLayerConfig::Ssh") + } + }; final_local_port = local_port; next_connect_endpoint = Some(LayerEndpoint::localhost(local_port)); @@ -274,35 +325,42 @@ fn plan_transport_layers( remote_port: u16, local_ports: &[u16], ) -> Vec { - plan_transport_layers_with_resolver(layers, remote_host, remote_port, local_ports, |ssh| ssh.clone()) + plan_transport_layers_with_resolver(layers, remote_host, remote_port, local_ports, |ssh| vec![ssh.clone()]) } /// Same as `plan_transport_layers`, but takes an explicit SSH-alias resolver -/// so tests can exercise `~/.ssh/config` resolution without touching the real -/// filesystem (mirrors `start_transport_layers`'s use of `resolve_ssh_layers`). +/// so tests can exercise `~/.ssh/config` resolution (including a `ProxyJump` +/// chain) without touching the real filesystem — mirrors `start_transport_layers`'s +/// use of `resolve_ssh_layer_chains`. Each planned SSH layer covers only its +/// *first* hop's connect endpoint; a chain's internal hops are exercised by +/// `resolve_ssh_tunnel_chain`'s own unit tests in `ssh_config.rs`; not +/// re-planned here. #[cfg(test)] fn plan_transport_layers_with_resolver( layers: &[TransportLayerConfig], remote_host: &str, remote_port: u16, local_ports: &[u16], - resolve: impl Fn(&crate::models::connection::SshTunnelConfig) -> crate::models::connection::SshTunnelConfig, + resolve: impl Fn(&crate::models::connection::SshTunnelConfig) -> Vec, ) -> Vec { - let layers = resolve_ssh_layers(layers, resolve); - let layers = layers.as_slice(); + let ssh_chains = resolve_ssh_layer_chains(layers, resolve); + let entry_endpoint = |index: usize| -> (String, u16) { + match &ssh_chains[index] { + Some(chain) => (chain[0].host.clone(), chain[0].port), + None => { + let (host, port) = layers[index].endpoint(); + (host.to_string(), port) + } + } + }; let mut planned = Vec::new(); let mut next_connect_endpoint: Option<(String, u16)> = None; for (index, layer) in layers.iter().enumerate() { let is_last = index + 1 == layers.len(); - let (layer_host, layer_port) = layer.endpoint(); - let (connect_host, connect_port) = - next_connect_endpoint.clone().unwrap_or_else(|| (layer_host.to_string(), layer_port)); - let (target_host, target_port) = if is_last { - (remote_host.to_string(), remote_port) - } else { - let (next_host, next_port) = layers[index + 1].endpoint(); - (next_host.to_string(), next_port) - }; + let (layer_host, layer_port) = entry_endpoint(index); + let (connect_host, connect_port) = next_connect_endpoint.clone().unwrap_or((layer_host, layer_port)); + let (target_host, target_port) = + if is_last { (remote_host.to_string(), remote_port) } else { entry_endpoint(index + 1) }; let layer_type = match layer { TransportLayerConfig::Ssh(_) => PlannedLayerType::Ssh, TransportLayerConfig::Proxy(_) => PlannedLayerType::Proxy, @@ -442,7 +500,7 @@ mod tests { let mut resolved = ssh.clone(); resolved.host = "10.0.0.5".to_string(); resolved.port = 2222; - resolved + vec![resolved] }); assert_eq!( @@ -471,7 +529,7 @@ mod tests { resolved.host = "10.0.0.5".to_string(); resolved.port = 2222; } - resolved + vec![resolved] }); assert_eq!( @@ -495,6 +553,35 @@ mod tests { ); } + #[test] + fn proxy_jump_chain_dials_first_hop_and_forwards_to_next_layer() { + // Issue #7706: an SSH layer whose alias resolves to a `ProxyJump` + // chain must dial the outermost jump first, not the final target. + let layers = vec![ssh_layer("ssh-a", "target-alias", 22)]; + + let planned = plan_transport_layers_with_resolver(&layers, "db.internal", 5432, &[], |ssh| { + assert_eq!(ssh.host, "target-alias"); + let mut jump = ssh.clone(); + jump.host = "1.1.1.1".to_string(); + jump.port = 22; + let mut target = ssh.clone(); + target.host = "1.1.1.2".to_string(); + target.port = 2222; + vec![jump, target] + }); + + assert_eq!( + planned, + vec![PlannedTransportLayer { + layer_type: PlannedLayerType::Ssh, + connect_host: "1.1.1.1".to_string(), + connect_port: 22, + remote_host: "db.internal".to_string(), + remote_port: 5432, + }] + ); + } + #[test] fn http_tunnel_first_layer_targets_next_layer() { let layers = vec![ diff --git a/crates/dbx-core/src/ssh_config.rs b/crates/dbx-core/src/ssh_config.rs index 17a6083a66..5096a55504 100644 --- a/crates/dbx-core/src/ssh_config.rs +++ b/crates/dbx-core/src/ssh_config.rs @@ -17,6 +17,11 @@ pub struct SshConfigHostEntry { 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 @@ -50,6 +55,73 @@ pub fn resolve_ssh_tunnel_config(ssh: &SshTunnelConfig) -> SshTunnelConfig { } } +/// 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 @@ -87,9 +159,11 @@ fn apply_host_entry(ssh: &SshTunnelConfig, entry: SshConfigHostEntry) -> SshTunn } /// Parses a minimal subset of OpenSSH client config syntax: `Host`, `HostName`, -/// `Port`, `User`, `IdentityFile`. 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. +/// `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(); @@ -117,6 +191,7 @@ fn parse_ssh_config(content: &str) -> Vec { port: None, user: None, identity_file: None, + proxy_jump: None, }); } } @@ -136,6 +211,18 @@ fn parse_ssh_config(content: &str) -> Vec { "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()); + }); + } + } + } _ => {} } } @@ -237,6 +324,24 @@ mod tests { 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(), @@ -244,6 +349,7 @@ mod tests { port: Some(2222), user: Some("deploy".to_string()), identity_file: Some("~/.ssh/id_ed25519".to_string()), + proxy_jump: None, } } @@ -312,4 +418,81 @@ mod tests { 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"); + } }