Posts from this year

Questions or comments? Email me.

Thu 29 May 2025

Connecting tokio-postgres over an SSH tunnel

I recently found myself writing a quick script to toggle some jobs on a remote server. The server itself runs a bog-standard PostgreSQL instance to store and manage the jobs, and the setup itself isn't so important that I wanted to spend the effort cobbling together an HTTP server and endpoint - just a quick call over SSH should be more than enough. Now, you could do this in any language, framework, or environment - but the overall project is written in Rust, and I like to keep things uniform where it makes sense to do so. After perusing some docs for a minute or two, it seemed relatively straightforward to do - but I realized I hadn't seen any good examples of this floating around, and so I figured I may as well throw one up here.

This won't be as in-depth or complete as other posts on this site, but the general approach is correct and I'm confident that interested parties can glean what they need from here. It's expected that you know what/why you're reading here.

Crates We Need

There's really only two or three crates that we need to be dealing with here.

The latter two crates are key: how do we make tokio-postgres call over SSH?

Step 1: SSH

Let's get our SSH tunnel sorted. A few key variables we need up-front:

// Remember, this is an example - you don't typically want to shove these
// (some or all) into your codebase. You probably want to load these 
// from your environment, or however you typically handle sensitive keys.
const SERVER: (&str, u16) = ("your.server.ip.address", 22);
const USER: &str = "your_ssh_username_here";
const KEY_PATH: &str = "path_to_your_ssh_key_here";
const DB_SERVER: (&str, u16) = ("127.0.0.1", 5432);
const DB_USER: &str = "your_postgres_username_here";
const DB_PASSWORD: &str = "your_postgres_password_here";
const DB_NAME: &str = "your_database_name_here";

Next, we can go ahead and start the SSH tunnel. The flow is relatively straightforward: open the connection, connect to Postgres, and then pass the stream to tokio-postgres to use. At time of writing, open_direct_tcpip_channel has no documentation - but it's thankfully an explanatory enough method name that has the same idea as what you'd find in other languages and ecosystems.

use async_ssh2_tokio::{Client, AuthMethod, ServerCheckMethod};

let key = AuthMethod::with_key_file(KEY_PATH, None);

match Client::connect(SERVER, USER, key, ServerCheckMethod::NoCheck).await {
    Ok(client) => match client.open_direct_tcpip_channel(DB_SERVER, None).await {
        Ok(channel) => {
            if let Err(error) = do_pg_stuff(channel.into_stream()).await {
                eprintln!("Failed to do PG stuff: {:?}", error);
            }
        },

        Err(error) => {
            eprintln!("Unable to open TCP/IP channel: {:?}", error);
        }
    },

    Err(error) => {
        eprintln!("Unable to connect via SSH: {:?}", error);
    }
}

Step 2: Postgres

With a successful tunnel going, we can complete our Postgres client and execute a simple select to make sure things work. The key part is calling connect_raw, which accepts a stream interface. Rather than deal with type hell, we're going to just take the easy route and let the compiler infer things for us through our parameter S.

use std::marker::Unpin;

use tokio::io::{AsyncRead, AsyncWrite};
use tokio_postgres::{Config, Error, NoTls};

async fn do_pg_stuff<S>(stream: S) -> Result<(), Error>
where
    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
    let mut config = Config::new();
    config.user(DB_USER);
    config.password(DB_PASSWORD);
    config.dbname(DB_NAME);

    let (client, connection) = config.connect_raw(stream, NoTls).await?;
    
    tokio::spawn(async move {
        if let Err(error) = connection.await {
            eprintln!("Connection error: {:?}", error);
        }
    });

    let rows = client
        .query("SELECT $1::TEXT", &[&"hello world"])
        .await?;

    let value: &str = rows[0].get(0);
    println!("{}", value);

    Ok(())
}

Put it all together, and you have a Postgres client in Rust over an SSH tunnel. This is definitely simpler with tokio-postgres; if you find yourself wanting to do this with diesel or sqlx, you'll likely need to do some extra legwork with their respective Connection traits to ferry things back and forth.

Ryan around the Web