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.19.0 — 2026-07-15

### Added

- **The HTTP/CONNECT proxy and the Postgres data-plane listener can now share a single port** — an operator putting gatekeeper behind a load balancer previously needed one backend service, one health check, and one firewall rule per listener, even when the two planes were fronted by the same LB. Standing up two of everything for what is conceptually one gatekeeper endpoint was pure ceremony; this removes it. The trigger is topology, not a new flag: when `postgres.port` equals `proxy.port` (and their hosts, after defaults, also match), gatekeeper multiplexes both planes onto one real listener instead of binding two — the port equality itself *is* the declaration, there is no `multiplex: true` to set. Distinct ports (the default, and every existing config) leave the two-listener path byte-for-byte unchanged; `postgres` being absent never engages the shared-listener path either.
Mechanism: a new `proxy.Demux` (`proxy/demux.go`) owns the one real listener — wrapped with `WrapProxyProtocolListener` first when `proxy.proxy_protocol` is set, exactly as today — and reads the first 8 bytes of each accepted connection *in a per-connection goroutine, never in the shared accept loop*, so a silent or slow client (an LB health check that opens a socket and waits, or a slow-loris) can never stall `Accept` for every other pending connection — the same discipline `WrapProxyProtocolListener`'s own `Accept` already follows, and a prior review finding this change deliberately doesn't regress. Those 8 bytes positively identify a Postgres startup signature — `SSLRequest` (length `00 00 00 08`, code `04 d2 16 2f`), `GSSENCRequest` (same length, code `04 d2 16 30`), or a v3 `StartupMessage` (any 4-byte length, then protocol `00 03 00 00`) — and default everything else (HTTP methods, the h2 client preface, or any other prefix) to the HTTP plane, the same positive-match-Postgres approach as Caddy-L4's postgres matcher. The classified connection is wrapped so those 8 bytes are replayed on its first `Read` calls before any further bytes from the underlying socket — the same hold-then-replay pattern `proxyProtoLogConn` already uses for the PROXY header — then pushed onto one of two in-memory virtual listeners (a bounded, non-blocking channel of conns implementing `net.Listener`). `http.Server.Serve` and `PostgresServer.StartListener` run completely unmodified against those virtual listeners, unaware they aren't backed by a real socket. Once classification succeeds, the sniff deadline (10s, matching `WrapProxyProtocolListener`'s own PROXY-header-read timeout) is cleared before handoff, so only the downstream server's own timeout — `http.Server`'s `ReadHeaderTimeout`, or `PostgresServer`'s handshake timeout — ever applies from that point on. A connection that times out, closes early, or otherwise sends fewer than 8 bytes is dropped with a DEBUG log line naming the failure, never the bytes themselves; a successfully classified connection logs a second DEBUG line naming only `http` or `postgres`.
Because one physical listener means one PROXY protocol setting, gatekeeper now validates listener topology at startup (`resolveListenTopology`, `config.go`) before either listener binds: equal ports with `proxy.proxy_protocol != postgres.proxy_protocol` is a fatal, descriptive config error (`proxy.proxy_protocol` is the setting actually applied to the shared listener), as is equal ports with different hosts (there's only one socket to bind, and gatekeeper won't guess which address you meant). Port `0` on both sides — "give me any available port," gatekeeper's own default — deliberately never triggers multiplexing on its own, even though the values compare equal; two independent ephemeral-port binds aren't "the same port," and treating them as such would have silently changed the two-listener behavior of every config (and most of this repo's own test suite) that leaves the port unset. `gatekeeper.Server` gains a `demux *proxy.Demux` field; `Stop` calls `Demux.StopAccepting` (not `Close`) to close only the real listener immediately, deliberately leaving both virtual listeners open for `PostgresServer.Shutdown`/`http.Server.Shutdown` to close as part of their own graceful drain — closing them out from under those servers first would race their own closed-flag bookkeeping and, for `PostgresServer`, surface as a spurious error log on an entirely ordinary shutdown.
The demux accept loop retries transient `Accept` errors instead of exiting on them, mirroring `net/http.Server.Serve`: on an error it exits cleanly only when the demux is shutting down (its own `Close`/`StopAccepting` set the closed flag before closing the listener), and otherwise backs off with a capped exponential delay (5ms doubling to a 1s cap, logged at WARN) and retries while the listener is live, resetting the delay after a successful `Accept`. This matters because in multiplex mode the demux loop is the *sole* caller of `Accept` on the real socket — `http.Server` only ever sees the virtual listener, which never surfaces an OS-level error — so exiting on a transient failure (EMFILE/ENFILE under fd exhaustion, ECONNABORTED, realistic for a proxy holding many long-lived tunnels and relays) would have killed accept for *both* planes until process restart, silently removing the resilience `http.Server.Serve`'s own accept-retry loop gave the HTTP plane before it was multiplexed. Unlike `net/http` it does not gate the retry on the deprecated, unreliable `net.Error.Temporary()`; the trade is that a genuinely dead-but-unclosed listener retries once per second forever with a WARN each time — the same visible, capped pathological case `net/http` tolerates, not a zero-delay spin.
Documented in the config reference (["Sharing one listener with the HTTP proxy"](docs/content/reference/02-config-file.md#sharing-one-listener-with-the-http-proxy)) and the load-balancer guide (["Single load balancer, one shared port"](docs/content/guides/11-load-balancer-proxy-protocol.md#single-load-balancer-one-shared-port), including health-check behavior on the shared listener: an HTTP health check classifies as HTTP traffic and reaches `/healthz` normally, and a bare TCP connect-then-close probe is simply dropped, cleanly and silently, once the classification read times out)

## v0.18.0 — 2026-07-15

### Added
Expand Down
69 changes: 69 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gatekeeper

import (
"fmt"
"os"

"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -171,3 +172,71 @@ func LoadConfig(path string) (*Config, error) {
}
return ParseConfig(data)
}

// listenTopology is the resolved result of applying gatekeeper's host
// default rules to Config.Proxy and Config.Postgres, and deciding whether
// they collapse onto one shared listener. See resolveListenTopology.
type listenTopology struct {
proxyHost string
proxyPort int
pgHost string // "" when no Postgres listener is configured
pgPort int
multiplex bool
}

// resolveListenTopology applies gatekeeper's host-default rules
// (proxy.host defaults to defaultProxyHost; postgres.host defaults to the
// resolved proxy host) and decides whether the proxy and Postgres listeners
// collapse onto one shared port. There is no separate flag for this — an
// operator declares "one listener" purely by pointing both configs at the
// same port and host; resolveListenTopology and Server.Start both call this
// function so the declaration and the actual wiring can never disagree.
//
// Multiplexing triggers only when postgres.port is a real, non-zero port
// and it equals proxy.port, and the two configs' hosts resolve to the same
// address. Port 0 (ask the OS for an ephemeral port) never triggers
// multiplexing, even when both sides leave it unset: two independent
// net.Listen(...:0) calls are not "the same port," and treating them as
// such would silently change today's two-listener behavior for every
// config — and every test in this suite — that leaves the port unset on
// both listeners.
//
// It returns a fatal, user-facing error for the two configurations
// gatekeeper refuses to start with once ports are declared equal: different
// hosts (ambiguous — which address should the one shared listener bind?),
// and proxy.proxy_protocol != postgres.proxy_protocol (a single shared
// listener can only have one PROXY protocol setting, owned by
// proxy.proxy_protocol since proxy.proxy_protocol is the listener owner).
func resolveListenTopology(cfg *Config) (listenTopology, error) {
proxyHost := cfg.Proxy.Host
if proxyHost == "" {
proxyHost = defaultProxyHost
}
topo := listenTopology{proxyHost: proxyHost, proxyPort: cfg.Proxy.Port}
if cfg.Postgres == nil {
return topo, nil
}

pgHost := cfg.Postgres.Host
if pgHost == "" {
pgHost = proxyHost
}
topo.pgHost = pgHost
topo.pgPort = cfg.Postgres.Port

if cfg.Postgres.Port == 0 || cfg.Postgres.Port != cfg.Proxy.Port {
return topo, nil
}

// Equal, non-zero ports: this is the multiplex declaration.
if pgHost != proxyHost {
return topo, fmt.Errorf("postgres.port (%d) equals proxy.port but postgres.host (%q) differs from proxy.host (%q); a shared listener can only bind one address — use the same host on both, or different ports",
cfg.Postgres.Port, pgHost, proxyHost)
}
if cfg.Postgres.ProxyProtocol != cfg.Proxy.ProxyProtocol {
return topo, fmt.Errorf("postgres.port (%d) equals proxy.port but postgres.proxy_protocol (%v) differs from proxy.proxy_protocol (%v); a shared listener has one PROXY protocol setting, owned by proxy.proxy_protocol",
cfg.Postgres.Port, cfg.Postgres.ProxyProtocol, cfg.Proxy.ProxyProtocol)
}
topo.multiplex = true
return topo, nil
}
163 changes: 163 additions & 0 deletions config_topology_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package gatekeeper

// config_topology_test.go tests resolveListenTopology, the pure function
// that decides whether the proxy and Postgres listeners collapse onto one
// shared, single-port listener (see demux.go in the proxy package) purely
// from port/host equality — there is no separate config flag.

import (
"strings"
"testing"
)

func TestResolveListenTopology_NoPostgresNeverMultiplexes(t *testing.T) {
cfg := &Config{Proxy: ProxyConfig{Port: 8080, Host: "127.0.0.1"}}
topo, err := resolveListenTopology(cfg)
if err != nil {
t.Fatalf("resolveListenTopology: %v", err)
}
if topo.multiplex {
t.Error("multiplex = true with no postgres configured, want false")
}
}

func TestResolveListenTopology_EqualNonZeroPortsAndHostsMultiplex(t *testing.T) {
cfg := &Config{
Proxy: ProxyConfig{Port: 5432, Host: "127.0.0.1"},
Postgres: &PostgresConfig{Port: 5432},
}
topo, err := resolveListenTopology(cfg)
if err != nil {
t.Fatalf("resolveListenTopology: %v", err)
}
if !topo.multiplex {
t.Error("multiplex = false with equal proxy.port/postgres.port and matching hosts, want true")
}
}

func TestResolveListenTopology_EqualPortsExplicitSameHostMultiplexes(t *testing.T) {
cfg := &Config{
Proxy: ProxyConfig{Port: 5432, Host: "0.0.0.0"},
Postgres: &PostgresConfig{Port: 5432, Host: "0.0.0.0"},
}
topo, err := resolveListenTopology(cfg)
if err != nil {
t.Fatalf("resolveListenTopology: %v", err)
}
if !topo.multiplex {
t.Error("multiplex = false with explicit matching hosts, want true")
}
}

// TestResolveListenTopology_EqualPortsDifferentHostsErrors pins the fatal,
// ambiguous-bind error: two listeners can't share one port but bind
// different addresses.
func TestResolveListenTopology_EqualPortsDifferentHostsErrors(t *testing.T) {
cfg := &Config{
Proxy: ProxyConfig{Port: 5432, Host: "127.0.0.1"},
Postgres: &PostgresConfig{Port: 5432, Host: "10.0.0.5"},
}
_, err := resolveListenTopology(cfg)
if err == nil {
t.Fatal("resolveListenTopology succeeded with equal ports but different hosts, want an error")
}
if !strings.Contains(err.Error(), "127.0.0.1") || !strings.Contains(err.Error(), "10.0.0.5") {
t.Errorf("error = %q, want it to name both conflicting hosts", err)
}
}

// TestResolveListenTopology_MismatchedProxyProtocolErrors pins the fatal
// error for a shared listener with disagreeing PROXY protocol settings: one
// physical listener can only have one PROXY protocol setting.
func TestResolveListenTopology_MismatchedProxyProtocolErrors(t *testing.T) {
cfg := &Config{
Proxy: ProxyConfig{Port: 5432, Host: "127.0.0.1", ProxyProtocol: true},
Postgres: &PostgresConfig{Port: 5432, ProxyProtocol: false},
}
_, err := resolveListenTopology(cfg)
if err == nil {
t.Fatal("resolveListenTopology succeeded with mismatched proxy_protocol settings, want an error")
}
if !strings.Contains(err.Error(), "proxy_protocol") {
t.Errorf("error = %q, want it to mention proxy_protocol", err)
}
}

// TestResolveListenTopology_MatchingProxyProtocolMultiplexes is the
// converse of the mismatch case: when both sides explicitly agree, the
// shared listener is allowed and inherits that setting.
func TestResolveListenTopology_MatchingProxyProtocolMultiplexes(t *testing.T) {
cfg := &Config{
Proxy: ProxyConfig{Port: 5432, Host: "127.0.0.1", ProxyProtocol: true},
Postgres: &PostgresConfig{Port: 5432, ProxyProtocol: true},
}
topo, err := resolveListenTopology(cfg)
if err != nil {
t.Fatalf("resolveListenTopology: %v", err)
}
if !topo.multiplex {
t.Error("multiplex = false with matching proxy_protocol settings, want true")
}
}

// TestResolveListenTopology_DistinctPortsNeverMultiplex pins today's default
// two-listener path, unchanged.
func TestResolveListenTopology_DistinctPortsNeverMultiplex(t *testing.T) {
cfg := &Config{
Proxy: ProxyConfig{Port: 8080, Host: "127.0.0.1"},
Postgres: &PostgresConfig{Port: 5432},
}
topo, err := resolveListenTopology(cfg)
if err != nil {
t.Fatalf("resolveListenTopology: %v", err)
}
if topo.multiplex {
t.Error("multiplex = true with distinct ports, want false")
}
}

// TestResolveListenTopology_BothPortsZeroNeverMultiplex is a regression pin
// for a subtle trap: Port: 0 on both sides means "ask the OS for an
// ephemeral port" on each listener independently, not "these are the same
// port." Treating 0 == 0 as a multiplex trigger would silently change
// behavior for every config (and every test in this suite) that leaves the
// port unset on both listeners, in violation of "distinct ports leave the
// two-listener path unchanged" — port 0 on both sides isn't a declared
// shared port at all, so it must never trigger multiplexing.
func TestResolveListenTopology_BothPortsZeroNeverMultiplex(t *testing.T) {
cfg := &Config{
Proxy: ProxyConfig{Port: 0, Host: "127.0.0.1"},
Postgres: &PostgresConfig{Port: 0},
}
topo, err := resolveListenTopology(cfg)
if err != nil {
t.Fatalf("resolveListenTopology: %v", err)
}
if topo.multiplex {
t.Error("multiplex = true with both ports left at 0, want false (0 means \"OS-assigned\", not \"shared\")")
}
}

// TestResolveListenTopology_PostgresHostDefaultsToResolvedProxyHost mirrors
// the existing gatekeeper.go rule (postgres.host defaults to the same host
// the proxy listener resolved to, including the 127.0.0.1 default when
// proxy.host is empty) so multiplex detection agrees with it exactly.
func TestResolveListenTopology_PostgresHostDefaultsToResolvedProxyHost(t *testing.T) {
cfg := &Config{
Proxy: ProxyConfig{Port: 5432}, // Host left empty -> defaultProxyHost
Postgres: &PostgresConfig{Port: 5432},
}
topo, err := resolveListenTopology(cfg)
if err != nil {
t.Fatalf("resolveListenTopology: %v", err)
}
if !topo.multiplex {
t.Error("multiplex = false when both hosts default to the same resolved address, want true")
}
if topo.proxyHost != defaultProxyHost {
t.Errorf("proxyHost = %q, want %q", topo.proxyHost, defaultProxyHost)
}
if topo.pgHost != defaultProxyHost {
t.Errorf("pgHost = %q, want %q", topo.pgHost, defaultProxyHost)
}
}
2 changes: 2 additions & 0 deletions docs/content/concepts/08-postgres-data-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ keywords: ["gatekeeper", "postgres", "neon", "SCRAM", "credential injection", "d

Gatekeeper runs a second listener that speaks the Postgres wire protocol. A client connects to it with the real database hostname and an authentication token in place of the database password. Gatekeeper resolves the real password, authenticates upstream, and relays the connection. The database password never reaches the client.

This is a genuinely separate listener from the HTTP/CONNECT proxy — except when `postgres.port` is configured to the same value as `proxy.port`, in which case gatekeeper multiplexes both planes onto one shared listener, classifying each connection by its first bytes. See [Sharing one listener with the HTTP proxy](../reference/02-config-file.md#sharing-one-listener-with-the-http-proxy) in the config reference.

This mirrors the HTTP credential-injection plane: the client presents a weak identity (its run token), and Gatekeeper substitutes the real credential before talking to the upstream. The two planes share configuration, network policy, per-run context scoping, and audit logging.

## What it solves
Expand Down
Loading
Loading