DEV Community

Paweł bbkr Pabian
Paweł bbkr Pabian

Posted on

SSH port forwarding from within Rust code

Reminder

In this series I demonstrate how to create on demand SSH tunnel to connect to service in another network directly inside your code. I will be referring to theory described in the first post of the series, so please make sure you read it first.

Preparation

We will be using tokio async framework and russh crates. Add them to your project:

cargo add tokio --features=full
cargo add russh
cargo add russh_keys
cargo add async_trait

cargo build
Enter fullscreen mode Exit fullscreen mode

If you get compilation error

error: could not compile `p521` (lib)
thread 'opt cgu.0' has overflowed its stack
Enter fullscreen mode Exit fullscreen mode

then you should increase stack size during build, here is how to increase it from default 2MB to 16MB:

RUST_MIN_STACK=16777216 cargo build
Enter fullscreen mode Exit fullscreen mode

No abstractions or error handling

I want this code to be transparent in Steve Mould style. That means I will purposely use instant failures everywhere and no abstractions to focus only on working principle.

Boilerplate

Let's start by setting necessary imports and variables.

use std::sync::Arc;
use tokio::net::TcpListener;
use russh_keys::{self, ssh_key, key::PrivateKeyWithHashAlg};
use russh::client;
use async_trait::async_trait;

