Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions contrib/sni-router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,52 @@ docker compose exec mtg mtg access /config/config.toml

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:
container address. Caddy also receives PROXY v2 from mtg on the
fronting path (see "Fronting loop" below), so all four pieces below
must stay in sync:

- `haproxy.cfg` — `send-proxy-v2` on the `mtg` and `web` backend `server` lines
- `mtg-config.toml` — `proxy-protocol-listener = true`
- `mtg-config.toml` — `proxy-protocol-listener = true` (HAProxy → mtg)
- `mtg-config.toml` — `[domain-fronting].proxy-protocol = true` (mtg → Caddy on fronting)
- `Caddyfile` — `listener_wrappers { proxy_protocol { ... } tls }` on `:8443`

If you disable one, disable all three, otherwise the backend will fail
If you disable one, disable all four, otherwise the backend will fail
to parse the connection.

## Fronting loop (why `[domain-fronting]` is set explicitly)

When mtg sees TLS that isn't valid Telegram (a probe or a browser
hitting the domain on `:443`), it forwards that connection to a real
web server — "domain fronting". By default mtg uses the secret's
hostname as the fronting target and resolves it via DNS, which in
this setup points back to this server: the fronting dial lands on
HAProxy, SNI matches the secret, HAProxy routes the connection back
to mtg → loop.

The trigger is DNS, not name equality: any time the secret's hostname
resolves to this host, the loop reproduces. In an SNI-router
deployment the secret's hostname has to point here for clients to
reach mtg in the first place, so the loop is the default state unless
mtg is steered away from HAProxy.

`mtg-config.toml` therefore pins the fronting target to the Caddy
container directly:

```toml
[domain-fronting]
host = "web"
port = 8443
proxy-protocol = true
```

`host = "web"` resolves through compose-network DNS to the `web`
service (Caddy), bypassing HAProxy. `proxy-protocol = true` matches
Caddy's `:8443` listener wrapper so the real client IP still
propagates to Caddy's logs.

Requires mtg ≥ 2.4 — hostname acceptance for the fronting target was
added in #480.

## ACME (Let's Encrypt) notes

HAProxy passes `/.well-known/acme-challenge/` requests on `:80` to
Expand Down
11 changes: 11 additions & 0 deletions contrib/sni-router/mtg-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ bind-to = "0.0.0.0:3128"
# real client IP. Keep this in sync with haproxy.cfg (`send-proxy-v2`).
proxy-protocol-listener = true

# Fronting target: point mtg at the Caddy container directly so its
# fallback dial (for non-Telegram TLS) bypasses HAProxy and doesn't
# loop back here. Without this, mtg resolves the secret's hostname
# via DNS, which in this setup resolves to this server -> HAProxy ->
# mtg again. See README's "Fronting loop" section for the long form.
# Requires mtg >= 2.4 (#480 added hostname acceptance for the target).
[domain-fronting]
host = "web"
port = 8443
proxy-protocol = true

[defense.anti-replay]
enabled = true
max-size = "1mib"
Expand Down
Loading