use super::auth::authenticate_connection;
use crate::jump::parser::JumpHost;
use crate::jump::rate_limiter::ConnectionRateLimiter;
use crate::security::Password;
use crate::ssh::known_hosts::StrictHostKeyChecking;
use crate::ssh::tokio_client::connection::{
KnownHostRuntimePolicy, apply_known_hosts_command_order,
};
use crate::ssh::tokio_client::{
AddressFamily, AuthMethod, Client, ClientHandler, Error as SshError, SshConnectionConfig,
};
use anyhow::{Context, Result};
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::Path;
use std::sync::Arc;
use tracing::debug;
fn resolve_handler_address(
host: &str,
port: u16,
address_family: AddressFamily,
) -> Result<SocketAddr> {
let candidates: Vec<SocketAddr> = format!("{host}:{port}").to_socket_addrs()?.collect();
if address_family.is_forced()
&& let Some(addr) = address_family.first_match(&candidates)
{
return Ok(addr);
}
if address_family.is_forced() && !candidates.is_empty() {
debug!(
"No {address_family} address resolved for {host}:{port}; using {} for host key context",
candidates[0]
);
}
candidates
.into_iter()
.next()
.context("No addresses resolved")
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn connect_through_tunnel(
previous_client: &Client,
jump_host: &JumpHost,
key_path: Option<&Path>,
use_agent: bool,
use_password: bool,
pre_collected_password: Option<Arc<Password>>,
strict_mode: StrictHostKeyChecking,
connect_timeout: std::time::Duration,
rate_limiter: &ConnectionRateLimiter,
ssh_connection_config: &SshConnectionConfig,
) -> Result<Client> {
debug!(
"Opening tunnel to jump host: {} ({}:{})",
jump_host,
jump_host.host,
jump_host.effective_port()
);
rate_limiter
.try_acquire(&jump_host.host.clone())
.await
.with_context(|| format!("Rate limited for jump host {}", jump_host.host))?;
let channel = tokio::time::timeout(
connect_timeout,
previous_client.open_direct_tcpip_channel_with_family(
(jump_host.host.as_str(), jump_host.effective_port()),
None,
ssh_connection_config.address_family,
),
)
.await
.map_err(|_| SshError::ConnectionTimeout {
host: jump_host.host.clone(),
port: jump_host.effective_port(),
seconds: connect_timeout.as_secs(),
stage: "jump-host channel open",
})?
.with_context(|| {
format!(
"Failed to open direct-tcpip channel to jump host {}:{}",
jump_host.host,
jump_host.effective_port()
)
})?;
let stream = channel.into_stream();
let auth_method = super::auth::determine_auth_method(
jump_host,
key_path,
use_agent,
use_password,
pre_collected_password,
ssh_connection_config,
)
.await?;
let policy = KnownHostRuntimePolicy::from_config(
ssh_connection_config,
&jump_host.effective_user(),
&jump_host.host,
&jump_host.host,
jump_host.effective_port(),
);
let mut config = ssh_connection_config.to_russh_config();
if ssh_connection_config.order_host_key_algorithms {
apply_known_hosts_command_order(
&mut config,
&policy,
ssh_connection_config.host_key_alias.as_deref(),
)
.await?;
}
let config = Arc::new(config);
let socket_addr = resolve_handler_address(
&jump_host.host,
jump_host.effective_port(),
ssh_connection_config.address_family,
)
.with_context(|| {
format!(
"Failed to resolve jump host address: {}:{}",
jump_host.host,
jump_host.effective_port()
)
})?;
let check_method = crate::ssh::known_hosts::get_check_method_for_target(
strict_mode,
ssh_connection_config,
&jump_host.host,
jump_host.effective_port(),
&jump_host.effective_user(),
);
let handler =
ClientHandler::new_with_policy(jump_host.host.clone(), socket_addr, check_method, policy);
let fatal_transport = handler.fatal_transport_state();
let hostkey_rotation = handler.hostkey_rotation_tasks();
let remote_forward_registry = handler.remote_forward_registry();
let agent_forwarding = handler.agent_forwarding_state();
let handle = tokio::time::timeout(
connect_timeout,
russh::client::connect_stream(config, stream, handler),
)
.await
.map_err(|_| SshError::ConnectionTimeout {
host: jump_host.host.clone(),
port: jump_host.effective_port(),
seconds: connect_timeout.as_secs(),
stage: "jump-host protocol negotiation",
})?
.with_context(|| {
format!(
"Failed to establish SSH connection over tunnel to {}:{}",
jump_host.host,
jump_host.effective_port()
)
})?;
let mut handle = handle;
let host_desc = format!(
"jump host '{}:{}'",
jump_host.host,
jump_host.effective_port()
);
authenticate_connection(
&mut handle,
&jump_host.effective_user(),
auth_method,
&host_desc,
)
.await
.or_else(|error| {
fatal_transport
.take_error()
.map(anyhow::Error::new)
.map_or(Err(error), Err)
})
.with_context(|| {
format!(
"Failed to authenticate to {} as user '{}'",
host_desc,
jump_host.effective_user()
)
})?;
let client = Client::from_authenticated_handle_with_policy_state(
Arc::new(handle),
jump_host.effective_user(),
socket_addr,
fatal_transport,
hostkey_rotation,
remote_forward_registry,
agent_forwarding,
)
.await;
Ok(client)
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn connect_to_destination(
jump_client: &Client,
destination_host: &str,
destination_port: u16,
destination_user: &str,
dest_auth_method: AuthMethod,
strict_mode: StrictHostKeyChecking,
connect_timeout: std::time::Duration,
rate_limiter: &ConnectionRateLimiter,
ssh_connection_config: &SshConnectionConfig,
) -> Result<Client> {
debug!(
"Opening tunnel to destination: {}:{} as user {}",
destination_host, destination_port, destination_user
);
rate_limiter
.try_acquire(&destination_host.to_string())
.await
.with_context(|| format!("Rate limited for destination {destination_host}"))?;
let channel = tokio::time::timeout(
connect_timeout,
jump_client.open_direct_tcpip_channel_with_family(
(destination_host, destination_port),
None,
ssh_connection_config.address_family,
),
)
.await
.map_err(|_| SshError::ConnectionTimeout {
host: destination_host.to_string(),
port: destination_port,
seconds: connect_timeout.as_secs(),
stage: "destination channel open",
})?
.with_context(|| {
format!(
"Failed to open direct-tcpip channel to destination {destination_host}:{destination_port}"
)
})?;
let stream = channel.into_stream();
let policy = KnownHostRuntimePolicy::from_config(
ssh_connection_config,
destination_user,
destination_host,
destination_host,
destination_port,
);
let mut config = ssh_connection_config.to_russh_config();
if ssh_connection_config.order_host_key_algorithms {
apply_known_hosts_command_order(
&mut config,
&policy,
ssh_connection_config.host_key_alias.as_deref(),
)
.await?;
}
let config = Arc::new(config);
let check_method = crate::ssh::known_hosts::get_check_method_for_target(
strict_mode,
ssh_connection_config,
destination_host,
destination_port,
destination_user,
);
let socket_addr = resolve_handler_address(
destination_host,
destination_port,
ssh_connection_config.address_family,
)
.with_context(|| {
format!("Failed to resolve destination address: {destination_host}:{destination_port}")
})?;
let handler = ClientHandler::new_with_policy(
destination_host.to_string(),
socket_addr,
check_method,
policy,
);
let fatal_transport = handler.fatal_transport_state();
let hostkey_rotation = handler.hostkey_rotation_tasks();
let remote_forward_registry = handler.remote_forward_registry();
let agent_forwarding = handler.agent_forwarding_state();
let handle = tokio::time::timeout(
connect_timeout,
russh::client::connect_stream(config, stream, handler),
)
.await
.map_err(|_| SshError::ConnectionTimeout {
host: destination_host.to_string(),
port: destination_port,
seconds: connect_timeout.as_secs(),
stage: "destination protocol negotiation",
})?
.with_context(|| {
format!(
"Failed to establish SSH connection to destination {destination_host}:{destination_port}"
)
})?;
let mut handle = handle;
let dest_desc = format!("destination '{}:{}'", destination_host, destination_port);
authenticate_connection(&mut handle, destination_user, dest_auth_method, &dest_desc)
.await
.or_else(|error| {
fatal_transport
.take_error()
.map(anyhow::Error::new)
.map_or(Err(error), Err)
})
.with_context(|| {
format!(
"Failed to authenticate to {} as user '{}'",
dest_desc, destination_user
)
})?;
let client = Client::from_authenticated_handle_with_policy_state(
Arc::new(handle),
destination_user.to_string(),
socket_addr,
fatal_transport,
hostkey_rotation,
remote_forward_registry,
agent_forwarding,
)
.await;
client
.initialize_forwarding(&ssh_connection_config.forwarding_plan)
.await?;
Ok(client)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn handler_address_prefers_the_forced_family() {
let addr =
resolve_handler_address("::1", 2222, AddressFamily::V6).expect("IPv6 literal resolves");
assert!(addr.is_ipv6());
assert_eq!(addr.port(), 2222);
let addr = resolve_handler_address("127.0.0.1", 2222, AddressFamily::V4)
.expect("IPv4 literal resolves");
assert!(addr.is_ipv4());
}
#[test]
fn handler_address_falls_back_when_no_candidate_matches() {
let addr = resolve_handler_address("127.0.0.1", 22, AddressFamily::V6)
.expect("must fall back rather than fail");
assert!(addr.is_ipv4(), "expected the IPv4 fallback, got {addr}");
let addr = resolve_handler_address("::1", 22, AddressFamily::V4)
.expect("must fall back rather than fail");
assert!(addr.is_ipv6(), "expected the IPv6 fallback, got {addr}");
}
#[test]
fn handler_address_is_unchanged_when_no_family_is_forced() {
let addr = resolve_handler_address("127.0.0.1", 22, AddressFamily::Any)
.expect("IPv4 literal resolves");
assert_eq!(addr.to_string(), "127.0.0.1:22");
}
#[test]
fn handler_address_failure_does_not_duplicate_host_port_in_the_chain() {
let host = "no-such-host.bssh-test.invalid";
let port = 22;
let err = resolve_handler_address(host, port, AddressFamily::Any)
.with_context(|| format!("Failed to resolve jump host address: {host}:{port}"))
.expect_err("a reserved .invalid hostname must not resolve");
let rendered = format!("{err:#}");
let needle = format!("{host}:{port}");
assert_eq!(
rendered.matches(needle.as_str()).count(),
1,
"host:port should appear exactly once in the rendered chain, got: {rendered}"
);
}
}