Let's Connect

A terminal window over a network diagram, illustrating an SSH tunnel forwarding a local port to a private remote database

SSH port forwarding in Rust is what I reached for the day I needed to open a Postgres GUI against a database that only listens on a private VPC subnet, with no public ingress at all. The fix is local forwarding: bind a port on my laptop, and every byte that hits it gets tunneled over an SSH session I already hold to a bastion that can see the database, then on to 10.0.1.20:5432. The terminal app I built on Tauri and russh already had that SSH session open, so adding `ssh -L` behavior was a matter of a TcpListener, one russh channel call, and tokio's bidirectional copy. No external ssh binary, no separate process. Here is exactly how it fits together.

Three kinds of forwarding, and which problem each one solves

Before writing any code I had to be clear about which direction I actually needed, because SSH gives you three and they are easy to confuse. They all push TCP through the encrypted session, but the listener lives in a different place each time.

  • Local forwarding (-L): the listener is on my machine. I connect to a local port, traffic comes out on the remote side. This is the one for reaching a private database, an internal admin panel, or a metrics endpoint that the bastion can see but I cannot.
  • Remote forwarding (-R): the listener is on the remote server. Something on that side connects to its port, traffic comes back out on mine. This is how you expose a service running on your laptop to the remote network, or punch a temporary callback path back through a jump host.
  • Dynamic forwarding (-D): the listener is local and speaks SOCKS. The client tells it the destination per-connection, so one tunnel becomes a general-purpose proxy instead of a single fixed host:port pair. Useful when you do not know all the targets in advance.

My use case was squarely local forwarding. The database address and port are fixed, I just cannot route to them directly. If you have followed my earlier work on this app in the offline SSH client build and the SSH and SFTP with russh post, this slots in beside the shell and file-transfer channels on the same authenticated session.

How does local forwarding actually work over an existing session?

The mechanics are simpler than the SSH spec makes them sound. I bind a tokio TcpListener locally. For each inbound connection I open a `direct-tcpip` channel on the live SSH session, telling the server which host and port to dial on my behalf. The server connects there, and now I have two byte pipes: the TCP socket on one side, the SSH channel on the other. I splice them with `tokio::io::copy_bidirectional` and let it run until either end closes.

The one detail that bit me first: the russh session handle has to be shared across every accepted connection, because each new TCP client needs to open its own channel on that same session. I keep the `russh::client::Handle` behind an `Arc` (cloning it is cheap; it is a handle to the connection task) and move a clone into each spawned task. The originator address and port arguments are informational metadata the server logs; they do not have to be real, but I pass the actual peer address so remote-side logs make sense.

src-tauri/src/tunnel.rs
use std::sync::Arc;
use russh::client::{Handle, Msg};
use russh::Channel;
use tokio::net::TcpListener;

/// Local forward: bind `local_addr` and tunnel every connection to
/// `remote_host:remote_port`, reachable from the SSH server's side.
/// `H` is the Handler your client::connect() was generic over.
pub async fn local_forward<H: russh::client::Handler>(
    session: Arc<Handle<H>>,
    local_addr: &str,        // e.g. "127.0.0.1:55432"
    remote_host: String,     // e.g. "10.0.1.20" (private DB)
    remote_port: u32,        // e.g. 5432
) -> anyhow::Result<()> {
    let listener = TcpListener::bind(local_addr).await?;

    loop {
        let (mut socket, peer) = listener.accept().await?;
        let session = session.clone();
        let host = remote_host.clone();

        tokio::spawn(async move {
            // Ask the SSH server to dial the private target for us.
            let channel: Channel<Msg> = match session
                .channel_open_direct_tcpip(
                    host,
                    remote_port,
                    peer.ip().to_string(), // originator address (metadata)
                    peer.port() as u32,    // originator port  (metadata)
                )
                .await
            {
                Ok(ch) => ch,
                Err(e) => {
                    eprintln!("channel open failed: {e}");
                    return;
                }
            };

            // into_stream() gives an AsyncRead + AsyncWrite over the channel.
            let mut stream = channel.into_stream();

            // Splice the local TCP socket and the SSH channel both ways
            // until one side hits EOF.
            if let Err(e) =
                tokio::io::copy_bidirectional(&mut socket, &mut stream).await
            {
                eprintln!("tunnel closed for {peer}: {e}");
            }
        });
    }
}

That is the whole local-forward path. `channel_open_direct_tcpip` returns a `Channel<Msg>`; calling `into_stream()` on it hands back a type that implements `AsyncRead + AsyncWrite`, which is exactly what `copy_bidirectional` wants on both arguments. The plain tokio `TcpStream` already satisfies that on the other side, so the two ends couple with no manual read/write loop. After this runs, pointing my Postgres client at `127.0.0.1:55432` lands queries on the private box as if it were local.

