Skip to content

l4postgres: add Postgres STARTTLS (terminate inbound + re-originate outbound)#432

Open
tannevaled wants to merge 4 commits into
mholt:masterfrom
tannevaled:feat/postgres-starttls
Open

l4postgres: add Postgres STARTTLS (terminate inbound + re-originate outbound)#432
tannevaled wants to merge 4 commits into
mholt:masterfrom
tannevaled:feat/postgres-starttls

Conversation

@tannevaled

@tannevaled tannevaled commented Jun 2, 2026

Copy link
Copy Markdown

What

Adds PostgreSQL STARTTLS support to l4postgres, in both directions:

  • Inbound — postgres_starttls handler: reads the 8-byte SSLRequest, replies S, and hands off. Put a tls handler next to terminate TLS; the handlers/matchers that follow then see the cleartext startup message (user, database, application_name). It reuses the existing tls handler (no TLS code duplicated).
  • Outbound — StartTLSClient(conn, *tls.Config): re-originates TLS to a Postgres upstream that expects sslmode != disable (sends SSLRequest, expects S, performs a tls.Client handshake).

Why

PostgreSQL doesn't begin TLS with a ClientHello — a libpq client sends SSLRequest and waits for S/N before the handshake (see #187). So the generic tls handler can't terminate a classic Postgres connection on its own. (PostgreSQL 17+ can negotiate TLS directly via ALPN postgresql, which the existing tls matcher/handler already covers — see docs/examples/postgres-over-tls.md; this handler is for the classic SSLRequest flow.)

Working example (verified by a caddyfile_adapt integration test in this PR):

{
    layer4 {
        :5432 {
            @pg postgres
            route @pg {
                postgres_starttls
                tls
                proxy upstream.local:5432
            }
        }
    }
}

Once cleartext, this composes with content matchers such as those proposed in #188 — e.g. adding a postgres { database acme } matcher (from that PR) inside the route after tls to route by database. (#188 isn't merged, so it's intentionally left out of the example above.)

Notes / design

  • The outbound part is a self-contained function rather than wired into l4proxy, to avoid coupling the generic proxy to Postgres. It would slot in behind an opt-in upstream STARTTLS hook — happy to shape that in review, or split the outbound piece into its own PR. Terminate-only deployments (backend on a trusted/encrypted network) don't need the outbound side.

Tests / docs

  • starttls_test.go: inbound (replies S / rejects non-SSLRequest / read & write error branches), outbound (real TLS handshake against a self-signed test server / N refusal / write, reply-read, unexpected-reply, and handshake error branches), and Caddyfile parsing. 100% statement coverage of the new code.
  • integration/caddyfile_adapt/gd_handler_postgres_starttls.caddytest: verifies the example above adapts to the expected JSON.
  • docs/handlers/postgres_starttls.md.
  • go test ./... -race, gofmt, go vet, golangci-lint clean; no go.mod change.

Builds on #187 and complements #188.

tannevaled and others added 2 commits June 2, 2026 19:11
PostgreSQL negotiates TLS with an 8-byte SSLRequest answered by 'S'/'N'
before the handshake, so the generic `tls` handler (which expects a
ClientHello first) cannot terminate a Postgres connection on its own.

Add a small handler that reads the SSLRequest, replies 'S', and hands off
to the next handler — so a following `tls` handler terminates TLS and
downstream matchers can match the now-cleartext startup message (user,
database, application_name). No TLS code is duplicated: it composes with
the existing `tls` handler.

Proof-of-concept to complement mholt#188 / the SSL discussion in mholt#187.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Outbound counterpart to the inbound termination: re-originate TLS to a
Postgres upstream that expects sslmode != disable. It performs the same
SSLRequest -> 'S' negotiation as a client, then a tls.Client handshake,
returning the encrypted connection ('N' or any other reply is an error).

Kept self-contained (a reusable function) rather than wired into l4proxy,
to avoid coupling the generic proxy to Postgres; intended to be plugged in
behind an opt-in upstream STARTTLS hook (left for discussion). Terminate-
only deployments (backend reached over a trusted/encrypted network) don't
need this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vnxme

vnxme commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Thank you for your latest contributions! Please add integration tests and documentation. The Postgres-over-TLS example may also require updating.

The config example in the why section above seems a bit strange - could you please explain why you need two postgres handlers? Neither do they exist, nor are they introduced by the PR -- or am I missing anything? Have you tested this code/example yourself?

tannevaled and others added 2 commits June 3, 2026 10:04
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a caddyfile_adapt integration test for postgres_starttls (a corrected,
working example that does not depend on unmerged matchers) and a handler
doc page. Addresses review feedback on mholt#432.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tannevaled

Copy link
Copy Markdown
Author

You're right, and thanks for the careful read — the example in the description was wrong as written: it mixed the postgres matcher with handlers and used a postgres { database ... } matcher form from #188 that isn't merged. I'd sketched it to illustrate the end state rather than adapting it, which is exactly the wrong shortcut. Apologies.

I've fixed it and added what you asked for:

  • A correct, L4 module for Postgres: matchers #188-independent example (matcher in @pg postgres, handlers postgres_starttlstlsproxy in the route), now verified by a caddyfile_adapt integration test (gd_handler_postgres_starttls.caddytest) — so it's checked to adapt, not asserted by hand.
  • Documentation: docs/handlers/postgres_starttls.md.
  • Test coverage of the new code brought to 100% (inbound + outbound, including the error branches).

On docs/examples/postgres-over-tls.md: it covers the PostgreSQL 17+ direct-TLS (ALPN postgresql) path, which is a different mechanism from the classic SSLRequest flow this handler targets, so I left it as-is and cross-referenced it from the new handler doc instead. Happy to add a short STARTTLS note there too if you'd prefer.

The PR description is updated accordingly. Thanks again — and please tell me if you'd rather I split the outbound StartTLSClient piece into its own PR.

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