From 0c1d0019498017a87f2a64548c66197677f03c4e Mon Sep 17 00:00:00 2001 From: dolonet Date: Fri, 10 Apr 2026 10:12:39 +0000 Subject: [PATCH 1/3] Add docker-compose example with HAProxy SNI router Turnkey deployment: HAProxy on :443 peeks at the TLS SNI and routes Telegram clients to mtg while forwarding everything else (including DPI probes) to a real Caddy web server with automatic HTTPS. This is the setup recommended in BEST_PRACTICES.md, packaged so that operators can clone and run it with minimal configuration. Refs: #458 --- contrib/sni-router/Caddyfile | 19 ++++++ contrib/sni-router/README.md | 83 +++++++++++++++++++++++++++ contrib/sni-router/docker-compose.yml | 53 +++++++++++++++++ contrib/sni-router/haproxy.cfg | 47 +++++++++++++++ contrib/sni-router/mtg-config.toml | 13 +++++ contrib/sni-router/www/index.html | 5 ++ 6 files changed, 220 insertions(+) create mode 100644 contrib/sni-router/Caddyfile create mode 100644 contrib/sni-router/README.md create mode 100644 contrib/sni-router/docker-compose.yml create mode 100644 contrib/sni-router/haproxy.cfg create mode 100644 contrib/sni-router/mtg-config.toml create mode 100644 contrib/sni-router/www/index.html diff --git a/contrib/sni-router/Caddyfile b/contrib/sni-router/Caddyfile new file mode 100644 index 0000000..9621307 --- /dev/null +++ b/contrib/sni-router/Caddyfile @@ -0,0 +1,19 @@ +{ + # Caddy listens on 8443 behind HAProxy, which passes raw TLS through. + # Caddy terminates TLS itself and auto-obtains a Let's Encrypt certificate. + # + # If your domain's DNS already points to this server, ACME HTTP-01 challenge + # works through the HAProxy http frontend (:80 → redirect). For DNS-01 + # or other ACME methods, see https://caddyserver.com/docs/automatic-https +} + +{$DOMAIN}:8443 { + tls { + # Use the ACME HTTP-01 challenge on port 80. + # HAProxy forwards :80 as HTTP, so Caddy can answer the challenge + # if you add an acl exception in haproxy.cfg (see README), or use + # DNS-01 instead. + } + root * /srv + file_server +} diff --git a/contrib/sni-router/README.md b/contrib/sni-router/README.md new file mode 100644 index 0000000..2ddcede --- /dev/null +++ b/contrib/sni-router/README.md @@ -0,0 +1,83 @@ +# 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 +``` + +## ACME (Let's Encrypt) notes + +Caddy needs to answer the ACME HTTP-01 challenge on port 80. The +default `haproxy.cfg` redirects all `:80` traffic to HTTPS. If Caddy +cannot obtain a certificate, either: + +1. Temporarily stop HAProxy, let Caddy bind `:80` directly for the + initial certificate, then start the full stack; or +2. Use DNS-01 validation in the Caddyfile (requires a DNS provider + plugin); or +3. Add an HAProxy ACL that passes `/.well-known/acme-challenge/` + requests to the Caddy backend instead of redirecting. + +## 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 0000000..6216a00 --- /dev/null +++ b/contrib/sni-router/docker-compose.yml @@ -0,0 +1,53 @@ +# 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: + - "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 0000000..4762074 --- /dev/null +++ b/contrib/sni-router/haproxy.cfg @@ -0,0 +1,47 @@ +# 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 — redirect to HTTPS ------------------------------------------- + +frontend http + bind *:80 + mode http + 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 + server mtg mtg:3128 + +backend web + server web web:8443 diff --git a/contrib/sni-router/mtg-config.toml b/contrib/sni-router/mtg-config.toml new file mode 100644 index 0000000..8ce9291 --- /dev/null +++ b/contrib/sni-router/mtg-config.toml @@ -0,0 +1,13 @@ +# 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" + +[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 0000000..97f105d --- /dev/null +++ b/contrib/sni-router/www/index.html @@ -0,0 +1,5 @@ + + +Welcome +

It works!

Replace this with your own content.

+ From d0412b21f6a5a5033de244d3eb57017558c77cec Mon Sep 17 00:00:00 2001 From: dolonet Date: Fri, 10 Apr 2026 10:50:26 +0000 Subject: [PATCH 2/3] Fix ACME HTTP-01 passthrough in HAProxy config Add an ACL that routes /.well-known/acme-challenge/ requests on :80 to Caddy instead of redirecting to HTTPS, so Let's Encrypt certificate issuance works out of the box. Also simplify Caddyfile to use Caddy's http_port/https_port directives. --- contrib/sni-router/Caddyfile | 18 +++++------------- contrib/sni-router/README.md | 13 +++---------- contrib/sni-router/docker-compose.yml | 1 + contrib/sni-router/haproxy.cfg | 11 ++++++++++- 4 files changed, 19 insertions(+), 24 deletions(-) diff --git a/contrib/sni-router/Caddyfile b/contrib/sni-router/Caddyfile index 9621307..426a3ad 100644 --- a/contrib/sni-router/Caddyfile +++ b/contrib/sni-router/Caddyfile @@ -1,19 +1,11 @@ { - # Caddy listens on 8443 behind HAProxy, which passes raw TLS through. - # Caddy terminates TLS itself and auto-obtains a Let's Encrypt certificate. - # - # If your domain's DNS already points to this server, ACME HTTP-01 challenge - # works through the HAProxy http frontend (:80 → redirect). For DNS-01 - # or other ACME methods, see https://caddyserver.com/docs/automatic-https + # 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 } -{$DOMAIN}:8443 { - tls { - # Use the ACME HTTP-01 challenge on port 80. - # HAProxy forwards :80 as HTTP, so Caddy can answer the challenge - # if you add an acl exception in haproxy.cfg (see README), or use - # DNS-01 instead. - } +{$DOMAIN} { root * /srv file_server } diff --git a/contrib/sni-router/README.md b/contrib/sni-router/README.md index 2ddcede..19b99b1 100644 --- a/contrib/sni-router/README.md +++ b/contrib/sni-router/README.md @@ -45,16 +45,9 @@ docker compose exec mtg mtg access /config/config.toml ## ACME (Let's Encrypt) notes -Caddy needs to answer the ACME HTTP-01 challenge on port 80. The -default `haproxy.cfg` redirects all `:80` traffic to HTTPS. If Caddy -cannot obtain a certificate, either: - -1. Temporarily stop HAProxy, let Caddy bind `:80` directly for the - initial certificate, then start the full stack; or -2. Use DNS-01 validation in the Caddyfile (requires a DNS provider - plugin); or -3. Add an HAProxy ACL that passes `/.well-known/acme-challenge/` - requests to the Caddy backend instead of redirecting. +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 diff --git a/contrib/sni-router/docker-compose.yml b/contrib/sni-router/docker-compose.yml index 6216a00..a8198dd 100644 --- a/contrib/sni-router/docker-compose.yml +++ b/contrib/sni-router/docker-compose.yml @@ -44,6 +44,7 @@ services: - caddy_data:/data - ./www:/srv:ro expose: + - "80" - "8443" environment: DOMAIN: ${DOMAIN:-example.com} diff --git a/contrib/sni-router/haproxy.cfg b/contrib/sni-router/haproxy.cfg index 4762074..dabc8f9 100644 --- a/contrib/sni-router/haproxy.cfg +++ b/contrib/sni-router/haproxy.cfg @@ -20,11 +20,16 @@ defaults timeout client 60s timeout server 60s -# --- HTTP :80 — redirect to HTTPS ------------------------------------------- +# --- 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 ------------------------------------------- @@ -45,3 +50,7 @@ backend mtg backend web server web web:8443 + +backend web_acme + mode http + server web web:80 From 170346bb7468e7b0af49ba4964e3b49b06e91686 Mon Sep 17 00:00:00 2001 From: dolonet Date: Mon, 13 Apr 2026 07:58:16 +0000 Subject: [PATCH 3/3] Pass real client IPs through with PROXY protocol v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, mtg and Caddy see HAProxy's container IP for every connection, which breaks meaningful logging, abuse handling, and any IP-based blocklist logic. HAProxy sends a PROXY protocol v2 header on its TCP backends; mtg enables proxy-protocol-listener, and Caddy wraps :8443 with a proxy_protocol listener before tls. The :80 path (ACME HTTP-01 passthrough) is unchanged — client IP there is not useful and HAProxy's http mode already adds X-Forwarded-For if anyone wants it. Requested in https://github.com/9seconds/mtg/pull/462 review. --- contrib/sni-router/Caddyfile | 19 +++++++++++++++++++ contrib/sni-router/README.md | 13 +++++++++++++ contrib/sni-router/haproxy.cfg | 10 ++++++++-- contrib/sni-router/mtg-config.toml | 4 ++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/contrib/sni-router/Caddyfile b/contrib/sni-router/Caddyfile index 426a3ad..d3ec528 100644 --- a/contrib/sni-router/Caddyfile +++ b/contrib/sni-router/Caddyfile @@ -3,6 +3,25 @@ # 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} { diff --git a/contrib/sni-router/README.md b/contrib/sni-router/README.md index 19b99b1..0e4113d 100644 --- a/contrib/sni-router/README.md +++ b/contrib/sni-router/README.md @@ -43,6 +43,19 @@ docker compose up -d 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 diff --git a/contrib/sni-router/haproxy.cfg b/contrib/sni-router/haproxy.cfg index dabc8f9..2a18c1b 100644 --- a/contrib/sni-router/haproxy.cfg +++ b/contrib/sni-router/haproxy.cfg @@ -46,10 +46,16 @@ frontend tls default_backend web backend mtg - server mtg mtg:3128 + # 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 - server web web:8443 + # 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 diff --git a/contrib/sni-router/mtg-config.toml b/contrib/sni-router/mtg-config.toml index 8ce9291..c45046a 100644 --- a/contrib/sni-router/mtg-config.toml +++ b/contrib/sni-router/mtg-config.toml @@ -7,6 +7,10 @@ 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"