Skip to content

feat(sentinel): preserve real client IP via PROXY protocol v2 - #105

Merged
hsinatfootprintai merged 6 commits into
mainfrom
feat/sentinel-proxy-protocol
May 9, 2026
Merged

feat(sentinel): preserve real client IP via PROXY protocol v2#105
hsinatfootprintai merged 6 commits into
mainfrom
feat/sentinel-proxy-protocol

Conversation

@hsinatfootprintai

Copy link
Copy Markdown
Contributor

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_protocol listener wrapper.

When the flag is on, end-to-end:

client (203.0.113.7)
  └─ TLS ─▶ sentinel  ── PROXY v2 (src=203.0.113.7) + TLS bytes ──▶  daemon Caddy
                                                                         └─ X-Forwarded-For: 203.0.113.7 ──▶ container

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 dep
  • Config.ProxyProtocol gate + --proxy-protocol CLI flag (default off)
  • Injected in buildSNIRoutingHandler before io.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 and trusted_proxies
  • Refuses empty / wildcard CIDRs to prevent IP spoofing
  • --proxy-protocol daemon CLI flag wiring is deliberately deferred — the API is in place; the call site decision is for whoever owns the rollout

Tests

Layer File Default? Catches
Unit proxyproto_test.go Yes Encoder byte correctness (IPv4/IPv6, payload preservation, nil-addr)
Go e2e proxyproto_e2e_test.go Yes Real TCP+TLS through buildSNIRoutingHandler; asserts client source port reaches backend with flag on, doesn't with it off
Real-Caddy e2e proxyproto_caddy_e2e_test.go No (-tags=proxyproto_real_caddy) Spawns real Caddy subprocess, drives TLS from 127.0.0.42, asserts X-Forwarded-For: 127.0.0.42 at backend

CI

New workflow .github/workflows/proxyproto-e2e.yml:

  • unit-and-default-e2ego test -race for sentinel + app packages
  • real-caddy-e2e — builds Caddy via xcaddy (cached), verifies caddy.listeners.proxy_protocol is present, runs the gated test

Rollout (recommended)

The proxy_protocol wrapper is fail-closed once the Allow CIDR matches, so order matters:

  1. Deploy daemon with EnableProxyProtocol([sentinel-VPC-IP/32, 127.0.0.0/8]) — passes through unchanged for non-allowed sources
  2. Verify HTTPS to existing subdomains still works
  3. Flip --proxy-protocol=true on the sentinel
  4. Curl from a known client IP, confirm X-Forwarded-For at the container shows it

Sandbox verification

Tested on ssh sandbox end-to-end with real Caddy 2.11.2:

Scenario curl source IP X-Forwarded-For at container
WITH --proxy-protocol 127.0.0.42 127.0.0.42
WITHOUT 127.0.0.42 127.0.0.1 ✗ (relay only)

Follow-ups (not in this PR)

  • Wire EnableProxyProtocol call into the daemon startup with new CLI flags
  • L4 mode interaction: when ActivateL4() moves the http server to :8443, the listener wrapper config needs to follow
  • Pin Caddy version in the CI workflow (currently uses xcaddy default)
  • Add the workflow to required status checks in branch protection

Test plan

  • go test ./internal/sentinel/... ./internal/app/... (default, race-enabled) — passes
  • go test -tags=proxyproto_real_caddy -run TestProxyProtocolE2E_RealCaddy ./internal/sentinel/... on Linux with real Caddy — passes (sandbox)
  • Skip path on darwin verified locally
  • Manual curl through sentinel→Caddy→container chain shows real client IP with flag on, sentinel IP with flag off

🤖 Generated with Claude Code

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>
Comment thread internal/sentinel/proxyproto.go Fixed
Comment thread internal/sentinel/proxyproto.go Fixed
Comment thread internal/sentinel/proxyproto.go Fixed
Comment thread internal/sentinel/proxyproto.go Fixed
Comment thread test/fixtures/ip-echo/main.go Fixed
Comment thread test/fixtures/ip-echo/main.go Fixed
Comment thread test/fixtures/ip-echo/main.go Fixed
Comment thread test/fixtures/ip-echo/main.go Fixed
Comment thread test/fixtures/proxyproto-relay/main.go Fixed
Comment thread test/fixtures/proxyproto-relay/main.go Fixed
hsinhoyeh and others added 3 commits May 9, 2026 16:39
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>
Comment thread test/fixtures/ip-echo/main.go Fixed
Comment thread test/fixtures/ip-echo/main.go Fixed
Comment thread test/fixtures/ip-echo/main.go Fixed
Comment thread test/fixtures/ip-echo/main.go Fixed
…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>
Comment thread test/fixtures/ip-echo/main.go Fixed
Comment thread test/fixtures/ip-echo/main.go Fixed
Comment thread test/fixtures/ip-echo/main.go Fixed
Comment thread test/fixtures/ip-echo/main.go Fixed
…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>
@hsinatfootprintai
hsinatfootprintai merged commit 62b4790 into main May 9, 2026
6 of 7 checks passed
@hsinatfootprintai
hsinatfootprintai deleted the feat/sentinel-proxy-protocol branch May 9, 2026 09:15
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>
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>
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.

3 participants