Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,13 +388,37 @@ pgbot resolves the connection in this order: the argument first, then
`$DATABASE_URL`, then `$PGBOT_DATABASE_URL`. Add `?sslmode=require` (or stricter)
for any database reached over a network.

### Reaching a private database

A database on a private network — RDS/Aurora inside a VPC, or a Postgres behind a
bastion — is reached through an SSH jump host:

```sh
pgbot inspect "postgres://pgbot_ro@db.internal:5432/appdb?sslmode=verify-full" \
--ssh-tunnel bastion.example.com # or user@host:port, or a ~/.ssh/config alias
```

`--ssh-tunnel` is global — every command that opens a connection takes it — and
`$PGBOT_SSH_TUNNEL` sets it for a whole session.

The tunnel is a dialer, not an `ssh -L` forward, so **the DSN keeps naming the real
host**: `sslmode=verify-full` still validates against that hostname, `.pgpass` still
matches on it, and no local port is left open to everyone else on your machine.

How the jump host is reached comes from your own `ssh_config` — `HostName`, `Port`,
`User`, `IdentityFile`, `IdentitiesOnly`, `IdentityAgent`, `StrictHostKeyChecking`,
`UserKnownHostsFile` — so a bare alias works and the host key is verified exactly
the way your `ssh` verifies it. Your agent is offered before any key on disk, and
one SSH connection serves the whole run. Raise `--timeout` if the link is slow.

### Environment reference

| Variable | Purpose |
|---|---|
| `DATABASE_URL` / `PGBOT_DATABASE_URL` | Connection used when no connection string is passed (checked in that order, after the argument). |
| `NO_COLOR` | Disables ANSI output (as does a non-TTY, or `--no-color`). |
| `XDG_STATE_HOME` | Where the baseline store lives; defaults to `~/.local/state`. |
| `PGBOT_SSH_TUNNEL` | SSH jump host used when `--ssh-tunnel` isn't passed (`[user@]host[:port]`, or a `~/.ssh/config` alias). |
| `PGBOT_CONFIG` | Path to `.pgbot.toml` (otherwise discovered from cwd upward, then `$XDG_CONFIG_HOME`). |
| `OPENAI_API_KEY` | Enables `ask` / `explain` via OpenAI. Keys are never accepted as flags. |
| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Enables `ask` / `explain` via Google Gemini. |
Expand Down Expand Up @@ -513,6 +537,8 @@ pgbot inspect <connection-string> # URL or libpq DSN, or set $DATABASE_URL
--interval 1s gap between the two counter samples (min 500ms)
--no-store don't read or write the local baseline
--no-color disable ANSI (also honors NO_COLOR and non-TTY)
--ssh-tunnel <host> reach the database through an SSH jump host — global, so
every command that connects takes it (also $PGBOT_SSH_TUNNEL)

pgbot baselines list # what's stored locally, per database
pgbot baselines prune <fingerprint> # delete a database's snapshots
Expand Down
6 changes: 5 additions & 1 deletion cmd/pgbot/gather.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ func gather(ctx context.Context, connString string, f inspectFlags) (*model.Cont
fmt.Fprintln(os.Stderr, target.Pooler.Note())
}

c, err := collect.Run(ctx, target, collect.Options{Interval: f.interval, ASHHz: f.ashHz, ASHWindow: f.window})
// Deadline must be forwarded: without it collect.Run falls back to its own
// 20s+interval budget and every --timeout on the commands routed through
// gather (vacuum, tables, indexes, queries, ask) is silently ignored — the
// exact flag whose help text says to raise it for slow or remote databases.
c, err := collect.Run(ctx, target, collect.Options{Interval: f.interval, ASHHz: f.ashHz, ASHWindow: f.window, Deadline: f.timeout})
if err != nil {
return nil, "", fmt.Errorf("collect: %s", conn.RedactConnString(err.Error()))
}
Expand Down
18 changes: 17 additions & 1 deletion cmd/pgbot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"os/signal"
"syscall"

"github.com/pgrundev/pgbot/internal/conn"
"github.com/spf13/cobra"
)

