`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.