Skip to content

feat(postgres): PROXY protocol support + per-listener proxy_protocol config#55

Merged
andybons merged 1 commit into
mainfrom
postgres-proxy-protocol
Jul 15, 2026
Merged

feat(postgres): PROXY protocol support + per-listener proxy_protocol config#55
andybons merged 1 commit into
mainfrom
postgres-proxy-protocol

Conversation

@andybons

Copy link
Copy Markdown
Contributor

What

Adds PROXY protocol support to the Postgres data-plane listener, and reorganizes the PROXY protocol config to be per-listener — mirroring how HAProxy attaches accept-proxy to each bind.

Config (breaking, pre-1.0)

  • proxy.proxy_protocol — the HTTP/CONNECT listener (renamed from network.proxy_protocol)
  • postgres.proxy_protocol — the Postgres data-plane listener (new)
  • network.proxy_protocolremoved, no alias. PROXY protocol is a listener-ingress concern, not network allow/deny policy, so it never belonged under network:. Migration: move the line under proxy:.

Behavior

Both listeners use identical semantics via a shared proxy.WrapProxyProtocolListener helper (deduplicated from the old inline HTTP path): fail-open USE policy (header honored if present; fall back to the raw TCP peer address if absent or on a 10s header-read timeout), and a DEBUG log on malformed headers. On the Postgres plane the advertised address flows into ClientAddr and the run-token-auth log automatically. The PROXY header is consumed before the Postgres SSLRequest and TLS handshake, so interception is unaffected. (go-proxyproto parses both v1 and v2.)

Security: client_ip/client_addr is logging-only on both listeners (auth is the constant-time run token; nothing keys off the advertised address), so a forged header only pollutes logs — enable only when the port is reachable solely through the LB. Documented in the config reference, guide 11, and the observability concept page.

Fixes folded in (surfaced by review)

  • Accept no longer blocks on the header parse. WrapProxyProtocolListener's Accept was calling conn.RemoteAddr() unconditionally, triggering the lazy blocking header read on the accept loop — a slow-loris stall of all new connections. Now uses the side-effect-free Raw().RemoteAddr() for proxyproto conns. This was pre-existing in the v0.17.0 HTTP path, so this hardens the HTTP listener too.
  • TCP keep-alives survive the wrapper. enableKeepAlive's *net.TCPConn assertion silently failed through the PROXY wrapper (losing keepalives); fixed via a documented underlyingTCPConn unwrap.

Tests

Test-first throughout: Postgres v1 header → ClientAddr = advertised IP (red before wiring), fail-open no-header fallback, disabled-default, HTTP via the new field, the Accept-doesn't-block-on-silent-client regression test, and the keepalive-unwrap test (mutation-checked). Live-verified end-to-end: both listeners, real CA, real handshakes. go test -race ./..., vet, gofmt clean.

Docs

CHANGELOG v0.18.0 (Added: Postgres support; Changed: the breaking rename + per-listener model). Config reference, guide 11 (now covers the Postgres port), observability page, README, and both example configs updated.

…l config

Adds PROXY protocol v1/v2 support to the Postgres data-plane listener,
matching the HTTP/CONNECT listener's existing fail-open handling: a
leading PROXY header is honored when present (client_ip and the
run-token-auth-failure log line reflect its advertised address instead
of the raw TCP peer), a connection with no header still succeeds
(fail-open), and a malformed header is dropped with one DEBUG log
line. The header always arrives as the first bytes on the wire, ahead
of the client's SSLRequest, so PostgresServer gains a
StartListener(net.Listener) method letting the caller wrap the
listener before the first Accept.

Both listeners now share one implementation, proxy.WrapProxyProtocolListener
(proxy/proxyproto.go) — the proxyproto.Listener construction, the fail-open
USE policy, the 10s ReadHeaderTimeout, and the malformed-header debug-log
wrapper, previously duplicated inline in gatekeeper.go for the HTTP listener
only.

