Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ Gatekeeper is a standalone credential-injecting TLS-intercepting proxy. It trans

Gatekeeper is pre-1.0. The configuration schema and credential source interface may change between minor versions.

## v0.18.0 — 2026-07-15

### Added

- **The Postgres data-plane listener now supports PROXY protocol v1/v2** — until now, only the HTTP/CONNECT listener could recover the real client address behind a TCP-terminating load balancer; a load balancer fronting the Postgres port had no equivalent, so `client_ip` on postgres canonical log lines (and the `client_addr` field on a failed run-token-authentication log line) always showed the load balancer's own front-end address. Setting `postgres.proxy_protocol: true` wraps the Postgres listener with the same PROXY protocol handling the HTTP listener already had: fail-open `USE` connection policy (a header is honored when present; a connection that opens without one, or that doesn't send one within a 10s read timeout, falls back to its raw TCP peer address rather than being rejected, so load balancer health checks and direct TCP probes of the port keep working), and a single DEBUG log line for a connection whose header is present but fails to parse. The PROXY header is always the very first bytes on the wire — ahead of even the client's `SSLRequest` — so the listener must be PROXY-protocol-aware from the first `Accept`; `PostgresServer` gains a `StartListener(net.Listener)` method so the caller can wrap the listener before handing it off, and `gatekeeper.go`'s postgres wiring now binds, conditionally wraps, then calls it, mirroring the HTTP listener's own wrap-before-serve sequence. `enableKeepAlive` (`proxy/postgres.go`) unwraps a wrapped connection via a structural `Raw() net.Conn` accessor before its `*net.TCPConn` type assertion, so TCP keep-alives on the relayed connection keep working when the listener is PROXY-protocol-wrapped — unwrapping through `Raw()` is side-effect-free, unlike `RemoteAddr()`/`Read()`, which trigger the (potentially blocking) PROXY header parse

### Changed

- **PROXY protocol configuration moved from `network.proxy_protocol` to per-listener toggles: `proxy.proxy_protocol` (HTTP/CONNECT) and `postgres.proxy_protocol` (Postgres)** — `network.proxy_protocol` predates the Postgres listener supporting PROXY protocol at all, so it implicitly named the only listener capable of it; now that both listeners can, a single flag under `network` no longer has an unambiguous meaning, and `network` is otherwise about host allow/deny policy, not listener transport concerns. `network.proxy_protocol` is removed outright — gatekeeper is pre-1.0 and single-consumer, so this ships as a breaking config change rather than carrying a deprecated alias. `proxy.proxy_protocol` is the new home for the exact same HTTP-listener behavior (semantics, defaults, and the fail-open/malformed-header handling are unchanged — only the config path moved); existing configs setting `network.proxy_protocol: true` must move that line under `proxy:` before upgrading. Internally, both listeners now share one implementation: `proxy.WrapProxyProtocolListener` (new file, `proxy/proxyproto.go`) holds the `proxyproto.Listener` construction, the fail-open `USE` policy, the 10s `ReadHeaderTimeout`, and the malformed-header debug-log wrapper (`proxyProtoLogListener`/`proxyProtoLogConn`, moved here from `gatekeeper.go`, which previously duplicated this logic inline for the HTTP listener only) — `gatekeeper.go`'s HTTP and Postgres wiring both call this one helper, so the two listeners' PROXY protocol handling is byte-identical and can't drift

## v0.17.3 — 2026-07-15

### Changed
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ cd examples && ./gen-ca.sh
proxy:
host: 127.0.0.1
port: 9080
# proxy_protocol: true # behind a TCP-terminating LB, recover the real client_ip

tls:
ca_cert: ca.crt
Expand All @@ -68,7 +69,6 @@ credentials:

network:
policy: permissive
# proxy_protocol: true # behind a TCP-terminating LB, recover the real client_ip

log:
level: info
Expand Down Expand Up @@ -140,7 +140,9 @@ network:

Policies: `permissive` (allow all), `strict` (deny all, allow listed).

