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::{
AddressFamily, AuthMethod, Client, ClientHandler, 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,
auth_mutex: &tokio::sync::Mutex<()>,
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
.with_context(|| {
format!(
"Timeout opening tunnel to jump host {}:{} after {}s",
jump_host.host,
jump_host.effective_port(),
connect_timeout.as_secs()
)
})?
.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,
auth_mutex,
)
.await?;
let config = Arc::new(ssh_connection_config.to_russh_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(strict_mode);
let handler = ClientHandler::new(jump_host.host.clone(), socket_addr, check_method);
let handle = tokio::time::timeout(
connect_timeout,
russh::client::connect_stream(config, stream, handler),
)
.await
.with_context(|| {
format!(
"Timeout establishing SSH over tunnel to {}:{} after {}s",
jump_host.host,
jump_host.effective_port(),
connect_timeout.as_secs()
)
})?
.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
.with_context(|| {
format!(
"Failed to authenticate to {} as user '{}'",
host_desc,
jump_host.effective_user()
)
})?;
let client =
Client::from_handle_and_address(Arc::new(handle), jump_host.effective_user(), socket_addr);
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
.with_context(|| {
format!(
"Timeout opening tunnel to destination {}:{} after {}s",
destination_host, destination_port, connect_timeout.as_secs()
)
})?
.with_context(|| {
format!(
"Failed to open direct-tcpip channel to destination {destination_host}:{destination_port}"
)
})?;
let stream = channel.into_stream();
let config = Arc::new(ssh_connection_config.to_russh_config());
let check_method = crate::ssh::known_hosts::get_check_method(strict_mode);
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(destination_host.to_string(), socket_addr, check_method);
let handle = tokio::time::timeout(
connect_timeout,
russh::client::connect_stream(config, stream, handler),
)
.await
.with_context(|| {
format!(
"Timeout establishing SSH to destination {}:{} after {}s",
destination_host, destination_port, connect_timeout.as_secs()
)
})?
.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
.with_context(|| {
format!(
"Failed to authenticate to {} as user '{}'",
dest_desc, destination_user
)
})?;
let client = Client::from_handle_and_address(
Arc::new(handle),
destination_user.to_string(),
socket_addr,
);
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}"
);
}
}