Configuration is reorganized to be per-listener: network.proxy_protocol
is removed (gatekeeper is pre-1.0 and single-consumer, so this is a
breaking config change with no deprecated alias) in favor of
proxy.proxy_protocol (HTTP/CONNECT) and the new postgres.proxy_protocol
(Postgres). Docs, guides, and example configs are updated to match.
@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @andybons's task in 2m 49s —— View job


Review: PROXY protocol support + per-listener config (#55)

Todo list

  • Gather context (PR body, changed files, custom instructions)
  • Read key changed files (proxy/proxyproto.go, proxy/postgres.go, config.go, gatekeeper.go)
  • Check tests, security implications, race conditions
  • Read docs/examples for consistency with the config rename
  • Post final summary

Note: this sandbox's go tool required approval I couldn't grant non-interactively, so I reviewed by reading the diff and code paths rather than running go build/go vet/go test -race. If you want CI-equivalent verification from me, allow the go binary in --allowedTools.

Summary

Clean, well-tested change. I traced the full data flow for both listeners and didn't find correctness or security bugs.

proxy/proxyproto.goWrapProxyProtocolListener is a solid dedup of the old inline HTTP-only logic. The Accept()-doesn't-block fix is real and worth calling out: previously conn.RemoteAddr() on a *proxyproto.Conn triggers the lazy header parse, which blocks until bytes arrive or the 10s ReadHeaderTimeout fires — that would stall the whole accept loop behind one silent client. Reading Raw().RemoteAddr() instead avoids that, and TestWrapProxyProtocolListenerAcceptDoesNotBlockOnSilentClient demonstrates it concretely.

enableKeepAlive unwrap (proxy/postgres.go) — the underlyingTCPConn helper correctly reaches through proxyProtoLogConn.Raw()proxyproto.Conn.Raw()*net.TCPConn without touching the header, and TestUnderlyingTCPConnUnwrapsWrappedListenerConn verifies the type assertion would otherwise silently fail (no panic, just a no-op) through the wrapper stack — a real, easy-to-miss regression this catches.

Postgres wiring order (gatekeeper.go) — the PROXY header is consumed by the wrapper before PostgresServer.StartListener ever sees a connection, so it's stripped ahead of SSLRequest/TLS as claimed. ClientAddr is read via clientConn.RemoteAddr() only after the handshake has already forced at least one Read() on the wrapped conn (postgres.go:719,779), so by the time it's called the lazy header parse has already resolved — no blocking risk there, consistent with the design.

Config renamenetwork.proxy_protocolproxy.proxy_protocol / postgres.proxy_protocol is clean: no dangling references, config_test.go covers both new fields and the absent-defaults-to-false case, and the CHANGELOG/README/docs/examples are all updated consistently (checked docs/content/reference/02-config-file.md, guide 11, the observability concept page, and both example YAMLs — no stale network.proxy_protocol mentions found in prose or config).

Security — the doc language correctly frames client_ip/client_addr as logging-only and forgeable by any peer that can reach the listener directly; nothing in the auth path (authenticate, constant-time run-token compare) keys off the advertised address, so this holds.

Minor, non-blocking observation: the malformed-header detection in proxyProtoLogConn.Read (proxy/proxyproto.go) matches on strings.HasPrefix(err.Error(), "proxyproto:") — a string-matched error rather than a sentinel/typed error from go-proxyproto. This is carried over unchanged from the pre-existing gatekeeper.go implementation (not introduced by this PR), so I'm not flagging it as a blocking issue, just noting it as pre-existing fragility if go-proxyproto's error message format ever changes upstream.

No security, race, or logic issues found. Test coverage is genuinely test-first (the PR description's failing-red-first claims check out against the actual test names/assertions, e.g. client_addr before/after wiring, fail-open, disabled-default, the Accept-blocking regression, and the keepalive-unwrap test). Docs and examples are fully in sync with the breaking rename.

@andybons andybons merged commit f1dbee8 into main Jul 15, 2026
2 checks passed
@andybons andybons deleted the postgres-proxy-protocol branch July 15, 2026 19:30
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