Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
124 changes: 124 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +301 to +303

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding more comprehensive documentation for this new public API method, similar to the documentation for the connect method. The documentation should explain what happens when addr yields multiple addresses, how authentication works, and describe the parameters in detail. This would help users understand the proxy jumping feature better.

Copilot uses AI. Check for mistakes.
pub async fn connect_via(
via: &Client,
addr: impl ToSocketAddrsWithHostname,
username: &str,
auth: AuthMethod,
server_check: ServerCheckMethod,
) -> Result<Self, crate::Error> {
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<Self, crate::Error> {
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<ClientHandler>,
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -1245,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;
Expand Down
2 changes: 2 additions & 0 deletions tests/async-ssh2-tokio/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions tests/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
---
services:
ssh-server:
hostname: ssh-server
platform: linux/amd64
build:
context: ./sshd-test
container_name: ssh-server
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
Expand All @@ -29,6 +40,7 @@ services:
ipv4_address: 10.10.10.3
depends_on:
- ssh-server
- ssh-server-2
- http-server
networks:
ssh-network:
Expand Down