-
Notifications
You must be signed in to change notification settings - Fork 367
Setup Without SNI Router
A two-service alternative to SNI Router Setup: no HAProxy/nginx/sslh in front — mtg itself owns port 443 and uses its built-in domain fronting to pass non-Telegram traffic to a real web server. Read Surviving Active Probing first for the threat model.
How it works: when mtg receives TLS that does not complete a valid FakeTLS handshake (a browser, a scanner, a DPI probe), it does not close the connection — it replays the bytes to the configured fronting target and pipes the rest of the stream. The routing decision is made by secret validation, not by SNI, so a probe that sends the correct SNI but no valid secret still ends up at the real website. The outcome is the same as with an SNI router; the router just isn't a separate process.
┌─────────────────────┐
:443 ──────>│ mtg │
│ (FakeTLS check) │
└──┬──────────────┬───┘
valid secret │ │ everything else
v v (PROXY v2)
┌────────────┐ ┌──────────┐
│ Telegram │ │ Caddy │
│ │ │ :8443 │
└────────────┘ │ real TLS │
└──────────┘
:80 ──────────────────────> Caddy (ACME HTTP-01 + HTTPS redirect)
| SNI router (3 services) | mtg fronting (2 services, this page) | |
|---|---|---|
| Process on :443 | HAProxy | mtg |
| Routing decision | SNI string in ClientHello | FakeTLS secret validation |
| Several domains / backends on one :443 | yes | no — mtg has a single fronting target |
| Website availability when mtg is down | site keeps working | site is down (mtg is the entry point) |
| Real client IPs | PROXY v2 from HAProxy to both backends | mtg sees them natively (host netns); Caddy gets PROXY v2 from mtg |
| Moving parts | 3 services, ~70-line haproxy.cfg | 2 services |
If you serve one domain and can accept that the website shares fate with mtg, the two-service setup does the same job with less configuration. If you multiplex several domains on the same IP, or want the website to survive mtg restarts, use the SNI router.
[domain-fronting].host landed after v2.2.8
(#480), so this page pins
the nineseconds/mtg:master image — same as
contrib/sni-router.
Switch to the release tag once the next release after v2.2.8 is out.
If you must run the tagged nineseconds/mtg:2 (v2.2.8) image today,
the key is spelled ip instead of host (same value, 127.0.0.1).
The key names are not interchangeable across versions: v2.2.8 does
not know host, master ignores ip — in both mismatch cases mtg
silently falls back to resolving the secret's hostname via DNS, which
points back at this server and produces a fronting loop (see
"Fronting loop" in the contrib README).
x-domain-env: &domain-env
DOMAIN: ${DOMAIN:-example.com}
services:
mtg:
# FIXME: :master until #480 lands in a tagged release; see "Version
# note" above.
image: nineseconds/mtg:master
# Host netns: mtg binds host :443 directly and sees real client
# IPs (v4/v6) — a published bridge port would rewrite the source
# address to the bridge gateway. Linux host only; same trade-offs
# as HAProxy's host mode in contrib/sni-router.
network_mode: host
volumes:
- ./mtg-config.toml:/config/config.toml:ro,Z
depends_on:
- web
restart: unless-stopped
web:
image: caddy:alpine
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro,Z
- caddy_data:/data
- ./www:/srv:ro,Z
ports:
# :80 goes straight to Caddy — mtg does not handle plain HTTP.
# ACME HTTP-01 and the HTTP->HTTPS redirect both live here.
- "80:80"
# TLS listener, published on host loopback only — mtg (host
# netns) reaches it via 127.0.0.1 on the fronting path.
- "127.0.0.1:8443:8443"
environment:
<<: *domain-env
restart: unless-stopped
volumes:
caddy_data:secret = "${MTG_SECRET}"
# mtg owns host :443 directly. [::] binds both IPv4 and IPv6.
bind-to = "[::]:443"
# No proxy-protocol-listener: clients connect to mtg directly, so it
# already sees their real addresses.
# Fronting target: the local Caddy. Non-Telegram TLS is relayed here
# byte-for-byte; Caddy answers with the real certificate. 127.0.0.1
# because mtg shares the host netns and Caddy's TLS port is published
# on host loopback. On v2.2.8 spell the key `ip`, not `host` — see
# "Version note".
[domain-fronting]
host = "127.0.0.1"
port = 8443
proxy-protocol = true
[defense.anti-replay]
enabled = true
max-size = "1mib"
error-rate = 0.001{
# ACME HTTP-01 arrives on :80, published straight to this
# container. TLS is served on :8443; mtg relays the public :443
# here. Caddy's automatic HTTP->HTTPS redirect points at the
# domain without a port (the non-standard https_port is never
# written into redirects), so browsers land on :443 -> mtg ->
# fronting -> back here.
http_port 80
https_port 8443
# mtg sends a PROXY protocol v2 header on the fronting path
# ([domain-fronting].proxy-protocol = true) so the real client IP
# reaches the access log. The `tls` wrapper must follow so that
# TLS is terminated on the unwrapped connection.
#
# mtg connects via the loopback-published port; depending on the
# engine the source seen inside the container is 127.0.0.1 or the
# bridge gateway — hence the RFC1918 ranges next to 127.0.0.1.
servers :8443 {
listener_wrappers {
proxy_protocol {
timeout 5s
allow 127.0.0.1/32 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
}
tls
}
}
}
{$DOMAIN} {
root * /srv
file_server
}# 1. Point your domain's DNS A/AAAA record to this server's IP.
# 2. Generate an mtg secret:
docker run --rm nineseconds/mtg:2 generate-secret --hex YOUR_DOMAIN
# 3. Configure:
echo 'DOMAIN=your.domain' > .env # picked up by Caddy
# Save the mtg-config.toml above next to docker-compose.yml and
# replace ${MTG_SECRET} with the hex secret from step 2.
# 4. Put your site content into www/
# 5. Start:
docker compose up -d
# 6. Get the proxy link:
docker compose exec mtg mtg access /config/config.tomlSame checks as the SNI-router test plan:
-
https://YOUR_DOMAINin a browser → your web page, real Let's Encrypt certificate. -
http://YOUR_DOMAIN→ redirects tohttps://YOUR_DOMAIN(no:8443in the Location header). - Probe simulation — SNI matches, no MTProto handshake:
curl --resolve YOUR_DOMAIN:443:SERVER_IP -kI https://YOUR_DOMAIN/→ terminates against Caddy, no loop; Caddy's access log shows your real client IP. - Telegram client connects through the proxy link from step 6.
-
Linux host only.
network_mode: hoston Docker Desktop (macOS/Windows) means the Linux VM, not your machine — external clients can't reach the proxy. - mtg owns host :443 — don't run anything else on it. Port :80 belongs to Caddy.
-
userns-remap: the in-container "root" loses the privilege to bind ports below 1024 on the host. Disableuserns-remapfor this stack or lowernet.ipv4.ip_unprivileged_port_start. - Website shares fate with mtg. If the mtg container is down, the site on :443 is down too (the :80 redirect still answers). If that's unacceptable, use the SNI router.
- mtg sits in the web path. Every visitor and probe passes through mtg's relay. For a low-traffic fronting site this is negligible; don't put a production website behind it.