#[tokio::main]
async fn main() {

    let service_host: &str = "service";
    let service_port: u16 = 7878;
    let jump_host: &str = "jump";
    let jump_port: u16 = 22;
    let jump_user: &str = "me";
    let jump_private_key_file: &str = "/home/me/.ssh/jump";
    let jump_private_key_password = Some("s3cret!");
    let local_host: &str = "127.0.0.1";
    let local_port: u16 = 8080;
Enter fullscreen mode Exit fullscreen mode

As mentioned in previous post - jump_private_key_password should never be stored in code directly, this is for demonstration purposes only. You can have passwordless SSH key, in such case set it to None.

Connect to jump host

ssh-2

Russh does not provide SSH client directly, instead it provides russh::client::Handler trait that we must implement in our own struct.

struct Client {}

#[async_trait]
impl client::Handler for Client {
    type Error = russh::Error;
    async fn check_server_key (&mut self, _server_public_key: &ssh_key::PublicKey) -> Result<bool, Self::Error> {
        Ok(true)
    }
}

let ssh_client = Client{};
Enter fullscreen mode Exit fullscreen mode

By default russh client will not connect to any host. Implementation of host authenticity check through check_server_key method is mandatory part of using this module, which may be tricky for newcomers. Jump host key will be available as instance of russh_keys::key::PublicKey and you are expected to return Ok(true) for successful verification or Ok(false) if you do not want to proceed.

Our SSH connection needs config. Default values are sane, so you can go with that:

let ssh_config = client::Config::default();
Enter fullscreen mode Exit fullscreen mode

But if you want to replace some of them remember that you can still use remaining default values like so:

let ssh_config = client::Config {
    inactivity_timeout: Some(Duration::from_secs(5)),
    ..<_>::default()
};
Enter fullscreen mode Exit fullscreen mode

Because russh assumes config is meant to be passed to multiple clients on different threads we also must wrap it in Arc thingy:

let ssh_config = Arc::new(ssh_config);
Enter fullscreen mode Exit fullscreen mode

Third component required to connect is jump private key:

let jump_private_key = russh_keys::load_secret_key(
    jump_private_key_file, jump_private_key_password
).expect("Cannot load SSH private key");

let jump_private_key = PrivateKeyWithHashAlg::new(
    Arc::new(jump_private_key), None
).expect("Cannot determine SSH key algorithm");
Enter fullscreen mode Exit fullscreen mode

Note that this changed in v0.49.0 version of russh. Previously key and hashing algorithm were in one instance of key::PrivateKey, now key itself must be decorated with hashing algorithm separately.

Phew! After implementing client::Handler trait, setting up SSH config using client::Config struct and creating instance of private key we are ready to connect to jump host:

let mut ssh_session = client::connect(
    ssh_config,
    (jump_host, jump_port),
    ssh_client
).await.expect("Cannot connect to SSH host");

ssh_session.authenticate_publickey(
    jump_user, ssh_key_secret
).await.expect("Cannot authenticate using SSH key");
Enter fullscreen mode Exit fullscreen mode

Note that connect method requires tokio::net::ToSocketAddrs as second parameter, so it must be one tuple of two parameters instead of two standalone parameters.

Open local listener

ssh-3

let local_listener = TcpListener::bind(
    (local_host, local_port)
).await.expect("Cannot bind local port");
Enter fullscreen mode Exit fullscreen mode

Bind method also requires tokio::net::ToSocketAddrs as second parameter, so it must be one tuple of two parameters instead of two standalone parameters.

There is one cool trick here - if you do not know free local port up front you can provide 0 and system will assign one for you.

let local_listener = TcpListener::bind(
    (local_host, 0)
).await.expect("Cannot bind local port");
let local_port = local_listener.local_addr().unwrap().port();
Enter fullscreen mode Exit fullscreen mode

Open channel from jump to service

ssh-4

tokio::spawn(
        async move {
            let (mut local_socket, _) = local_listener.accept().await.expect("Cannot process local client");

            let ssh_channel = ssh_session.channel_open_direct_tcpip(
                service_host, service_port as u32,
                local_host, local_port as u32
            ).await.expect("Cannot open SSH forwarding channel");
            let mut ssh_stream = ssh_channel.into_stream();
            ...
        }
);        
Enter fullscreen mode Exit fullscreen mode

When we receive local connection we must ask jump host to create SSH channel that will pass TCP packets to service host. But we cannot wait for local connection in blocking manner so we spawn tokio thread for that purpose. This is simplified example that will wait for one connection and therefore allow us to use SSH forwarding once.

Note that channel_open_direct_tcpip does not use tokio::net::ToSocketAddrs but separate parameters. It is because ToSocketAddrs trait is for resolvable addresses while from local machine perspective service_host is not resolvable. Also port numbers must be of u32 type.

We immediately consume the channel to produce a bidirectionnal stream capable of sending and receiving ChannelMsg::Data as AsyncRead + AsyncWrite.

Create bidirectional data flow between local connection and SSH channel

Image description

tokio::spawn(
        async move {
            let (mut local_socket, _) = ...
            let mut ssh_stream = ...

            tokio::io::copy_bidirectional(
                &mut local_socket, &mut ssh_stream
            ).await.expect("Copy error between local socket and SSH stream");
        }
);        
Enter fullscreen mode Exit fullscreen mode

Last part is the easiest one due to copy_bidirectional method provided by tokio framework, which can automatically couple two ends as long as both implement AsyncRead + AsyncWrite traits.

Done

To recap here is whole code with marked spot where you can implement your own logic that requires connection to inaccessible service host but can now be achieved by connecting to local host that forwards it to service host through jump host.

use russh::client;
use russh_keys::{self, ssh_key, key::PrivateKeyWithHashAlg};
use tokio::net::TcpListener;
use std::sync::Arc;
use async_trait::async_trait;


#[tokio::main]
async fn main() {

    let service_host: &str = "service";
    let service_port: u16 = 7878;
    let jump_host: &str = "jump";
    let jump_port: u16 = 22;
    let jump_user: &str = "me";
    let jump_private_key_file: &str = "/home/me/.ssh/jump";
    let jump_private_key_password = Some("s3cret!");
    let local_host: &str = "127.0.0.1";
    let local_port: u16 = 8080;

    struct Client {}
    #[async_trait]
    impl client::Handler for Client {

        type Error = russh::Error;

        async fn check_server_key (&mut self, _server_public_key: &ssh_key::PublicKey) -> Result<bool, Self::Error> {
            Ok(true)
        }

    }
    let ssh_client = Client{};

    let ssh_config = client::Config::default();
    let ssh_config = Arc::new(ssh_config);

    let jump_private_key = russh_keys::load_secret_key(
        jump_private_key_file, jump_private_key_password
    ).expect("Cannot load SSH private key");
    let jump_private_key = PrivateKeyWithHashAlg::new(
        Arc::new(jump_private_key) ,None
    ).expect("Cannot determine SSH key algorithm");

    let mut ssh_session = client::connect(
        ssh_config, (jump_host, jump_port), ssh_client
    ).await.expect("Cannot connect to SSH host");

    ssh_session.authenticate_publickey(
        jump_user, jump_private_key
    ).await.expect("Cannot authenticate using SSH key");

    let local_listener = TcpListener::bind(
        (local_host, local_port)
    ).await.expect("Cannot bind local port");

    tokio::spawn(
        async move {
            let (mut local_socket, _) = local_listener.accept().await.expect("Cannot process local client");

            let ssh_channel = ssh_session.channel_open_direct_tcpip(
                service_host, service_port as u32,
                local_host, local_port as u32
            ).await.expect("Cannot open SSH forwarding channel");
            let mut ssh_stream = ssh_channel.into_stream();

            tokio::io::copy_bidirectional(
                &mut local_socket, &mut ssh_stream
            ).await.expect("Copy error between local socket and SSH stream");
        }
    );

    println!(
        "Connect to {}:{} to actually connect to {}:{}.",
        local_host, local_port,
        service_host, service_port
    );

    // your logic that requires service host goes here

}
Enter fullscreen mode Exit fullscreen mode

At this point you should understand mechanics behind ssh forwarding. As I mentioned before my spaghetti code was oversimplified on purpose to focus on working principle, however for production use I strongly recommend creating Session abstraction as in russh examples section.

I'm still pretty new to Rust so if you think something could/should be better implemented then let me know in the comments.

Top comments (0)