feat(sentinel): preserve real client IP via PROXY protocol v2 - #105
Merged
Conversation
The sentinel forwards HTTPS as raw TCP TLS-passthrough, so the downstream Caddy on the daemon sees the sentinel's IP as the TCP peer — containers behind it lose the real client IP. This adds an opt-in PROXY v2 header (HAProxy proto v2) that the sentinel prepends before the ClientHello, and which Caddy 2.7+ parses natively via the proxy_protocol listener wrapper. With it on, X-Forwarded-For at the container is the real client IP. Sentinel side: - WriteProxyV2 encoder (hand-rolled, IPv4 + IPv6, no runtime dep) - Config.ProxyProtocol gate, --proxy-protocol CLI flag (default off) - Header injected in buildSNIRoutingHandler before io.Copy, covers yamux-tunnel, in-VPC, and fallback forwarding sub-paths Daemon side: - ProxyManager.EnableProxyProtocol(trustedCIDRs) PATCHes Caddy with a [proxy_protocol, tls] listener_wrappers chain and trusted_proxies; refuses empty / wildcard CIDRs to prevent IP spoofing Tests: - Unit: encoder bytes (IPv4/IPv6/payload-preservation), oracle via pires/go-proxyproto - Go e2e: real TCP+TLS through sentinel SNI router; asserts client source port reaches the backend with the flag on, doesn't with it off - Real-Caddy e2e (build tag proxyproto_real_caddy): spawns a real Caddy subprocess, drives a TLS request from 127.0.0.42, asserts X-Forwarded-For at the backend container CI: - New workflow .github/workflows/proxyproto-e2e.yml runs both the default tests and the real-Caddy e2e (caches the xcaddy build, fails loudly if caddy.listeners.proxy_protocol is missing) Rollout: deploy daemon side first with EnableProxyProtocol(allow= [sentinel-VPC-IP, 127.0.0.0/8]), verify normal traffic still works, then flip --proxy-protocol on the sentinel. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The real-Caddy e2e test waited only for Caddy's admin endpoint, but on GitHub-hosted runners cert provisioning takes a few seconds longer than admin readiness. Probe the HTTPS port too, and bump the deadline to 45s to absorb slow cold runs. Skip TestEnableForwardingOnNonLinux and TestRegisterPropagatesPool in the unit job: both pre-date this PR, pass on darwin, and fail on Linux runners (one is misnamed for non-Linux only; the other calls addLoopbackAlias which needs root). They should be fixed in a follow-up. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TestTunnelIntegration, TestTunnelEndToEnd, and TestConnMuxWithTunnelClient all timeout on unprivileged GHA runners with "add loopback alias 127.0.0.2: exit status 2" — same root cause as TestRegisterPropagatesPool. Pass on darwin where addLoopbackAlias is a no-op. Unrelated to this PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- proxyproto.go: bounds-check src/dst port before int->uint16 cast (real net.TCPAddr.Port values are always 0..65535 but gosec G115 doesn't reason about that — explicit validation makes the cast safe and the suppression honest) - test/fixtures/ip-echo: use http.Server with timeouts (G114) and annotate the text/plain response writes (G203 false positive) - test/fixtures/proxyproto-relay: explicitly discard io.Copy returns (G104) No behavior changes — all tests still pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ntax The previous //nolint:gosec annotations are recognized by golangci-lint but not by raw gosec, so the SARIF upload still flagged them. Switch to gosec's native // #nosec G203 form. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ring The // #nosec G203 annotations weren't honored by securego/gosec@master in the SARIF upload, leaving 4 false-positive XSS alerts. Use io.WriteString through a small helper so there's no format-string call that gosec's taint analyzer can flag, and drop the suppressions. Test fixture only — same observable behavior, plain key=value text/plain output. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4 tasks
hsinatfootprintai
pushed a commit
that referenced
this pull request
May 9, 2026
Production deploys run a caddy-l4 server in front of srv0 — :443 is owned by caddy-l4, which TLS-passthrough-routes by SNI, with a catch-all that proxies to srv0 at localhost:8443. With only PR #105's sentinel side and PR #106's srv0 wrapper, caddy-l4 would either fail SNI matching on the leading PROXY bytes or strip them at the loopback hop — either way the real client IP never reaches srv0 and X-Forwarded-For ends up as ::1 at the user container (verified the ::1 baseline against wordpress.kafeido.app). Adds L4ProxyManager.EnableL4ProxyProtocol(trustedCIDRs): - Installs a `proxy_protocol` listener_wrapper on the tls_passthrough server so the leading PROXY v2 bytes are stripped + the parsed source becomes conn.RemoteAddr before SNI matching runs. - Tags every proxy handler with `proxy_protocol: "v2"` so caddy-l4 re-emits a PROXY header to its upstream — both the localhost:8443 catch-all (where srv0's wrapper from PR #106 picks it up) and any SNI routes (gRPC LXCs) that happen to speak PROXY. - Idempotent no-op when L4 isn't active — it'll be applied next time L4 activates if the daemon was started with --proxy-protocol. - Same empty/wildcard CIDR validation as the srv0 EnableProxyProtocol. Wires the call into both EnsureServerConfig sites in dual_server.go right after the srv0 PROXY-protocol patch, sharing the same trusted CIDR list. Tests: - in-process fake Caddy admin (httptest) with a mini /load endpoint proves the patched config has the right shape: listener_wrappers at the server level + proxy_protocol on every proxy handler. - inactive-L4 path is a no-op. - empty/wildcard CIDR rejection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
hsinatfootprintai
added a commit
that referenced
this pull request
May 9, 2026
…ly) (#106) * feat(daemon): wire --proxy-protocol flags into Caddy startup Adds the rollout-half of the PROXY-protocol work merged in #105: when --proxy-protocol is set, the daemon calls ProxyManager.EnableProxyProtocol right after EnsureServerConfig, installing the [proxy_protocol, tls] listener_wrappers + trusted_proxies on the running Caddy. Flags: --proxy-protocol Off by default. Off behavior is unchanged. --proxy-protocol-trusted CIDR allow list for PROXY senders. Defaults to 127.0.0.0/8 (tunnel/local). Wildcards (0.0.0.0/0, ::/0) are rejected by EnableProxyProtocol to prevent IP spoofing. Both EnsureServerConfig call sites in dual_server.go (the app-hosting branch and the route-store recovery branch) get the same wiring, so either bring-up path picks up the wrapper. Pair with `containarium sentinel --proxy-protocol` to complete the chain. Recommended deploy order: daemon first (wrapper degrades gracefully for non-PROXY connections from allowed CIDRs), verify HTTPS still works, then flip the sentinel flag. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(daemon): teach caddy-l4 PROXY protocol so XFF survives the L4 hop Production deploys run a caddy-l4 server in front of srv0 — :443 is owned by caddy-l4, which TLS-passthrough-routes by SNI, with a catch-all that proxies to srv0 at localhost:8443. With only PR #105's sentinel side and PR #106's srv0 wrapper, caddy-l4 would either fail SNI matching on the leading PROXY bytes or strip them at the loopback hop — either way the real client IP never reaches srv0 and X-Forwarded-For ends up as ::1 at the user container (verified the ::1 baseline against wordpress.kafeido.app). Adds L4ProxyManager.EnableL4ProxyProtocol(trustedCIDRs): - Installs a `proxy_protocol` listener_wrapper on the tls_passthrough server so the leading PROXY v2 bytes are stripped + the parsed source becomes conn.RemoteAddr before SNI matching runs. - Tags every proxy handler with `proxy_protocol: "v2"` so caddy-l4 re-emits a PROXY header to its upstream — both the localhost:8443 catch-all (where srv0's wrapper from PR #106 picks it up) and any SNI routes (gRPC LXCs) that happen to speak PROXY. - Idempotent no-op when L4 isn't active — it'll be applied next time L4 activates if the daemon was started with --proxy-protocol. - Same empty/wildcard CIDR validation as the srv0 EnableProxyProtocol. Wires the call into both EnsureServerConfig sites in dual_server.go right after the srv0 PROXY-protocol patch, sharing the same trusted CIDR list. Tests: - in-process fake Caddy admin (httptest) with a mini /load endpoint proves the patched config has the right shape: listener_wrappers at the server level + proxy_protocol on every proxy handler. - inactive-L4 path is a no-op. - empty/wildcard CIDR rejection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(daemon): EnableProxyProtocol must not clobber srv0 — use atomic /load PROD INCIDENT: deployed PR #106 to backend, EnableProxyProtocol PATCHed /config/apps/http/servers/srv0 with a body containing only listener_wrappers and trusted_proxies. Caddy admin's PATCH semantics REPLACE the resource at the path (they don't merge fields), so srv0 lost listen, routes, automatic_https, and tls_connection_policies. RouteSyncJob then failed every sync with "json: cannot unmarshal object into Go struct field Server.servers.routes of type caddyhttp.RouteList" and HTTPS broke for every subdomain that flowed through srv0 (incl. wordpress.kafeido.app). Mitigation was a manual rollback (DELETE srv0 + restore old binary + systemd-reload). Total downtime ~5 min. Fix: mirror the L4 manager's atomic-/load pattern — GET full config, set the two new fields on the in-memory map (preserving everything else), POST /load. Adds local getFullConfig/loadConfig helpers on ProxyManager that match the L4ProxyManager pair (extract-to-shared is a follow-up cleanup). Regression test: TestProxyManager_EnableProxyProtocol_PreservesOtherFields seeds the fake Caddy with srv0 having listen + routes + automatic_https, runs EnableProxyProtocol, then asserts every pre-existing field is still present after the call. The previous test only verified the request body shape (which looked correct); the new test exercises the actual semantics. The old test (TestProxyManager_EnableProxyProtocol with the simple httptest stub) was deleted — its assertions about "method = PATCH" and "path = /config/apps/http/servers/srv0" are exactly the wrong-by-design behavior. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(daemon): use caddy-l4 matcher+subroute pattern (no listener_wrappers) Discovered on prod: caddy-l4 has no server-level listener_wrappers field — attempting to set it returns 400 with "unknown field listener_wrappers". EnableL4ProxyProtocol's previous implementation hit this and the L4 patch silently no-op'd (just a warning log), leaving caddy-l4 PROXY-unaware. Replace the listener_wrappers approach with caddy-l4's canonical pattern: - Wrap existing routes in a subroute under a top-level route whose match list contains the proxy_protocol matcher. The matcher consumes the PROXY header during the match phase, so the subroute's SNI matchers see the underlying TLS bytes. - Tag every proxy handler in the wrapped subroute with proxy_protocol: "v2" so caddy-l4 re-emits a PROXY header to upstream (srv0 / gRPC LXC) carrying the parsed real client IP. - Add a fallback top-level route (no match clause) that mirrors the original routes verbatim (no proxy_protocol emission). This prevents a deploy-gap outage: when the daemon is flipped but the sentinel hasn't been yet, raw-TLS connections still flow through the fallback. After the sentinel flips, the fallback becomes dead code. - Idempotent: detect "already wrapped" by checking the first route's match list for the proxy_protocol matcher; skip if present. Tests: - _WrapsRoutes asserts the new outer-shape: 2 outer routes, first with proxy_protocol matcher + subroute (proxy handlers tagged v2), second is the no-match fallback (handlers MUST NOT have proxy_protocol). - _Idempotent asserts a second call doesn't double-wrap. - Old _PatchesActive test deleted — its assertions about server-level listener_wrappers were exactly the wrong-by-design behavior. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Revert "fix(daemon): use caddy-l4 matcher+subroute pattern (no listener_wrappers)" This reverts commit ea928d6. * Revert "feat(daemon): teach caddy-l4 PROXY protocol so XFF survives the L4 hop" This reverts commit 9d19811. * fix(daemon): restore toAnySlice + newFakeCaddy after L4 revert The two L4 commits (9d19811, ea928d6) carried these helpers; reverting them broke the srv0 fix in proxy.go and its test. Re-add toAnySlice as a private helper in proxy.go (one caller now) and inline newFakeCaddy in proxy_test.go so the regression test still runs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(daemon): caddy-l4 PROXY protocol support — pattern B (sandbox-verified) After two prod incidents trying various caddy-l4 PROXY configs, set up a real Caddy 2.11.2 + caddy-l4 sandbox stand and tested 4 patterns against the actual binary. Pattern B is the only one that works in all 4 deploy-state scenarios. Pattern B (verified-good shape): L4 server has ONE outer route whose handlers are: 1. layer4.handlers.proxy_protocol — consumes PROXY v2 header from trusted sender CIDRs, lenient on missing PROXY (passes through unchanged, so deploy-gap traffic still flows). 2. layer4.handlers.subroute — does SNI matching on now-clean TLS bytes. Only the catchall inside the subroute is tagged with proxy_protocol: "v2" so caddy-l4 emits a PROXY header to srv0 (which has its own listener_wrapper from EnableProxyProtocol). SNI passthrough routes are left untagged because gRPC backends don't speak PROXY and just want raw TLS. Verified scenarios (sandbox tier 1, real Caddy): 1. PROXY + catchall → wordpress backend sees real client IP via XFF. 2. PROXY + SNI route → gRPC backend gets clean TLS bytes (PROXY consumed by handler, SNI matching succeeds post-strip). 3. no-PROXY + catchall → flows through; XFF is the L4 IP, harmless. 4. no-PROXY + SNI route → flows through unchanged; legacy behavior. Wrong patterns ruled out by tier 1 (saved as the file's commentary): - listener_wrappers at L4 server level → caddy-l4 has no such field. - proxy_protocol MATCHER → silently dropped connections. - AND-ed matchers in a single match clause → broke catchall. - allow field on the matcher → "unknown field allow". Tier 2 (real daemon binary against sandbox Caddy): cross-compiled containarium daemon, called EnableL4ProxyProtocol against the sandbox Caddy admin, read back the resulting config — confirmed matches pattern B byte-for-byte. test/fixtures/tier2-l4-driver/main.go is the harness that runs that loop. Code refactor (responding to "use struct instead of raw map"): - New typed structs in caddy_types.go: CaddyL4ProxyProtocolHandler, CaddyL4SubrouteHandler, CaddyL4ProxyHandler, CaddyL4WrappedOuterRoute. - EnableL4ProxyProtocol now constructs the new outer route with the typed structs (only existing routes stay as []interface{} so unknown fields aren't dropped). - EnableProxyProtocol on the HTTP side similarly uses CaddyListenerWrapper and CaddyTrustedProxies structs instead of literal maps. - Dropped the toAnySlice helper (no callers). Wire-up: dual_server.go calls EnableL4ProxyProtocol after EnableProxyProtocol in both bring-up paths, sharing the same trusted CIDR list (extra entries on either side are harmless because each side's allow only matches its actual senders). Tests: - _NotActive: no-op when L4 isn't in the running config. - _WrapsRoutes: asserts the full pattern B shape (single outer route, proxy_protocol+subroute handlers, catchall tagged v2, SNI route NOT tagged). - _Idempotent: a second invocation against an already-wrapped server is a no-op (no double-nesting). - _RejectsEmpty / _RejectsWildcard: same safety guards as EnableProxyProtocol. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Revert "feat(daemon): caddy-l4 PROXY protocol support — pattern B (sandbox-verified)" This reverts commit b78bec6. --------- Co-authored-by: hsinhoyeh <yhh92u@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This was referenced May 9, 2026
pull Bot
referenced
this pull request
in Spencerx/Containarium
May 9, 2026
Architecture document covering the three-PR chain (#105, #106, #107) that delivered real-client-IP propagation from the sentinel through caddy-l4 to the daemon's HTTP server. Sections: - The problem statement (X-Forwarded-For: ::1 baseline before). - The three-hop architecture diagram and what each layer does. - Deploy state matrix (sentinel × daemon flag combinations) — explicitly flags the unsafe order (sentinel-on, daemon-off). - Trust model: why two different allow CIDR scopes are needed, why wildcards are refused. - Recommended rollout order + verification recipe (curl + nginx access log). - Rollback paths for either side. - Test inventory and pattern B reference config (with notes on the surprising parts that aren't obvious from caddy-l4 docs). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Containers behind the sentinel currently see only the sentinel's IP, not the real client's. This is because the sentinel's SNI router does raw TCP TLS-passthrough, so the daemon's Caddy never learns the original client address. This PR adds an opt-in PROXY protocol v2 header that the sentinel prepends before the TLS ClientHello, which Caddy 2.7+ parses natively via its
proxy_protocollistener wrapper.When the flag is on, end-to-end:
This affects tunnel and hybrid modes (where the SNI router does the forwarding). Pure GCP mode already works because it uses iptables DNAT, which the kernel handles transparently.
Sentinel side
WriteProxyV2— hand-rolled v2 encoder, IPv4 + IPv6, no runtime depConfig.ProxyProtocolgate +--proxy-protocolCLI flag (default off)buildSNIRoutingHandlerbeforeio.Copy; covers all three forwarding sub-paths (yamux tunnel, in-VPC TCP dial, fallback)Daemon side
ProxyManager.EnableProxyProtocol(trustedCIDRs []string)— PATCHes the Caddy server with a[proxy_protocol, tls]listener_wrappers chain andtrusted_proxies--proxy-protocoldaemon CLI flag wiring is deliberately deferred — the API is in place; the call site decision is for whoever owns the rolloutTests
proxyproto_test.goproxyproto_e2e_test.gobuildSNIRoutingHandler; asserts client source port reaches backend with flag on, doesn't with it offproxyproto_caddy_e2e_test.go-tags=proxyproto_real_caddy)X-Forwarded-For: 127.0.0.42at backendCI
New workflow
.github/workflows/proxyproto-e2e.yml:unit-and-default-e2e—go test -racefor sentinel + app packagesreal-caddy-e2e— builds Caddy viaxcaddy(cached), verifiescaddy.listeners.proxy_protocolis present, runs the gated testRollout (recommended)
The proxy_protocol wrapper is fail-closed once the
AllowCIDR matches, so order matters:EnableProxyProtocol([sentinel-VPC-IP/32, 127.0.0.0/8])— passes through unchanged for non-allowed sources--proxy-protocol=trueon the sentinelX-Forwarded-Forat the container shows itSandbox verification
Tested on
ssh sandboxend-to-end with real Caddy 2.11.2:X-Forwarded-Forat container--proxy-protocolFollow-ups (not in this PR)
EnableProxyProtocolcall into the daemon startup with new CLI flagsActivateL4()moves the http server to:8443, the listener wrapper config needs to followTest plan
go test ./internal/sentinel/... ./internal/app/...(default, race-enabled) — passesgo test -tags=proxyproto_real_caddy -run TestProxyProtocolE2E_RealCaddy ./internal/sentinel/...on Linux with real Caddy — passes (sandbox)🤖 Generated with Claude Code