feat(conn): --ssh-tunnel — reach a database through an SSH jump host - #28
Conversation
gather() dropped f.timeout, so collect.Run fell back to its own 20s+interval budget and --timeout was silently ignored on every command routed through gather (vacuum, tables, indexes, queries, ask) — the exact flag whose help text says to raise it for slow or remote databases.
A managed database on a private network (RDS/Aurora inside a VPC, a Postgres behind a bastion) is unreachable from a laptop without a jump host. --ssh-tunnel, or $PGBOT_SSH_TUNNEL, routes the TCP leg through one. The tunnel is installed as pgx's DialFunc rather than as a local port forward. pgconn documents DialFunc as running before TLS is established, so the DSN keeps naming the real host all the way through: sslmode= verify-full still validates against that hostname and .pgpass still matches on it. An `ssh -L` forward would force the DSN to say 127.0.0.1, silently breaking both, besides leaving a port open to every local user. Host identity is not pgbot's policy to invent. StrictHostKeyChecking, UserKnownHostsFile, IdentityFile, IdentitiesOnly, IdentityAgent, User and Port are all read from the user's ssh_config, so pgbot behaves the way their own ssh already does for that host; the agent is offered before any key read off disk. One SSH connection is shared per process and re-dials once if the transport dies under a long-lived pool (`mcp`, --all-databases). New dependencies: github.com/kevinburke/ssh_config, golang.org/x/crypto.
The flag had no prose: the README's environment reference didn't list $PGBOT_SSH_TUNNEL, and the RDS/Aurora page still offered an EC2 in the VPC as the only way into a private instance, with "no SSH tunnel" as one of its selling points. Document the dialer-not-a-forward property where a reader looks for it — it's the reason sslmode=verify-full and .pgpass keep working against the real hostname — and say that the jump host's own ssh_config is what governs the connection.
# Conflicts: # cmd/pgbot/gather.go
…record accepted host keys Review follow-ups on the --ssh-tunnel feature, each pinned by an end-to-end test against an in-process SSH server (host key, publickey auth, direct-tcpip): - Only a dead transport triggers the redial. A forward the jump host refuses (OpenChannelError) or an expired context left the shared client alone before the retry too, but the retry closed it — cutting every other pool connection riding the tunnel, and under --all-databases --parallel racing to close a replacement another goroutine had just dialed. dropTunnelClient now drops the client only while it is still the shared one. - IdentityAgent none disables the agent, as ssh_config(5) defines it; it was read as "use SSH_AUTH_SOCK". The unit test asserted the inverted reading. - The agent socket is closed once the handshake is over instead of leaking one descriptor per SSH dial in a long-lived mcp process. - A host key accepted on first sight (accept-new, and the non-interactive reading of ask) is recorded in the first UserKnownHostsFile, as ssh does. Without the record every run was a first sight and a changed key could never be told apart from a new host. - golang.org/x/crypto v0.54.0 -> v0.56.0: govulncheck reports GO-2026-6354 and GO-2026-6355 against the SSH package, fixed in 0.56.0, which needs Go 1.26. go.mod moves 1.25.13 -> 1.26.8. CI and release read the version from go.mod. - CHANGELOG entries; README notes that accepted keys are recorded. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qGZKWgfGTBCoHsDjy1SuB
Code reviewFound 4 issues, all fixed in 1a6ca1a (pushed to this branch with maintainer edits, on top of a merge of main that resolves the
pgbot/internal/conn/sshtunnel.go Lines 94 to 105 in d581863
pgbot/internal/conn/sshtunnel.go Lines 351 to 357 in d581863
pgbot/internal/conn/sshtunnel.go Lines 400 to 409 in d581863
Lines 12 to 14 in d581863 Also closed the agent socket after the handshake (one leaked descriptor per SSH dial in a long-lived Checked for bugs, git history, prior PRs and issues (#23, #25, #29), and code-comment guidance; this repo has no CLAUDE.md. Verified against pgx source that 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
What and why
A managed database on a private network — RDS/Aurora inside a VPC, or any Postgres
behind a bastion — can't be reached from a laptop without a jump host. Today that
leaves
ssh -Las the only option, which quietly costs you TLS verification.This adds a global
--ssh-tunnel [user@]host[:port]flag (and$PGBOT_SSH_TUNNEL)that routes the TCP leg through an SSH jump host.
It's a
DialFunc, not a port forward. pgconn documentsDialFuncas runningbefore TLS is established, so the DSN keeps naming the real host all the way
through:
sslmode=verify-fullstill validates against that hostname and.pgpassstill matches on it. An
ssh -Lforward would force the DSN to say127.0.0.1,silently breaking both, and would leave a port open to every local user for the
lifetime of the run.
Host identity isn't pgbot's policy to invent.
StrictHostKeyChecking,UserKnownHostsFile,IdentityFile,IdentitiesOnly,IdentityAgent,UserandPortare read from the user'sssh_config, so pgbot behaves the way their ownsshalready does for that host — including refusing an unknown host key when theyconfigured it to. The agent is offered before any key read off disk, so an
encrypted key that lives only in the agent keeps working.
One SSH connection is shared per process (
--all-databasesandmcpopen manyTargets) and re-dials once if the transport dies under a long-lived pool — an idle
timeout on the jump host, a suspended laptop, a flapping VPN.
Six unit tests cover spec parsing, tilde expansion,
IdentityAgentenv expansion,the known-hosts filter and the no-tunnel path; none need network or a server.
New dependencies:
github.com/kevinburke/ssh_configandgolang.org/x/crypto(
golang.org/x/termwas already direct). Thegolang.org/x/textindirect bump tov0.40.0 is what
x/cryptov0.54.0 requires.The other two commits
fix(gather): forward --timeout to collect.Run so the flag is honored—gather()dropped
f.timeout, socollect.Runfell back to its own20s+intervalbudget and--timeoutwas silently ignored on every command routed through gather (vacuum,tables,indexes,queries,ask). It's the exact flag whose help text says toraise it for slow or remote databases, which is how it surfaced here. Happy to split
it into its own PR if you'd rather keep this one to the feature.
docs: --ssh-tunnel — reaching a private database through a jump host— aReaching a private database section in the README, a
PGBOT_SSH_TUNNELrow inthe environment reference, the flag in the usage block, and
docs/providers.mdnow offering a bastion as the second way into a private RDS/Aurora instance rather
than an in-VPC EC2 as the only one.
Checklist
scripts/gate.shpasses (builds HEAD, not just the working tree)EXPLAIN ANALYZE; findings stay deterministic — no new SQL, the change is transport-onlymodel.Context/--json/ the store — the tunnel spec is never collected--jsonchange is additive — unchangeddocs/findings/<id>.mdpage + catalog entry — no new findings