From 2e5a05d017128debe3eb935a38d50b291eaa7953 Mon Sep 17 00:00:00 2001 From: timlyo Date: Wed, 11 Feb 2026 19:07:33 +0000 Subject: [PATCH 1/2] feat: Add Client function to connect via a proxy --- src/client.rs | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/client.rs b/src/client.rs index 197f077..018d7c2 100644 --- a/src/client.rs +++ b/src/client.rs @@ -298,6 +298,75 @@ impl Client { }) } + /// Open a connection to a remote host via an existing connection. Can be + /// used for Proxy Jumping to hosts that are only accessible from a + /// different remote host. + pub async fn connect_via( + via: &Client, + addr: impl ToSocketAddrsWithHostname, + username: &str, + auth: AuthMethod, + server_check: ServerCheckMethod, + ) -> Result { + Self::connect_via_with_config(via, addr, username, auth, server_check, Config::default()) + .await + } + + /// Same as `connect_via`, but with the option to specify a non default + /// [`russh::client::Config`]. + pub async fn connect_via_with_config( + via: &Client, + addr: impl ToSocketAddrsWithHostname, + username: &str, + auth: AuthMethod, + server_check: ServerCheckMethod, + config: Config, + ) -> Result { + let config = Arc::new(config); + + let socket_addrs = addr + .to_socket_addrs() + .map_err(crate::Error::AddressInvalid)?; + let username = username.to_string(); + let mut connect_res = Err(crate::Error::AddressInvalid(io::Error::new( + io::ErrorKind::InvalidInput, + "could not resolve to any addresses", + ))); + + for socket_addr in socket_addrs { + let channel = match via.open_direct_tcpip_channel(socket_addr, None).await { + Ok(channel) => channel, + Err(e) => { + connect_res = Err(e); + continue; + } + }; + + let handler = ClientHandler { + hostname: addr.hostname(), + host: socket_addr, + server_check: server_check.clone(), + }; + + match russh::client::connect_stream(config.clone(), channel.into_stream(), handler) + .await + { + Ok(mut handle) => { + Self::authenticate(&mut handle, &username, auth).await?; + + return Ok(Self { + connection_handle: Arc::new(handle), + username, + address: socket_addr, + }); + } + Err(e) => connect_res = Err(e), + } + } + + connect_res + } + /// This takes a handle and performs authentification with the given method. async fn authenticate( handle: &mut Handle, @@ -1008,6 +1077,7 @@ mod tests { use dotenv::dotenv; use std::path::Path; use std::sync::Once; + use tokio::io::AsyncReadExt; static INIT: Once = Once::new(); From ed33c707f040641e52bbb1261874e376f586d669 Mon Sep 17 00:00:00 2001 From: timlyo Date: Wed, 11 Feb 2026 19:07:33 +0000 Subject: [PATCH 2/2] test: Test Client function to connect via a proxy --- .env | 2 ++ src/client.rs | 54 +++++++++++++++++++++++++++++++ tests/async-ssh2-tokio/Dockerfile | 2 ++ tests/docker-compose.yml | 12 +++++++ 4 files changed, 70 insertions(+) diff --git a/.env b/.env index ad3ba92..f7319a2 100644 --- a/.env +++ b/.env @@ -12,4 +12,6 @@ ASYNC_SSH2_TEST_SERVER_PUB=./tests/sshd-test/ssh_host_ed25519_key.pub ASYNC_SSH2_TEST_UPLOAD_FILE=./tests/async-ssh2-tokio/test-upload-file ASYNC_SSH2_TEST_HTTP_SERVER_IP=10.10.10.4 ASYNC_SSH2_TEST_HTTP_SERVER_PORT=8000 +ASYNC_SSH2_TEST_SSH_SERVER_2_IP=10.10.10.5 +ASYNC_SSH2_TEST_SSH_SERVER_2_PORT=22 ASYNC_SSH2_TEST_HOST_NAME=localhost diff --git a/src/client.rs b/src/client.rs index 018d7c2..9fccdf2 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1315,6 +1315,60 @@ mod tests { assert_eq!("Hello", body); } + #[tokio::test] + async fn connect_via_existing_client() { + let client_1 = establish_test_host_connection().await; + let client_2 = Client::connect_via( + &client_1, + format!( + "{}:{}", + env("ASYNC_SSH2_TEST_SSH_SERVER_2_IP"), + env("ASYNC_SSH2_TEST_SSH_SERVER_2_PORT"), + ), + &client_1.username, + AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), + ServerCheckMethod::NoCheck, + ) + .await + .unwrap(); + + // Check hostnames to ensure we've connected to the expected places + assert_eq!( + "ssh-server", + client_1.execute("hostname").await.unwrap().stdout.trim() + ); + assert_eq!( + "ssh-server-2", + client_2.execute("hostname").await.unwrap().stdout.trim() + ); + } + + #[tokio::test] + async fn connect_via_existing_client_multiple_addresses() { + let client_1 = establish_test_host_connection().await; + Client::connect_via( + &client_1, + vec![ + // First address in invalid and should fail + SocketAddr::from(([10, 0, 0, 0], 22)), + // Should still be able to connect via the second + format!( + "{}:{}", + env("ASYNC_SSH2_TEST_SSH_SERVER_2_IP"), + env("ASYNC_SSH2_TEST_SSH_SERVER_2_PORT"), + ) + .parse() + .unwrap(), + ] + .as_slice(), + &client_1.username, + AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), + ServerCheckMethod::NoCheck, + ) + .await + .unwrap(); + } + #[tokio::test] async fn stderr_redirection() { let client = establish_test_host_connection().await; diff --git a/tests/async-ssh2-tokio/Dockerfile b/tests/async-ssh2-tokio/Dockerfile index a9f61a2..c5e327b 100644 --- a/tests/async-ssh2-tokio/Dockerfile +++ b/tests/async-ssh2-tokio/Dockerfile @@ -5,6 +5,8 @@ ENV ASYNC_SSH2_TEST_HOST_USER=root ENV ASYNC_SSH2_TEST_HOST_PW=root ENV ASYNC_SSH2_TEST_HTTP_SERVER_IP=10.10.10.4 ENV ASYNC_SSH2_TEST_HTTP_SERVER_PORT=8000 +ENV ASYNC_SSH2_TEST_SSH_SERVER_2_IP=10.10.10.5 +ENV ASYNC_SSH2_TEST_SSH_SERVER_2_PORT=22 ENV ASYNC_SSH2_TEST_CLIENT_PRIV=/root/.ssh/id_ed25519 ENV ASYNC_SSH2_TEST_CLIENT_PROT_PRIV=/root/.ssh/prot.id_ed25519 ENV ASYNC_SSH2_TEST_CLIENT_PROT_PASS=test diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index beeaf50..13b4aca 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -1,6 +1,7 @@ --- services: ssh-server: + hostname: ssh-server platform: linux/amd64 build: context: ./sshd-test @@ -8,6 +9,16 @@ services: networks: ssh-network: ipv4_address: 10.10.10.2 + # Used for `connect_via` proxy jumping tests + ssh-server-2: + hostname: ssh-server-2 + platform: linux/amd64 + build: + context: ./sshd-test + container_name: ssh-server-2 + networks: + ssh-network: + ipv4_address: 10.10.10.5 # The HTTP server is used for the `direct-tcpip` channel test. http-server: image: python:alpine @@ -29,6 +40,7 @@ services: ipv4_address: 10.10.10.3 depends_on: - ssh-server + - ssh-server-2 - http-server networks: ssh-network: