diff --git a/contrib/sni-router/Caddyfile b/contrib/sni-router/Caddyfile new file mode 100644 index 000000000..d3ec52803 --- /dev/null +++ b/contrib/sni-router/Caddyfile @@ -0,0 +1,30 @@ +{ + # Caddy sits behind HAProxy which passes raw TLS through on :8443. + # 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} { + root * /srv + file_server +} diff --git a/contrib/sni-router/README.md b/contrib/sni-router/README.md new file mode 100644 index 000000000..0e4113d42 --- /dev/null +++ b/contrib/sni-router/README.md @@ -0,0 +1,89 @@ +# SNI-routing deployment for mtg + +A turnkey `docker compose` setup that puts an SNI-aware TCP router +(HAProxy) in front of mtg **and** a real web server (Caddy with +automatic HTTPS). + +## Why + +Modern DPI systems actively probe suspected proxies. If the server +closes the connection or returns something unexpected, the IP gets +flagged. With this setup: + +- **Telegram clients** connect to port 443, HAProxy sees the configured + SNI and routes them to mtg (FakeTLS). +- **Everything else** (browsers, DPI probes, scanners) is routed to + Caddy, which responds with a real Let's Encrypt certificate and serves + genuine web content. + +Because your domain's DNS points to this server, the SNI/IP match is +natural and passive DPI has nothing to flag. + +## Quick start + +```bash +# 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. Edit the config files: +# - mtg-config.toml → paste the secret +# - haproxy.cfg → replace "example.com" in the SNI ACL +# - .env or export → DOMAIN=your.domain + +# 4. (Optional) put your site content into www/ + +# 5. Start: +docker compose up -d + +# 6. Verify: +# - Open https://YOUR_DOMAIN in a browser → you should see the web page +# - Configure Telegram with the proxy link from: +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 +Caddy so that HTTP-01 validation works out of the box. Make sure your +domain's DNS A/AAAA record points to this server before starting. + +## Architecture + +``` + ┌──────────────────┐ + :443 ──────>│ HAProxy │ + │ (TCP, SNI peek) │ + └──┬───────────┬───┘ + SNI match │ │ default + v v + ┌─────────┐ ┌─────────┐ + │ mtg │ │ Caddy │ + │ :3128 │ │ :8443 │ + │ FakeTLS │ │ real TLS│ + └─────────┘ └─────────┘ +``` + +## Files + +| File | Purpose | +|---|---| +| `docker-compose.yml` | Service definitions | +| `haproxy.cfg` | SNI routing rules — **edit the domain** | +| `mtg-config.toml` | mtg proxy config — **paste your secret** | +| `Caddyfile` | Web server config (auto-HTTPS) | +| `www/` | Static site content served by Caddy | diff --git a/contrib/sni-router/docker-compose.yml b/contrib/sni-router/docker-compose.yml new file mode 100644 index 000000000..a8198dd09 --- /dev/null +++ b/contrib/sni-router/docker-compose.yml @@ -0,0 +1,54 @@ +# SNI-routing deployment: HAProxy (443) -> mtg + real web backend +# +# This setup puts an SNI-aware TCP router in front of mtg so that: +# - Telegram clients (FakeTLS with the correct SNI) are routed to mtg +# - All other TLS traffic (including DPI probes) reaches the real web +# server, which responds with a genuine certificate +# +# The result: active probes see a real website; passive DPI sees matching +# SNI/IP because the domain resolves to this server's IP. +# +# Quick start: +# 1. Set YOUR_DOMAIN below (and in mtg-config.toml) +# 2. docker compose up -d +# 3. mtg generate-secret YOUR_DOMAIN -> put it in mtg-config.toml +# 4. docker compose restart mtg +# +# See BEST_PRACTICES.md and the project wiki for background. + +services: + haproxy: + image: haproxy:lts-alpine + ports: + - "443:443" + - "80:80" + volumes: + - ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro + depends_on: + - mtg + - web + restart: unless-stopped + + mtg: + image: nineseconds/mtg:2 + volumes: + - ./mtg-config.toml:/config/config.toml:ro + expose: + - "3128" + restart: unless-stopped + + web: + image: caddy:alpine + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - ./www:/srv:ro + expose: + - "80" + - "8443" + environment: + DOMAIN: ${DOMAIN:-example.com} + restart: unless-stopped + +volumes: + caddy_data: diff --git a/contrib/sni-router/haproxy.cfg b/contrib/sni-router/haproxy.cfg new file mode 100644 index 000000000..2a18c1be5 --- /dev/null +++ b/contrib/sni-router/haproxy.cfg @@ -0,0 +1,62 @@ +# HAProxy SNI router — Layer 4 (TCP mode) +# +# Inspects the SNI in the TLS ClientHello and routes traffic: +# - SNI matching the mtg secret domain -> mtg (FakeTLS / MTProto) +# - Everything else -> real web backend (Caddy) +# +# Because routing happens before TLS termination, each backend sees the +# raw ClientHello and handles TLS itself. The real web backend therefore +# presents a genuine certificate to any probe or browser. + +global + log stdout format raw local0 info + maxconn 4096 + +defaults + log global + mode tcp + option tcplog + timeout connect 5s + timeout client 60s + timeout server 60s + +# --- HTTP :80 — ACME challenges + redirect ----------------------------------- + +frontend http + bind *:80 + mode http + + # Let Caddy answer ACME HTTP-01 challenges for Let's Encrypt. + acl is_acme path_beg /.well-known/acme-challenge/ + use_backend web_acme if is_acme + + http-request redirect scheme https code 301 + +# --- TLS :443 — SNI-based routing ------------------------------------------- + +frontend tls + bind *:443 + tcp-request inspect-delay 5s + tcp-request content accept if { req_ssl_hello_type 1 } + + # Route Telegram clients to mtg. + # Replace "example.com" with the domain from your mtg secret. + use_backend mtg if { req_ssl_sni -i example.com } + + default_backend web + +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 + +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 + +backend web_acme + mode http + server web web:80 diff --git a/contrib/sni-router/mtg-config.toml b/contrib/sni-router/mtg-config.toml new file mode 100644 index 000000000..c45046a2c --- /dev/null +++ b/contrib/sni-router/mtg-config.toml @@ -0,0 +1,17 @@ +# Minimal mtg configuration for the SNI-router setup. +# +# 1. Generate a secret: mtg generate-secret --hex example.com +# 2. Paste it below. +# 3. Replace example.com with your actual domain everywhere. + +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" +error-rate = 0.001 diff --git a/contrib/sni-router/www/index.html b/contrib/sni-router/www/index.html new file mode 100644 index 000000000..97f105dfc --- /dev/null +++ b/contrib/sni-router/www/index.html @@ -0,0 +1,5 @@ + + +
Replace this with your own content.
+