Expand All @@ -24,6 +25,9 @@ func main() {
// and the store finishes its write. cmd.Context() in every handler is this ctx.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// One SSH connection serves every Target this process opens; drop it on the
// way out rather than per-Target (--all-databases and `mcp` open many).
defer conn.CloseSSHTunnel()

root := &cobra.Command{
Use: "pgbot",
Expand Down Expand Up @@ -51,12 +55,24 @@ func main() {
root.AddCommand(newInitCmd())
root.AddCommand(newWhyCmd())

// --ssh-tunnel is global: every command that takes a connection can need it,
// and it changes only HOW the DSN is reached, never what is inspected.
var sshTunnel string
root.PersistentFlags().StringVar(&sshTunnel, "ssh-tunnel", "",
"reach the database through this SSH jump host ([user@]host[:port]; a bare alias is resolved via ~/.ssh/config)")

// enteredRun distinguishes a malformed invocation (bad flags/args/unknown
// command — cobra fails before PersistentPreRun) from an execution failure
// (a handler ran and returned an error). B5's --fail-on makes exit codes a
// public interface, so the two must not share code 3.
enteredRun := false
root.PersistentPreRun = func(*cobra.Command, []string) { enteredRun = true }
root.PersistentPreRun = func(*cobra.Command, []string) {
enteredRun = true
if sshTunnel == "" {
sshTunnel = os.Getenv(conn.SSHTunnelEnv)
}
conn.SetSSHTunnel(sshTunnel)
}

if err := root.ExecuteContext(ctx); err != nil {
fmt.Fprintln(os.Stderr, "pgbot: "+err.Error())
Expand Down
2 changes: 1 addition & 1 deletion docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ detection works even when the host is a bare IP or sits behind a proxy.

## Amazon RDS / Aurora

- **Connecting:** you can't install on the RDS/Aurora instance (managed, no OS access) — run pgbot from a client that can reach it. For a **private** instance (typical prod), run pgbot from a small **EC2 in the same VPC**: it reaches the private endpoint over AWS's internal network, so the DB never needs public access, no SSH tunnel, no IP allow-listing the only rule is the RDS security group allowing `5432` from the EC2's security group. For a **publicly accessible** instance, allow your IP in the security group and connect from your laptop.
- **Connecting:** you can't install on the RDS/Aurora instance (managed, no OS access) — run pgbot from a client that can reach it. For a **private** instance (typical prod) there are two ways in: run pgbot from a small **EC2 in the same VPC**it reaches the private endpoint over AWS's internal network, so the DB never needs public access, no SSH tunnel, no IP allow-listing, and the only rule is the RDS security group allowing `5432` from the EC2's security group — or keep pgbot on your laptop and reach the endpoint through a bastion with `--ssh-tunnel` (see [Reaching a private database](../README.md#reaching-a-private-database)), which still validates `sslmode=verify-full` against the real endpoint name. For a **publicly accessible** instance, allow your IP in the security group and connect from your laptop.
```bash
pgbot inspect "postgres://pgbot_ro@mydb.abc123.us-east-1.rds.amazonaws.com:5432/appdb?sslmode=require"
```
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ require (
github.com/charmbracelet/lipgloss v1.1.0
github.com/invopop/jsonschema v0.14.0
github.com/jackc/pgx/v5 v5.10.0
github.com/kevinburke/ssh_config v1.4.0
github.com/owenrumney/go-sarif/v2 v2.3.3
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.54.0
golang.org/x/sync v0.22.0
golang.org/x/term v0.45.0
modernc.org/sqlite v1.56.0
Expand Down Expand Up @@ -40,7 +42,7 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.39.0 // indirect
golang.org/x/text v0.40.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
Expand Down
8 changes: 6 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
Expand Down Expand Up @@ -88,6 +90,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
Expand All @@ -104,8 +108,8 @@ golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
Expand Down
9 changes: 9 additions & 0 deletions internal/conn/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ func ConnectDB(ctx context.Context, connString, database string) (*Target, error
cfg.MaxConnLifetime = 5 * time.Minute
cfg.ConnConfig.RuntimeParams["application_name"] = "pgbot"

// Route the TCP leg through the SSH jump host when one is configured. This has
// to happen before probe(): the probe connection dials too, and it must take
// the same path as the pool. Installing it here rather than rewriting the DSN
// to a local forward is what keeps sslmode= and .pgpass matching on the real
// hostname — see sshtunnel.go.
if dial := sshDialFunc(); dial != nil {
cfg.ConnConfig.DialFunc = dial
}

// Drop client-only params pgx forwarded into RuntimeParams (it would send them
// as server GUCs, which the server rejects). See clientOnlyParams.
for _, p := range clientOnlyParams {
Expand Down
Loading