Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SSH Tunnels

Many production databases sit behind a bastion: the database refuses direct connections from the open internet, but you can ssh into a jumphost on the inside that can reach it. Most desktop database tools (DBeaver, DataGrip, Beekeeper, TablePlus) handle this with a “use SSH tunnel” checkbox; ferrule does the same with a CLI flag.

The tunnel is set up with russh (a pure-Rust SSH 2 client), opens a direct-tcpip channel to the database, and passes the underlying stream into the database driver. There is no ssh binary shelled out to and no ~/.ssh/config honored.

Quick start

ferrule query \
  --ssh-tunnel ec2-user@bastion.example.com \
  --ssh-key ~/.ssh/id_ed25519 \
  "postgres://app:pwd@db.internal:5432/myapp" \
  "SELECT * FROM users LIMIT 10;"

This:

  1. Opens an SSH session to ec2-user@bastion.example.com:22 using the key at ~/.ssh/id_ed25519.
  2. Asks the bastion to open a direct-tcpip channel to db.internal:5432.
  3. Hands that channel directly to tokio_postgres via connect_raw. The Postgres protocol — and TLS, if your URL has sslmode=require — runs end-to-end through the SSH stream.

The database URL stays clean: it’s the same string you’d copy out of the AWS RDS, GCP Cloud SQL, or Heroku console. SSH config goes in its own flags or its own profile keys.

Where SSH config lives

Three layers, primary to ad-hoc:

1. Profile keys (primary — .ferrule.toml)

For a connection you use repeatedly, put the SSH bits in the profile:

[connection.prod-pg]
url = "postgres://app:pwd@db.internal:5432/myapp"
ssh_host = "bastion.example.com"
ssh_user = "ec2-user"
ssh_port = 22
ssh_key  = "~/.ssh/prod-bastion.pem"

Then ferrule query prod-pg "SELECT 1" automatically tunnels through ec2-user@bastion.example.com:22 using the named key.

2. CLI flags (ad-hoc)

For one-shot use against a connection you haven’t profiled:

  • --ssh-tunnel [user@]host[:port] — atomic-replacement for the three SSH connection parameters. Matches pgcli’s flag syntax verbatim. If you pass --ssh-tunnel host (no user, no port), the user falls back to $USER and the port falls back to 22 — not to whatever the profile said. “One flag, one tunnel target.”
  • --ssh-key <path> — overrides ssh_key independently. Useful for testing different keys against the same bastion.

3. The URL stays plain

There is no ssh+postgres:// scheme. That style was tried, then backed out — every other tool in the ecosystem (DBeaver, DataGrip, Beekeeper, TablePlus, Sequel Ace, pgAdmin, MySQL Workbench, Navicat, SQLAlchemy, Prisma, TypeORM, libpq pg_service.conf, pgcli) keeps the SSH section separate from the database URL, and ferrule matches that consensus. The same postgres://... string works inside or outside the tunnel.

Key resolution

When you pass --ssh-tunnel ... (or set ssh_host in a profile), ferrule resolves the SSH key in this order. First hit wins:

  1. --ssh-key <path> (CLI) or ssh_key = "..." (profile).
  2. FERRULE_<NAME>_SSH_KEY=<path> env var (where <NAME> is the uppercased connection name with -_).
  3. ~/.ssh/id_ed25519.
  4. ~/.ssh/id_rsa.
  5. The SSH agent at $SSH_AUTH_SOCK.

If none of those resolve, ferrule errors out with a diagnostic listing every option it tried — the same shape as the password resolution stack.

no SSH key resolved for connection 'prod-pg'. Provide one of:
  --ssh-key <path>
  ssh_key in the profile
  FERRULE_PROD_PG_SSH_KEY=<path> env var
  ~/.ssh/id_ed25519 or ~/.ssh/id_rsa identity file
  a running SSH agent (SSH_AUTH_SOCK)

Encrypted keys

If load_secret_key reports the key needs a passphrase, ferrule prompts for it interactively:

Enter passphrase for SSH key /home/user/.ssh/id_ed25519:

In non-interactive contexts (CI, scripts, pipes) the prompt is skipped and ferrule returns an error:

SSH key /path/to/key is encrypted. Passphrase prompting requires an interactive terminal.
Use an SSH agent or decrypt the key on disk.

Workarounds when a terminal is not available:

  • Use the SSH agent. ssh-add ~/.ssh/encrypted-key once per shell session, then ferrule will route signing requests through the agent.
  • Decrypt the key on disk: ssh-keygen -p -f ~/.ssh/encrypted-key removes the passphrase (don’t do this for keys you don’t exclusively control).

Transport: how the bytes flow

Two transports, picked by backend automatically:

(b) Direct stream — Postgres

The russh ChannelStream is fed straight into tokio_postgres::Config::connect_raw(stream, tls_connector). There is no extra TCP hop on the local side. TLS, if requested via ?sslmode=require/verify-full, is negotiated end-to-end inside the SSH channel — so a URL like postgres://app:pwd@db/myapp?sslmode=require paired with --ssh-tunnel bastion gets BOTH SSH transport AND TLS to the database. The two layers compose.

(a) Local listener — MySQL, MSSQL, Oracle

Those drivers don’t expose a custom-stream injection API, so ferrule binds a 127.0.0.1:<random> TCP listener, spawns a forwarder task that pumps bytes between the listener and the SSH channel via tokio::io::copy_bidirectional, and rewrites the URL to point at the local port before handing it to the driver.

