sni-router: break domain-fronting loop with pinned Caddy IP#478
sni-router: break domain-fronting loop with pinned Caddy IP#478dolonet wants to merge 3 commits into9seconds:masterfrom
Conversation
| > (Caddy's pinned address). Caddy may refuse the mixed-family header | ||
| > and log the docker-network address instead of the real client IP for | ||
| > that connection. Telegram traffic is unaffected. |
| `docker-compose.yml` (mtg's `domain-fronting.ip` only accepts a literal | ||
| IP, not a hostname, hence the static `sni` network). `proxy-protocol = |
There was a problem hiding this comment.
Is it fundamental mtg's restriction? Maybe try to fix it there?
There was a problem hiding this comment.
Not fundamental — TypeIP just calls net.ParseIP, but the rest of the dial path is hostname-capable. Opened #480 to add a sibling [domain-fronting].host that accepts hostname or IP. Once it lands this PR shrinks to a host = "web" line and the static subnet/pin go away.
There was a problem hiding this comment.
#480 I suggest to have this one first, so the whole PR could be simplified
There was a problem hiding this comment.
So we can shrink it now, right?
|
|
||
| networks: | ||
| sni: | ||
| driver: bridge |
There was a problem hiding this comment.
Is the bridge driver necessary?
|
Just to clarify - is this problem happens only if both the hostname and the domain are fully equal, or also if they just partially intersect - so even if the hostname is a.b.com and the domain is b.com? |
|
Good question — it's not about the names overlapping, it's about DNS. HAProxy matches the SNI exactly ( So in your Pushed a small README tweak (bcfacec) leading with "the trigger is DNS, not name equality" so the doc doesn't imply the matching-name case is the only one. |
|
Could we add a loop detection to the mtg runtime and/or it's config check doctor mode? |
|
Good idea, but I'd rather not expand 478 (it's config + docs only) — happy to track it as a separate issue/PR. Sketch of a feasible runtime check: when Caveat worth being upfront about: the check only sees the direct case. An SNI-router on a separate IP that ultimately routes back to mtg would slip through, since mtg's outbound dial lands on a "foreign" IP. A precise detector would need an out-of-band marker on the fronting connection, which MTProto doesn't expose cleanly. So 80% coverage from a cheap check, the rest stays a documentation problem. Shall I open a follow-up issue with this scope? |
|
Sure, thanks. |
|
Wanna hear @9seconds's opinion on that before diving in :) |
|
Sounds good — happy to wait on #480. Once it lands, this PR collapses to:
I'll rebase and force-push the simplified version after #480 merges. Separately, on the runtime loop-detection idea raised above (#478 (comment)) — would you like me to open a follow-up issue with the "resolve secret hostname at startup, warn if it matches a local interface, non-fatal" sketch? Easy to track, easy to scope, but I didn't want to open it without your nod. |
When the secret's domain points at this server (the recommended deployment), mtg's default fronting behavior dials that domain on :443 and the connection lands on HAProxy. HAProxy sees the SNI matching the secret and routes back to mtg, looping until something gives. Pin Caddy's container address via a static `sni` network and point mtg's `[domain-fronting]` at it directly with `proxy-protocol = true`, matching Caddy's :8443 PROXY listener wrapper. mtg's `domain-fronting.ip` only accepts a literal IP (not a hostname), so the network needs a fixed subnet. README documents the loop, the fix, and the requirement to keep the pinned IP in sync between docker-compose.yml and mtg-config.toml. Reported by @gaudima in 9seconds#462.
- Use list form for `networks: [sni]` on services that need no per-network config; keep map form only on `web` where ipv4_address requires it. - README: note that the 172.28.0.0/24 subnet can be changed if it collides with an existing host network (and remind to update both files in lockstep). - README: caveat that IPv6 fronting may lose the real client IP in Caddy's logs because mtg constructs a mixed-family PROXY v2 header (IPv6 source, IPv4 destination); Telegram traffic unaffected.
bcfacec to
bf501a8
Compare
Summary
Follow-up to #462. When the secret's domain is the same as the
fronting domain (the recommended setup, since matching SNI/IP is the
whole point of the SNI-router topology), mtg's default fronting
behavior loops:
:443with SNI=example.com → HAProxy routes to mtg.example.com) via DNS.:443→ HAProxy.Reported by @gaudima in #462 (comment).
Fix
Pin Caddy's container address via a static
sninetwork indocker-compose.yml, and add a[domain-fronting]block inmtg-config.tomlpointing mtg at Caddy directly:mtg now bypasses HAProxy for the fronting connection. PROXY protocol
v2 stays consistent (Caddy's
:8443already has the listener wrapper),so Caddy's logs still see the real client IP.
domain-fronting.ipis parsed byTypeIP(net.ParseIP) and onlyaccepts a literal IP, not a hostname — hence the static subnet and
pinned
ipv4_addressrather than relying on docker DNS.README gains a "Fronting loop" section explaining the cause and the
requirement to keep the pinned IP in sync between the two files.
Test plan
docker compose configvalidates with the newnetworksblockcurl https://DOMAIN/returns Caddy's contentcurl --resolve DOMAIN:443:HOST_IP -k -I https://DOMAIN/(probe simulation: SNI matches the secret, no MTProto handshake) — connection terminates against Caddy without looping; Caddy's access log shows the real client IP