Network switch with multiple cables, representing TCP connections being multiplexed through a single SSH tunnel
Every local connection opens its own direct-tcpip channel, but they all ride the one authenticated SSH session.

What changes for remote forwarding?

Remote forwarding flips the listener to the server side, so the API is a two-step dance instead of one call. First I ask the server to start listening with `tcpip_forward(address, port)`. Passing port 0 tells the server to pick a free port and return it, which is handy when you do not care about the exact number. Then, crucially, the connections do not come back through a method I call. They arrive as callbacks on my Handler. The server opens a channel toward me for each inbound connection, and russh invokes `server_channel_open_forwarded_tcpip` with that channel.

src-tauri/src/handler.rs
use russh::client::{Handler, Session};
use russh::{Channel, client::Msg};
use tokio::net::TcpStream;

impl Handler for MyClient {
    type Error = russh::Error;

    // Called once per connection the server forwards back to us.
    async fn server_channel_open_forwarded_tcpip(
        &mut self,
        channel: Channel<Msg>,
        connected_address: &str,
        connected_port: u32,
        _originator_address: &str,
        _originator_port: u32,
        _session: &mut Session,
    ) -> Result<(), Self::Error> {
        // Decide where to send it on OUR side. For a classic -R that
        // exposes a local service, dial that local target.
        let local_target = format!("127.0.0.1:{}", self.local_service_port);
        let dest = connected_address.to_string();
        let dport = connected_port;

        tokio::spawn(async move {
            let Ok(mut local) = TcpStream::connect(local_target).await else {
                eprintln!("no local service for {dest}:{dport}");
                return;
            };
            let mut stream = channel.into_stream();
            let _ = tokio::io::copy_bidirectional(&mut local, &mut stream).await;
        });

        Ok(())
    }
}

// Elsewhere, after authenticating, request the forward:
// let bound_port = session.tcpip_forward("127.0.0.1", 8000).await?;
// println!("server now listening on {bound_port}");

Note the inversion. With local forwarding I drove the loop and opened channels. With remote forwarding the server drives it and I react, so the bidirectional copy lives inside the Handler callback rather than an accept loop. Same `into_stream()` plus `copy_bidirectional` underneath; only the trigger differs. Dynamic SOCKS forwarding is local forwarding with the destination decided per-connection from the SOCKS handshake instead of being hardcoded, so it reuses the same `channel_open_direct_tcpip` call with a parsed target.

Once you internalize that a tunnel is just two byte streams spliced together, the SSH part stops being magic. The only real work is getting the right stream on each side and keeping the session alive long enough to carry them.Md Raihan Hasan

The gotchas that cost me real time

The code above is the happy path. A few things broke in ways that were not obvious from the compiler, and they are the difference between a demo and something I trust against production infrastructure.

  • Share the session, do not reopen it. Every forwarded connection must open its channel on the one authenticated Handle. I learned this the slow way by accidentally reconnecting per request and watching latency and auth load explode. Arc the handle, clone it into each task.
  • Keep the connection task alive. russh runs the transport on a background task; if your Handle and its owner get dropped, the session dies and every channel with it. Hold the Arc somewhere with a lifetime that outlives the tunnel.
  • copy_bidirectional propagates EOF, not errors gracefully. A client that closes one half-duplex direction is normal, so do not treat every returned error as a failure to log loudly. Connection reset on a database client that just disconnected is expected noise.
  • The bind address matters for exposure. Binding the local listener to 0.0.0.0 instead of 127.0.0.1 turns your private tunnel into something anyone on your LAN can ride. Default to loopback unless you have a deliberate reason not to.
  • Outbound reachability on the server side is a separate problem. If channel_open_direct_tcpip fails, the SSH server could not reach the target. I have debugged that exact wall on AWS, where the answer was a security group, not my code, which I wrote up in blocked outbound TCP on EC2.

If you want the protocol-level detail behind these channel types, the OpenSSH and connection-protocol docs are the authoritative source, but for the russh surface itself the russh crate docs carry the exact signatures I used here.

What I like about doing this in-process is that the tunnel is no longer a second tool I have to babysit. My terminal app authenticates once, and forwarding, shell, and SFTP all ride that same session as ordinary channels. For reaching a locked-down database or an internal dashboard from a developer machine, that is the whole feature: a TcpListener, a `direct-tcpip` channel, and `copy_bidirectional` doing the boring work of moving bytes. Get the shared-session lifetime right, default your bind to loopback, and you have a tunnel you can actually trust.