`doctor`'s checkSecretHost and the proxy-startup warnSNIMismatch each
carried their own copy of the same logic: resolve the secret hostname,
determine the server's public IPv4/IPv6 (config first, getIP fallback),
and compare the two sets.
Extract that data-gathering into runSNICheck (internal/cli/sni_check.go),
returning an sniCheckResult. The success decision stays with each caller
because the rules genuinely differ — `doctor` reports OK when any family
matches, while the startup warning requires every detected family to
match — so only the gathering is shared, not the verdict.
No behavior change: both callers produce byte-identical output and the
same return values as before.
doctor: deepen DC verification with MTProto handshake probe
Closes #494.
After a successful TCP connect, run an unauthenticated req_pq_multi ->
resPQ exchange via mtglib/dcprobe. This rejects generic listeners that
happen to bind 443 but cannot speak MTProto.
Output now shows "(rpc <rtt>)" on success; on failure the wrapped error
distinguishes "tcp connect to ...: ..." from "rpc handshake to ...: ...".
The probe runs by default — an opt-in flag would defeat the purpose,
since the existing TCP-only check is what motivated the issue.
mtglib/dcprobe: unauthenticated DC verification probe
New leaf package that performs the first step of the MTProto handshake
(req_pq_multi -> resPQ) over the existing obfuscated2 transport. No
auth_key is generated; no long-lived state is introduced. Two TL
messages, one round-trip, no new dependencies.
A generic listener cannot fake the reply because it must echo back our
random nonce in resPQ.
Used by the doctor command in a follow-up commit to distinguish a real
Telegram DC from a generic TCP listener bound to port 443.
sni-router: collapse haproxy bind to comma-separated form
Switch to one-line `bind :80,[::]:80` and `bind :443,[::]:443` per
review feedback in #522. The v6only flag was self-documentation, not
load-bearing: with SO_REUSEADDR (HAProxy's default) and bindv6only=0
the kernel routes v4 packets to the more-specific AF_INET socket
regardless. Comment trimmed to match — the v6only paragraph is gone
because v6only itself is gone.
The shorter form also scales more cleanly when adding ports later,
e.g. `bind :8080,[::]:8080` on a new line.
- Caddy allow: 127.0.0.0/8 → 127.0.0.1/32 (only loopback peer is HAProxy).
- haproxy.cfg: rewrite v6only comment to describe what it actually does
(suppresses v4-mapped accept, preventing conflict with the v4 bind),
not the symptom.
- docker-compose.yml: trim the 8-line haproxy comment to 3 lines and
defer the rationale to README. Add one-line note explaining why web
uses host port 8080 (HAProxy owns :80).
- README: condense the "Why network_mode: host" subsection. Spell out
trade-offs as a list: own-the-host-ports, Linux-only (Docker Desktop
doesn't make this layout reachable), userns-remap incompatibility.
Note that mtg-config.toml stays as-is because mtg/web remain on the
compose bridge.
sni-router: switch HAProxy to host networking for real client IPs
Bridge ingress (Docker's docker-proxy userland forwarder, Podman's
slirp4netns/pasta) rewrites the source IP of inbound connections on a
published port to the bridge gateway address. HAProxy then stamps that
gateway address into the PROXY v2 header it forwards to mtg and Caddy,
so neither backend ever sees a real client IP.
Move HAProxy into the host netns (network_mode: host) so it binds
:443/:80 directly with no NAT in the path. mtg and Caddy stay on the
compose bridge and are published on 127.0.0.1 only; HAProxy reaches
them via host loopback and PROXY v2 carries the real client IP (v4 or
v6) end-to-end.
Also accept IPv6 clients explicitly on the HAProxy frontends — `bind
*:443` is IPv4-only and missed v6 clients on hosts where the previous
example happened to "work" only because of dual-stack quirks.
Add 127.0.0.0/8 to Caddy's PROXY allow-list to cover the new loopback
hop from HAProxy. README gains a short subsection explaining the
host-mode choice and its trade-off (HAProxy occupies host :443/:80).
Diagnosed and tested by @bam80 on Fedora + Docker 29. Fixes #498.
contrib/sni-router: split MTG_SECRET assignment from envsubst in examples
`MTG_SECRET=<placeholder> envsubst < ...` was shell-broken on literal
copy-paste — bash parses `<placeholder>` as redirection from a
non-existent file. Two-line `export MTG_SECRET=...` + plain envsubst
form removes the ambiguity. Applies to README, docker-compose.yml,
and the .example header.
contrib/sni-router: render mtg-config.toml from a tracked .example
Track `mtg-config.toml.example` with `secret = "${MTG_SECRET}"`; the
rendered `mtg-config.toml` and local `.env` are gitignored, so the
secret never lands in a tracked file.
Quick start switches from "paste the secret into mtg-config.toml" to
either `envsubst < mtg-config.toml.example > mtg-config.toml` or
`cp` + hand-edit `${MTG_SECRET}` for users without envsubst.
After #502 made DOMAIN env-driven, the secret was the last hand-edit
of a tracked file in the example. Follow-up to #506.
OpenWrt firewall zones are bound to interface names. With bare podman
you can pin the static podman0 bridge into a zone, but podman-compose
creates a project-scoped network and netavark spawns a fresh bridge
(podman1, podman2, ...) per project — with no firewall rules — so
containers lose outbound access.
Mark the default network as external/name=podman to attach to the
router-managed podman0 instead.
Background: #513.
docs: mark secret as (required) in example.config.toml
Follow-up to #503, which introduced the (required) marker on bind-to.
secret is the other top-level option without a sensible default, so it
takes the same marker, on the first line of its description.
Symmetric with #504 ((default) on prefer-ipv6). Rolling the convention
out to the rest of the file is left for separate PRs.
Mirrors the proxy-protocol-listener TOML config option so simple-run can
sit behind a PROXY-protocol-emitting frontend (HAProxy, nginx stream)
without dropping back to a config file.
Discussed in #502: with this flag the compose recipe collapses to
'simple-run $BIND $SECRET --proxy-protocol-listener', keeping the
secret in the environment and no mtg-config.toml in the repo.
The hardcoded 128 KiB cap caps single-flow upload throughput at roughly
value / RTT on high-BDP links (~10 Mbit/s at 100 ms RTT), manifesting
as slow uploads through the proxy. Expose it as
[network] tcp-not-sent-lowat so operators can raise it on fast links,
while keeping the default behaviour unchanged.
sni-router: extend PROXY-protocol sync list to four pieces
mtg now also sends PROXY v2 on the fronting dial (introduced in the
previous commit via [domain-fronting].proxy-protocol = true), so the
"Real client IPs" section's sync list must include that fourth piece.
Without it, an operator who disables Caddy's PROXY listener wrapper
without also flipping [domain-fronting].proxy-protocol will leave mtg
sending an unparsed PROXY v2 prefix to Caddy on every fronted probe.
doctor: surface both public IPs in SNI-DNS mismatch message
Closes #486.
The previous message read "Hostname X is resolved to Y addresses, not
Z" with Z being either the detected IPv4 or IPv6 (whichever was set
first), which made dual-stack mismatches confusing — a hostname
resolving to v6 only on a host with v4 detected and v6 undetected
printed "not <v4>" without hinting that v6 was the missing piece.
The reworked template lists the resolved DNS records and both public
addresses (or "<not detected>" when missing) so the gap is obvious:
Hostname X resolves to "<v6>", but the proxy's public IP is
1.2.3.4 (IPv4) / <not detected> (IPv6) — none of the resolved
addresses match
Pure message change.
When the secret's domain resolves back to this server (the SNI-router
default), mtg's fallback fronting dial lands on HAProxy, the SNI
matches the secret, HAProxy routes the connection back to mtg -> loop.
Set [domain-fronting].host = "web" in mtg-config.toml so mtg dials
Caddy directly via compose-network DNS, bypassing HAProxy. Requires
mtg >= 2.4 (#480 added hostname acceptance for the fronting target).
README gains a "Fronting loop" section explaining the cause.
contrib/sni-router: align mtg-config.toml comments with $DOMAIN flow
Comments said 'Replace example.com everywhere' which became stale once
$DOMAIN drives haproxy.cfg + Caddyfile + docker-compose. Reword so the
user's mental model is: pick a domain, generate the secret with it,
put it in .env once.
contrib/sni-router: read $DOMAIN from env in haproxy.cfg
Closes #501.
Before: the SNI hostname had to be edited in haproxy.cfg, plus
DOMAIN had to be set in .env for Caddy. Two places to keep in sync.
After: DOMAIN is the single source — set it once in .env (or export),
docker-compose forwards it into both haproxy and caddy containers,
and HAProxy interpolates ${DOMAIN} into the SNI ACL at startup.
mtg-config.toml's secret still embeds the domain in its bytes
(generated once with `mtg generate-secret <domain>`), so that one
remains a one-time edit at install — the README is updated to reflect
this.
HAProxy has supported environment-variable substitution in config
strings since 1.6.
Per discussion on #494, this allows external packages (e.g. the upcoming
mtglib/dcprobe for the doctor RPC probe) to reuse the obfuscated2
transport without an internal wrapper.
No public-API change beyond the import path. The only exported names
(Obfuscator, its two methods, and the Secret field) were already
exported within the package.
Deprecate "ip" in favour of "host" for domain fronting
Per review on #480: warn-and-ignore for the IP-shaped paths,
mirroring the net.Dialer.DualStack precedent — a config that
sets only "ip" will warn at startup and effectively disable
domain-fronting until the user switches to "host".
- mtglib.ProxyOpts: add DomainFrontingHost; mark DomainFrontingIP
Deprecated and warn-and-drop in NewProxy.
- internal/config: GetDomainFrontingHost returns only
[domain-fronting].host; deprecated keys are no longer used to
derive the dial target. runProxy logs a startup warning per
deprecated key that is set.
- internal/cli: add --domain-fronting-host; --domain-fronting-ip
flag is parsed only so the runtime warning can fire.
- internal/cli/doctor: redirect the existing 2.3.0 entry at "host"
and add a 2.4.0 entry for [domain-fronting].ip.
- example.config.toml: mark # ip = ... as deprecated.
doctor: use WaitGroup.Go and recover panics in DC probes
Address review feedback on #485:
- switch to sync.WaitGroup.Go (Go 1.25+) for the per-DC goroutine
- recover panics inside the goroutine and record them as that DC's
error, so a single panicking probe no longer crashes the whole
doctor run and the remaining DCs still report their results