Skip to content

Setup Without SNI Router

dolonet edited this page Jun 11, 2026 · 2 revisions

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)

Which setup do I want?

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.

Version note

[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).

Files

docker-compose.yml

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:

mtg-config.toml

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

Caddyfile

{
	# 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
}

Quick start

# 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.toml

Verifying

Same checks as the SNI-router test plan:

  • https://YOUR_DOMAIN in a browser → your web page, real Let's Encrypt certificate.
  • http://YOUR_DOMAIN → redirects to https://YOUR_DOMAIN (no :8443 in 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.

Caveats

  • Linux host only. network_mode: host on 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. Disable userns-remap for this stack or lower net.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.

Clone this wiki locally