Behind a TCP-terminating load balancer (e.g. GCP's global TCP Proxy LB), every connection's peer address is the load balancer's, not the real client — `network.proxy_protocol: true` parses a PROXY protocol v1/v2 header from the LB and uses its advertised source as the `client_ip` recorded in request logs. It's fail-open (headerless connections, like LB health checks, fall back to the raw peer address) and should only be enabled when the port is reachable solely through the load balancer, since the header is otherwise forgeable by any direct client.
## PROXY protocol (client IP behind a load balancer)

Behind a TCP-terminating load balancer (e.g. GCP's global TCP Proxy LB), every connection's peer address is the load balancer's, not the real client. PROXY protocol parsing recovers the real client address, and it's configured per listener: `proxy.proxy_protocol: true` for the HTTP/CONNECT listener, and `postgres.proxy_protocol: true` for the Postgres data-plane listener. Each parses a PROXY protocol v1/v2 header from the LB and uses its advertised source as the `client_ip` recorded in request logs. Both are fail-open (headerless connections, like LB health checks, fall back to the raw peer address) and should only be enabled when that listener's port is reachable solely through the load balancer, since the header is otherwise forgeable by any direct client. See [Deploying behind a TCP load balancer](docs/content/guides/11-load-balancer-proxy-protocol.md).

## MCP relay

Expand Down
51 changes: 34 additions & 17 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,47 @@ type Config struct {
type PostgresConfig struct {
Port int `yaml:"port"` // listener port (e.g. 5432)
Host string `yaml:"host,omitempty"` // bind address (default: same as proxy host)

// ProxyProtocol enables PROXY protocol v1/v2 parsing on the Postgres
// data-plane listener, mirroring proxy.proxy_protocol: when true, each
// inbound connection is checked for a leading PROXY protocol header (as
// prepended by a TCP-terminating load balancer in front of the Postgres
// port) and, if present, the advertised source address replaces the raw
// TCP peer address as the connection's client address for logging.
// Connections that do not open with a PROXY header fall back to the raw
// TCP peer address (fail-open), so load balancer health checks and
// direct probes of the port keep working. Default false.
//
// Because headers are honored from any peer, a client that can reach
// this listener directly (bypassing the load balancer) can forge the
// logged client_ip by prepending its own PROXY header. Only enable this
// when the port is reachable solely through the load balancer, and never
// use client_ip for security decisions.
ProxyProtocol bool `yaml:"proxy_protocol,omitempty"`
}

// ProxyConfig configures the proxy listener.
type ProxyConfig struct {
Port int `yaml:"port"`
Host string `yaml:"host"`
AuthToken string `yaml:"auth_token,omitempty"` // Optional token clients must provide via Proxy-Authorization

// ProxyProtocol enables PROXY protocol v1/v2 parsing on the HTTP/CONNECT
// proxy listener. When true, each inbound connection is checked for a
// leading PROXY protocol header (as prepended by a TCP load balancer,
// e.g. GCP's global TCP Proxy LB) and, if present, the advertised source
// address replaces the raw TCP peer address as the connection's client
// address for logging (the client_ip request-log attribute). Connections
// that do not open with a PROXY header fall back to the raw TCP peer
// address (fail-open), so load balancer health checks and direct probes
// of the port keep working. Default false.
//
// Because headers are honored from any peer, a client that can reach the
// listener directly (bypassing the load balancer) can forge the logged
// client_ip by prepending its own PROXY header. Only enable this when
// the port is reachable solely through the load balancer, and never use
// client_ip for security decisions.
ProxyProtocol bool `yaml:"proxy_protocol,omitempty"`
}

// TLSConfig configures the CA certificate used for TLS interception.
Expand Down Expand Up @@ -110,23 +144,6 @@ type SourceConfig struct {
type NetworkConfig struct {
Policy string `yaml:"policy"`
Allow []string `yaml:"allow,omitempty"`

// ProxyProtocol enables PROXY protocol v1/v2 parsing on the proxy
// listener. When true, each inbound connection is checked for a leading
// PROXY protocol header (as prepended by a TCP load balancer, e.g. GCP's
// global TCP Proxy LB) and, if present, the advertised source address
// replaces the TCP peer address as the connection's client address for
// logging (the client_ip request-log attribute). Connections that do not
// open with a PROXY header fall back to the raw TCP peer address
// (fail-open), so load balancer health checks and direct probes of the
// port keep working. Default false.
//
// Because headers are honored from any peer, a client that can reach the
// listener directly (bypassing the load balancer) can forge the logged
// client_ip by prepending its own PROXY header. Only enable this when
// the port is reachable solely through the load balancer, and never use
// client_ip for security decisions.
ProxyProtocol bool `yaml:"proxy_protocol,omitempty"`
}

// LogConfig configures logging.
Expand Down
19 changes: 13 additions & 6 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func TestParseConfig_Full(t *testing.T) {
proxy:
port: 8080
host: 127.0.0.1
proxy_protocol: true
tls:
ca_cert: /tmp/ca.crt
ca_key: /tmp/ca.key
Expand Down Expand Up @@ -42,7 +43,6 @@ network:
allow:
- "*.github.com"
- api.anthropic.com
proxy_protocol: true
log:
level: debug
format: json
Expand All @@ -60,6 +60,9 @@ log:
if cfg.Proxy.Host != "127.0.0.1" {
t.Errorf("Proxy.Host = %q, want 127.0.0.1", cfg.Proxy.Host)
}
if !cfg.Proxy.ProxyProtocol {
t.Error("Proxy.ProxyProtocol = false, want true")
}

// TLS
if cfg.TLS.CACert != "/tmp/ca.crt" {
Expand Down Expand Up @@ -111,9 +114,6 @@ log:
if cfg.Network.Allow[0] != "*.github.com" {
t.Errorf("Network.Allow[0] = %q, want *.github.com", cfg.Network.Allow[0])
}
if !cfg.Network.ProxyProtocol {
t.Error("Network.ProxyProtocol = false, want true")
}
// Log
if cfg.Log.Level != "debug" {
t.Errorf("Log.Level = %q, want debug", cfg.Log.Level)
Expand All @@ -138,8 +138,11 @@ proxy:
if len(cfg.Credentials) != 0 {
t.Errorf("len(Credentials) = %d, want 0", len(cfg.Credentials))
}
if cfg.Network.ProxyProtocol {
t.Error("Network.ProxyProtocol = true, want false when absent from config")
if cfg.Proxy.ProxyProtocol {
t.Error("Proxy.ProxyProtocol = true, want false when absent from config")
}
if cfg.Postgres != nil && cfg.Postgres.ProxyProtocol {
t.Error("Postgres.ProxyProtocol = true, want false when absent from config")
}
}

Expand All @@ -150,6 +153,7 @@ proxy:
postgres:
port: 5432
host: 0.0.0.0
proxy_protocol: true
credentials:
- host: "*.neon.tech"
postgres:
Expand All @@ -173,6 +177,9 @@ credentials:
if cfg.Postgres.Host != "0.0.0.0" {
t.Errorf("Host = %q, want 0.0.0.0", cfg.Postgres.Host)
}
if !cfg.Postgres.ProxyProtocol {
t.Error("Postgres.ProxyProtocol = false, want true")
}
if len(cfg.Credentials) != 1 {
t.Fatalf("credentials = %d, want 1", len(cfg.Credentials))
}
Expand Down
6 changes: 3 additions & 3 deletions docs/content/concepts/06-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,11 @@ Log level is determined by outcome: `ERROR` for server errors or transport failu

## Client IP attribution behind a load balancer

By default, `client_ip` comes from the raw TCP peer address of the proxy listener's accepted connection. That's accurate for a directly-exposed listener, but not for one that sits behind a TCP-terminating load balancer (e.g. GCP's global TCP Proxy load balancer) — every connection appears to originate from the load balancer's own front-end range (`35.191.0.0/16` for GCP), not the real client.
By default, `client_ip` comes from the raw TCP peer address of the accepted connection. That's accurate for a directly-exposed listener, but not for one that sits behind a TCP-terminating load balancer (e.g. GCP's global TCP Proxy load balancer) — every connection appears to originate from the load balancer's own front-end range (`35.191.0.0/16` for GCP), not the real client.

Setting `network.proxy_protocol: true` wraps the proxy listener with PROXY protocol v1/v2 parsing. When the load balancer prepends a PROXY header, gatekeeper uses its advertised source address as the connection's client address instead of the TCP peer, flowing through to `client_ip` on every request path, including CONNECT-intercepted inner requests. Connections opened without a header — load balancer health checks, direct probes of the port — fall back to the raw TCP peer address rather than being rejected.
PROXY protocol support is configured per listener: `proxy.proxy_protocol: true` wraps the HTTP/CONNECT proxy listener, and `postgres.proxy_protocol: true` wraps the [Postgres data-plane listener](./08-postgres-data-plane.md) — both parse PROXY protocol v1/v2 through the same shared logic. When the load balancer prepends a PROXY header, gatekeeper uses its advertised source address as the connection's client address instead of the TCP peer. On the HTTP listener this flows through to `client_ip` on every request path, including CONNECT-intercepted inner requests. On the Postgres listener it flows through to `client_ip` on the canonical log line and to the `client_addr` field on a failed run-token-authentication log line; the PROXY header is always the first bytes on the wire, so it's parsed before the client's `SSLRequest` and the TLS handshake. Connections opened without a header — load balancer health checks, direct probes of the port — fall back to the raw TCP peer address rather than being rejected, on either listener.

Because the header is honored from any peer that can reach the listener, only enable `proxy_protocol` when the port is reachable solely through the load balancer, and never use `client_ip` for security decisions — it's a logging convenience, not an authenticated identity.
Because the header is honored from any peer that can reach the listener, only enable `proxy_protocol` on a listener whose port is reachable solely through the load balancer, and never use `client_ip` for security decisions — it's a logging convenience, not an authenticated identity. See [Deploying behind a TCP load balancer](../guides/11-load-balancer-proxy-protocol.md) for the full setup, including the Postgres listener.

## Request ID tracking

Expand Down
Loading
Loading