From b86a9cf85d2dfd26a523a2e93c9eaeb89c5b5fea Mon Sep 17 00:00:00 2001 From: dolonet Date: Mon, 18 May 2026 07:53:11 +0000 Subject: [PATCH] contrib/sni-router: use host networking for HAProxy to preserve client IPs Move HAProxy into the host network namespace so it sees the real client source IP on inbound connections. With bridge networking + published ports the source IP is rewritten to the bridge gateway by the runtime (Docker's userland-proxy, rootless Podman's slirp4netns or pasta), and the PROXY v2 header HAProxy then sends to mtg and Caddy carries that useless address. mtg and Caddy stay on the compose bridge and publish their ports on host loopback; the host-mode HAProxy dials them at 127.0.0.1. Caddy's proxy_protocol allow list is tightened to loopback only. The 'sysctls: net.ipv4.ip_unprivileged_port_start=80' line is removed because Docker refuses to apply namespaced sysctls when the netns is shared with the host. Rootless Podman users binding the privileged ports need the equivalent host-side sysctl once; this is documented in README.md. Fixes #498. --- contrib/sni-router/Caddyfile | 8 ++++---- contrib/sni-router/README.md | 24 ++++++++++++++++++++++++ contrib/sni-router/docker-compose.yml | 25 +++++++++++++++---------- contrib/sni-router/haproxy.cfg | 11 ++++++++--- 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/contrib/sni-router/Caddyfile b/contrib/sni-router/Caddyfile index d3ec52803..fabb184f0 100644 --- a/contrib/sni-router/Caddyfile +++ b/contrib/sni-router/Caddyfile @@ -10,14 +10,14 @@ # 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. + # `allow` lists the networks permitted to send PROXY headers. + # HAProxy runs in the host netns and reaches Caddy via host loopback + # (see docker-compose.yml), so the only legitimate peer is loopback. servers :8443 { listener_wrappers { proxy_protocol { timeout 5s - allow 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 + allow 127.0.0.0/8 ::1/128 } tls } diff --git a/contrib/sni-router/README.md b/contrib/sni-router/README.md index aed6e9301..7254354b3 100644 --- a/contrib/sni-router/README.md +++ b/contrib/sni-router/README.md @@ -58,6 +58,30 @@ must stay in sync: If you disable one, disable all four, otherwise the backend will fail to parse the connection. +## Why host networking for HAProxy + +HAProxy runs in the host network namespace (`network_mode: host` in +`docker-compose.yml`) so it sees the real client source IP on every +inbound connection. With the default bridge networking + published +ports the source IP is rewritten to the bridge gateway — by Docker's +userland proxy (`docker-proxy`), by rootless Podman's `slirp4netns` +or `pasta`, or by NAT on the Docker host — and the PROXY v2 header +HAProxy then sends to mtg and Caddy carries that useless address. +Host networking lifts HAProxy out of the rewrite path; mtg and Caddy +stay on the compose bridge and HAProxy dials them via host loopback +(`127.0.0.1`). + +Trade-off: HAProxy occupies the host's `:443` and `:80` directly, so +nothing else on the host may listen on those ports. For a dedicated +mtg/SNI-router host that is the intended layout. + +Rootless Podman users binding the privileged ports `:80`/`:443` need +the host-side sysctl once (rootful Docker handles this implicitly): + +```bash +sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80 +``` + ## Fronting loop (why `[domain-fronting]` is set explicitly) When mtg sees TLS that isn't valid Telegram (a probe or a browser diff --git a/contrib/sni-router/docker-compose.yml b/contrib/sni-router/docker-compose.yml index a365003b8..2d09b9f01 100644 --- a/contrib/sni-router/docker-compose.yml +++ b/contrib/sni-router/docker-compose.yml @@ -24,9 +24,12 @@ x-domain-env: &domain-env services: haproxy: image: haproxy:lts-alpine - ports: - - "443:443" - - "80:80" + # Host networking so HAProxy sees the real client source IP on + # inbound (bridge networking with published ports rewrites it to + # the bridge gateway under both Docker's userland-proxy and + # rootless Podman's slirp4netns/pasta). See "Why host networking" + # in README.md. + network_mode: host volumes: - ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro,Z environment: @@ -35,15 +38,14 @@ services: - mtg - web restart: unless-stopped - sysctls: - - net.ipv4.ip_unprivileged_port_start=80 mtg: image: nineseconds/mtg:2 volumes: - ./mtg-config.toml:/config/config.toml:ro,Z - expose: - - "3128" + # Publish on host loopback so the host-mode HAProxy can reach it. + ports: + - "127.0.0.1:3128:3128" restart: unless-stopped extra_hosts: - "host.containers.internal:host-gateway" @@ -54,9 +56,12 @@ services: - ./Caddyfile:/etc/caddy/Caddyfile:ro,Z - caddy_data:/data - ./www:/srv:ro,Z - expose: - - "80" - - "8443" + # Publish on host loopback so the host-mode HAProxy can reach it. + # Caddy's HTTP listener is mapped off :80 (occupied by HAProxy) + # to :8080; haproxy.cfg dials it on 127.0.0.1:8080 for ACME. + ports: + - "127.0.0.1:8080:80" + - "127.0.0.1:8443:8443" environment: <<: *domain-env restart: unless-stopped diff --git a/contrib/sni-router/haproxy.cfg b/contrib/sni-router/haproxy.cfg index 14aba963b..ec8f2646d 100644 --- a/contrib/sni-router/haproxy.cfg +++ b/contrib/sni-router/haproxy.cfg @@ -50,14 +50,19 @@ backend mtg # 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 + # + # HAProxy runs in the host network namespace (see docker-compose.yml) + # and mtg publishes :3128 on host loopback, so we dial 127.0.0.1. + server mtg 127.0.0.1:3128 send-proxy-v2 backend web # 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 + server web 127.0.0.1:8443 send-proxy-v2 backend web_acme mode http - server web web:80 + # Caddy's :80 is published on host 127.0.0.1:8080 (HAProxy occupies + # the host's :80 itself). + server web 127.0.0.1:8080