From 170346bb7468e7b0af49ba4964e3b49b06e91686 Mon Sep 17 00:00:00 2001 From: dolonet Date: Mon, 13 Apr 2026 07:58:16 +0000 Subject: [PATCH] Pass real client IPs through with PROXY protocol v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, mtg and Caddy see HAProxy's container IP for every connection, which breaks meaningful logging, abuse handling, and any IP-based blocklist logic. HAProxy sends a PROXY protocol v2 header on its TCP backends; mtg enables proxy-protocol-listener, and Caddy wraps :8443 with a proxy_protocol listener before tls. The :80 path (ACME HTTP-01 passthrough) is unchanged — client IP there is not useful and HAProxy's http mode already adds X-Forwarded-For if anyone wants it. Requested in https://github.com/9seconds/mtg/pull/462 review. --- contrib/sni-router/Caddyfile | 19 +++++++++++++++++++ contrib/sni-router/README.md | 13 +++++++++++++ contrib/sni-router/haproxy.cfg | 10 ++++++++-- contrib/sni-router/mtg-config.toml | 4 ++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/contrib/sni-router/Caddyfile b/contrib/sni-router/Caddyfile index 426a3ad..d3ec528 100644 --- a/contrib/sni-router/Caddyfile +++ b/contrib/sni-router/Caddyfile @@ -3,6 +3,25 @@ # ACME HTTP-01 challenges arrive on :80 via HAProxy's acl passthrough. http_port 80 https_port 8443 + + # HAProxy forwards connections to :8443 with a PROXY protocol v2 + # header (see haproxy.cfg `send-proxy-v2`). The proxy_protocol + # listener wrapper strips the header and exposes the real client IP + # to Caddy's access log. The `tls` wrapper must follow so that TLS + # is terminated on the unwrapped connection. + # + # `allow` lists the networks permitted to send PROXY headers. These + # ranges cover docker compose's default bridge networks; tighten + # them if you pin a specific subnet in docker-compose.yml. + servers :8443 { + listener_wrappers { + proxy_protocol { + timeout 5s + allow 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 + } + tls + } + } } {$DOMAIN} { diff --git a/contrib/sni-router/README.md b/contrib/sni-router/README.md index 19b99b1..0e4113d 100644 --- a/contrib/sni-router/README.md +++ b/contrib/sni-router/README.md @@ -43,6 +43,19 @@ docker compose up -d docker compose exec mtg mtg access /config/config.toml ``` +## Real client IPs (PROXY protocol) + +HAProxy forwards TCP connections to mtg and Caddy with a PROXY protocol +v2 header so both backends see the real client IP instead of HAProxy's +container address. The three pieces must stay in sync: + +- `haproxy.cfg` — `send-proxy-v2` on the `mtg` and `web` backend `server` lines +- `mtg-config.toml` — `proxy-protocol-listener = true` +- `Caddyfile` — `listener_wrappers { proxy_protocol { ... } tls }` on `:8443` + +If you disable one, disable all three, otherwise the backend will fail +to parse the connection. + ## ACME (Let's Encrypt) notes HAProxy passes `/.well-known/acme-challenge/` requests on `:80` to diff --git a/contrib/sni-router/haproxy.cfg b/contrib/sni-router/haproxy.cfg index dabc8f9..2a18c1b 100644 --- a/contrib/sni-router/haproxy.cfg +++ b/contrib/sni-router/haproxy.cfg @@ -46,10 +46,16 @@ frontend tls default_backend web backend mtg - server mtg mtg:3128 + # send-proxy-v2 prepends a PROXY protocol v2 header so mtg sees the + # real client IP instead of HAProxy's. mtg must have + # `proxy-protocol-listener = true` in its config. + server mtg mtg:3128 send-proxy-v2 backend web - server web web:8443 + # send-proxy-v2 prepends a PROXY protocol v2 header so Caddy logs the + # real client IP instead of HAProxy's. Caddy must enable the + # proxy_protocol listener wrapper on :8443 (see Caddyfile). + server web web:8443 send-proxy-v2 backend web_acme mode http diff --git a/contrib/sni-router/mtg-config.toml b/contrib/sni-router/mtg-config.toml index 8ce9291..c45046a 100644 --- a/contrib/sni-router/mtg-config.toml +++ b/contrib/sni-router/mtg-config.toml @@ -7,6 +7,10 @@ secret = "PASTE_YOUR_SECRET_HERE" bind-to = "0.0.0.0:3128" +# HAProxy in front sends PROXY protocol v2 headers so mtg can see the +# real client IP. Keep this in sync with haproxy.cfg (`send-proxy-v2`). +proxy-protocol-listener = true + [defense.anti-replay] enabled = true max-size = "1mib"