The listener stays open and accepts multiple inbound connections. For each connection a fresh direct-tcpip channel is opened through the same SSH session, so drivers that retry or pool (e.g. mysql_async) work transparently. When the tunnel handle drops, the listener closes and all active channels are torn down.

Sqlite is rejected

SQLite is local-file only — there’s no host:port for SSH to forward to. Combining --ssh-tunnel ... sqlite:///path/to/db produces:

SSH tunneling is not applicable to SQLite (local-file backend)

Host-key verification (TOFU)

Ferrule compares the SSH bastion’s server key against your ~/.ssh/known_hosts on every tunnel setup, using russh’s native OpenSSH-format parser (hashed hosts, [host]:port entries, and all standard key algorithms are supported).

Three outcomes

OutcomeIn a TTYIn a script / CI
Host known, key matchesSilent acceptSilent accept
Host key mismatchFatal errorFatal error
Unknown hostPrompts once for TOFUFatal error with instructions

TOFU prompt (TTY unknown host)

The authenticity of host 'bastion.example.com:22' can't be established.
ED25519 key fingerprint is SHA256:abcdef1234567890abcdef1234567890abcdef12.
Are you sure you want to continue connecting (yes/no)?

Type yes (or y) and ferrule writes the key to ~/.ssh/known_hosts and proceeds. Any other answer aborts with exit code 2 (USAGE).

Non-interactive unknown host

In CI, pipes, or any non-TTY context, the prompt is skipped:

SSH host bastion.example.com:22 is not in ~/.ssh/known_hosts.
To add it, run interactively once or use:
  ssh-keyscan -p 22 bastion.example.com >> ~/.ssh/known_hosts

Pre-seeding known_hosts before the pipeline runs is the standard OpenSSH-compatible workflow.

Host key mismatch

If the recorded key differs from the one the server presents:

SSH host key mismatch for bastion.example.com:22
The key sent by the server does not match the one recorded in ~/.ssh/known_hosts.
To resolve: verify the new fingerprint and remove the old key:
  ssh-keygen -R bastion.example.com -f ~/.ssh/known_hosts

This is always fatal — there is no --insecure-ssh-hosts bypass flag. Treat a mismatch as a potential man-in-the-middle attack until you verify the new fingerprint out of band.

SSH and the daemon don’t mix

If you pass --daemon and an SSH tunnel, ferrule rejects the combination:

SSH tunnels bypass the connection pooling daemon. The tunnel
session lifecycle is tied to the request, so pooling tunneled
connections would introduce a class of failure modes around session
timeout that ferrule does not currently handle. Either drop --daemon
or open without a tunnel.

Why: pooling tunneled connections has a real failure mode that is hard to handle correctly. When the SSH session times out (most bastions kill idle sessions in 5-15 minutes), the pooled DB connection above it goes dead. The DB driver then tries to talk to a now-dead local port and returns confusingly long timeouts. DBeaver has fought this for years; ferrule sidesteps it by not pooling tunnels at all.

If you need a long-lived tunnel for many queries, use the REPL — ferrule repl --ssh-tunnel ... <conn> keeps a single session open for the whole REPL session, and \conn <name> switches the inner DB without reopening the bastion.

Build feature

The SSH stack is opt-in via the ssh Cargo feature:

# Default build — no SSH support, --ssh-tunnel errors out with a
# diagnostic.
cargo build --release

# With SSH support
cargo build --release --features ferrule/ssh

# All features (Oracle + SSH)
cargo build --release --features ferrule/all

The default build excludes russh because the SSH dependency stack adds ~4 MB to the release binary (20 MB → 24 MB on Linux x86_64, measured 2026-04-27). Most users who never tunnel don’t need to pay that.

If you try to use --ssh-tunnel against a default-features binary:

This ferrule binary was built without the `ssh` feature. Rebuild
with `cargo build --features ferrule/ssh` (or `--features all`).

Troubleshooting

SymptomLikely causeFix
connect to <host>:<port>: Connection refusedBastion isn’t listening on that port, or you’re not on its allow-listConfirm with plain ssh -p <port> user@host
publickey auth failed for user 'X' (server rejected key)Key not in ~user/.ssh/authorized_keys on the bastionConfirm with plain ssh -i <key> user@host
SSH agent at <sock> has no identities loadedAgent is running but emptyssh-add ~/.ssh/id_ed25519
load SSH key from <path>: ...Wrong passphrase or corrupted keyCheck the passphrase or regenerate the key
SSH key <path> is encrypted. Passphrase prompting requires an interactive terminal.Encrypted key in a non-interactive contextUse the agent or decrypt on disk (see above)
SSH host <host>:<port> is not in ~/.ssh/known_hosts.Unknown bastion in CI / scriptPre-seed with ssh-keyscan (see Host-key verification)
SSH host key mismatch for <host>:<port>Server key changed (rebuild / MITM)Verify fingerprint and ssh-keygen -R <host>
SSH tunneling is not applicable to SQLiteThe URL is a sqlite:// schemeDrop --ssh-tunnel for sqlite
Long hang then “connection failed”DB host unreachable from the bastionConfirm with ssh user@host -- nc -zv <db-host> <db-port>

When in doubt, drop ferrule and try the plain ssh and psql/mysql binaries against the same hosts. If those work, ferrule should too.