Skip to content

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

Merged
alexshapalov merged 5 commits into
pgrundev:mainfrom
DiegoDAF:feat/ssh-tunnel
Sep 6, 2026
Merged

feat(conn): --ssh-tunnel — reach a database through an SSH jump host#28
alexshapalov merged 5 commits into
pgrundev:mainfrom
DiegoDAF:feat/ssh-tunnel

Conversation

@DiegoDAF

Copy link
Copy Markdown
Contributor

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.
alexshapalov and others added 2 commits September 5, 2026 14:12
# 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
@alexshapalov

Copy link
Copy Markdown
Contributor

Code review

Found 4 issues, all fixed in 1a6ca1a (pushed to this branch with maintainer edits, on top of a merge of main that resolves the gather.go conflict with #30):

  1. Any channel-open failure tore down the shared SSH client, not just a dead transport. A forward the jump host refuses (OpenChannelError) or an expired context also hit CloseSSHTunnel(), severing every other pool connection riding the tunnel, and under --all-databases --parallel a late failure could close a replacement client another goroutine had just dialed. Now only a transport-level error triggers the redial, and the drop is a compare-and-close on the client that actually failed.

}
// A pooled connection can outlive the SSH transport (an idle timeout on the
// jump host, a laptop that slept, a VPN that flapped). Drop the dead client
// and re-dial once before surfacing the failure — the pool would otherwise
// stay broken for the rest of a long-lived `pgbot mcp` process.
CloseSSHTunnel()
c, rerr := tunnelClient(ctx)
if rerr != nil {
return nil, fmt.Errorf("%w (reconnect failed: %v)", err, rerr)
}
return c.DialContext(ctx, network, addr)
}

  1. IdentityAgent none was read as "use $SSH_AUTH_SOCK". ssh_config(5) defines none as disabling the agent for that host, so a user who set it to keep a jump host away from their agent got the agent anyway. The bundled unit test asserted the inverted reading.

func expandAgentSpec(ia string) string {
ia = strings.Trim(strings.TrimSpace(ia), `"`)
switch {
case ia == "", strings.EqualFold(ia, "none"), ia == "SSH_AUTH_SOCK":
return os.Getenv("SSH_AUTH_SOCK")
}
if p := expandTilde(os.ExpandEnv(ia)); p != "" {

  1. A host key accepted on first sight was never recorded, so every run was a first sight and a later, different key at the same address could not be told apart from a new host. The changed-key refusal only ever fired for hosts the user had already reached with plain ssh. Accepted keys are now appended to the first UserKnownHostsFile, as ssh does under accept-new.

}
// Unknown host.
switch strict {
case "yes":
return fmt.Errorf("host %q is not in known_hosts and StrictHostKeyChecking=yes", alias)
default:
fmt.Fprintf(os.Stderr, "pgbot: accepting unknown ssh host key for %q (%s)\n", alias, ssh.FingerprintSHA256(key))
return nil
}
}, nil

  1. golang.org/x/crypto v0.54.0 carries GO-2026-6354 and GO-2026-6355 in the SSH package, which govulncheck reports as reached by this code, so the CI vulncheck step would fail. Fixed in v0.56.0, which requires Go 1.26; go.mod moves to 1.26.8.

pgbot/go.mod

Lines 12 to 14 in d581863

github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.54.0
golang.org/x/sync v0.22.0

Also closed the agent socket after the handshake (one leaked descriptor per SSH dial in a long-lived mcp process) and added end-to-end tests against an in-process SSH server covering the dial path, redial after transport loss, the refused-forward case, changed-key refusal under every policy, and key recording.

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 connect_timeout is still applied with a custom dialer and that .pgpass and verify-full key off the DSN host, as the PR claims.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@alexshapalov
alexshapalov merged commit 502e895 into pgrundev:main Sep 6, 2026
17 checks passed
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.

2 participants