DEV Community

Paweł bbkr Pabian
Paweł bbkr Pabian

Posted on

3

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.

Billboard image

Monitor more than uptime.

With Checkly, you can use Playwright tests and Javascript to monitor end-to-end scenarios in your NextJS, Astro, Remix, or other application.

Get started now!

Top comments (3)

Collapse
 
eugeny profile image
Eugene

Very nice writeup, definitely some things in here that I can improve in the crate docs. Lmk if you ran into any other hickups setting up russh that can be improved with better docs

Collapse
 
bbkr profile image
Paweł bbkr Pabian

First and foremost I'd like to thank you for maintainig russh crate.

For me the biggest challenge was to understand:

  • Why do I have to implement client::Handler?
  • Which parts of this trait do I actually need to implement and what is provided by default implementation?
  • Why this trait does not provide and encapsulte actual connection?
struct Client { }
impl client::Handler for Client { ... }
client::connect( ..., Client{ }  );
Enter fullscreen mode Exit fullscreen mode

Such flow is distant from what user of typical OO libraries is used to - where you plop everything into constructor and Client object does Client things. It "clicked" when I realized that I have to stop thinking in OO terms and my implementation of client::Handler trait passed to connect method is just definition of handlers for SSH session features.

In fact impulse for writing this article was pile of notes I made after deabstracting code from examples section. So from my perspective of noob Rust developer more streamlined examples and better explanation of client::Handler would be most beneficial to overcome initial high entry level of using this crate.

Collapse
 
eugeny profile image
Eugene

Thanks for the feedback! The reason why both server and client have their Handler traits is due to the async nature of the SSH protcol. The other side may trigger asynchronous events at any time (e.g. host key checking happens while client is blocked on the .connect() call, and channel requests can happen simply at any moment whatsoever), so this is the least painful way to represent it within the Rust ownership system (the other option is to have a set of MPSC channels for these events and replies, which is what happens internally in russh).

As you can see from the examples, for the client you're only required to implement check_server_key unless you want to listen to other non-channel-related events. But for the server side, there are significantly more events that you'd listen for.

I've already updated the docs on the client trait to mention what needs overriding.

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Engage with a sea of insights in this enlightening article, highly esteemed within the encouraging DEV Community. Programmers of every skill level are invited to participate and enrich our shared knowledge.

A simple "thank you" can uplift someone's spirits. Express your appreciation in the comments section!

On DEV, sharing knowledge smooths our journey and strengthens our community bonds. Found this useful? A brief thank you to the author can mean a lot.

Okay