Skip to content

feat(conn): --ssh-tunnel — reach a database through an SSH jump host - #28

Open
DiegoDAF wants to merge 3 commits into
pgrundev:mainfrom
DiegoDAF:feat/ssh-tunnel
Open

feat(conn): --ssh-tunnel — reach a database through an SSH jump host#28
DiegoDAF wants to merge 3 commits into
pgrundev:mainfrom
DiegoDAF:feat/ssh-tunnel

Conversation

@DiegoDAF

Copy link
Copy Markdown

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 -L as 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 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, 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, User and
Port are read from the user's ssh_config, so pgbot behaves the way their own
ssh already does for that host — including refusing an unknown host key when they
configured 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-databases and mcp open many
Targets) 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, IdentityAgent env expansion,
the known-hosts filter and the no-tunnel path; none need network or a server.

New dependencies: github.com/kevinburke/ssh_config and golang.org/x/crypto
(golang.org/x/term was already direct). The golang.org/x/text indirect bump to
v0.40.0 is what x/crypto v0.54.0 requires.

The other two commits

fix(gather): forward --timeout to collect.Run so the flag is honoredgather()
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). It's the exact flag whose help text says to
raise 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 — a
Reaching a private database section in the README, a PGBOT_SSH_TUNNEL row in
the environment reference, the flag in the usage block, and docs/providers.md
now 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.sh passes (builds HEAD, not just the working tree)
  • New SQL is read-only; no EXPLAIN ANALYZE; findings stay deterministic — no new SQL, the change is transport-only
  • No PII enters a model.Context / --json / the store — the tunnel spec is never collected
  • --json change is additive — unchanged
  • A new finding has a docs/findings/<id>.md page + catalog entry — no new findings

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant