fix(ssh): follow ssh config ProxyJump hops in tunnels
This commit is contained in:
1 parent
0938b6bee0
commit
0bccda193b
3 files changed
+409
-131
No files matched your search
@@ -2906,41 +2906,49 @@ impl AppState {
|
||||
pub async fn test_tunnel_profile(&self, profile: &TransportLayerConfig) -> Result<String, String> {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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<TransportLayerConfig> {
|
||||
resolve: impl Fn(&SshTunnelConfig) -> Vec<SshTunnelConfig>,
|
||||
) -> Vec<Option<Vec<SshTunnelConfig>>> {
|
||||
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<LayerEndpoint> = 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<PlannedTransportLayer> {
|
||||
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<crate::models::connection::SshTunnelConfig>,
|
||||
) -> Vec<PlannedTransportLayer> {
|
||||
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![
|
||||
|
||||
@@ -17,6 +17,11 @@ pub struct SshConfigHostEntry {
|
||||
pub port: Option<u16>,
|
||||
pub user: Option<String>,
|
||||
pub identity_file: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<SshTunnelConfig> {
|
||||
resolve_chain_from_entries(ssh, &list_hosts().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn resolve_chain_from_entries(ssh: &SshTunnelConfig, entries: &[SshConfigHostEntry]) -> Vec<SshTunnelConfig> {
|
||||
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<SshConfigHostEntry> {
|
||||
let mut entries: Vec<SshConfigHostEntry> = Vec::new();
|
||||
let mut current_aliases: Vec<String> = Vec::new();
|
||||
@@ -117,6 +191,7 @@ fn parse_ssh_config(content: &str) -> Vec<SshConfigHostEntry> {
|
||||
port: None,
|
||||
user: None,
|
||||
identity_file: None,
|
||||
proxy_jump: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -136,6 +211,18 @@ fn parse_ssh_config(content: &str) -> Vec<SshConfigHostEntry> {
|
||||
"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<_>>(),
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user