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:
- Opens an SSH session to
ec2-user@bastion.example.com:22using the key at~/.ssh/id_ed25519. - Asks the bastion to open a
direct-tcpipchannel todb.internal:5432. - Hands that channel directly to
tokio_postgresviaconnect_raw. The Postgres protocol — and TLS, if your URL hassslmode=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$USERand the port falls back to 22 — not to whatever the profile said. “One flag, one tunnel target.”--ssh-key <path>— overridesssh_keyindependently. 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:
--ssh-key <path>(CLI) orssh_key = "..."(profile).FERRULE_<NAME>_SSH_KEY=<path>env var (where<NAME>is the uppercased connection name with-→_).~/.ssh/id_ed25519.~/.ssh/id_rsa.- 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-keyonce per shell session, then ferrule will route signing requests through the agent. - Decrypt the key on disk:
ssh-keygen -p -f ~/.ssh/encrypted-keyremoves 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
| Outcome | In a TTY | In a script / CI |
|---|---|---|
| Host known, key matches | Silent accept | Silent accept |
| Host key mismatch | Fatal error | Fatal error |
| Unknown host | Prompts once for TOFU | Fatal 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
| Symptom | Likely cause | Fix |
|---|---|---|
connect to <host>:<port>: Connection refused | Bastion isn’t listening on that port, or you’re not on its allow-list | Confirm 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 bastion | Confirm with plain ssh -i <key> user@host |
SSH agent at <sock> has no identities loaded | Agent is running but empty | ssh-add ~/.ssh/id_ed25519 |
load SSH key from <path>: ... | Wrong passphrase or corrupted key | Check the passphrase or regenerate the key |
SSH key <path> is encrypted. Passphrase prompting requires an interactive terminal. | Encrypted key in a non-interactive context | Use the agent or decrypt on disk (see above) |
SSH host <host>:<port> is not in ~/.ssh/known_hosts. | Unknown bastion in CI / script | Pre-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 SQLite | The URL is a sqlite:// scheme | Drop --ssh-tunnel for sqlite |
| Long hang then “connection failed” | DB host unreachable from the bastion | Confirm 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.