Repository navigation
FAQ
Short answers to questions that come up repeatedly, each with a link to the page that carries the detail. If your question is "why is it doing this right now", start at Troubleshooting instead.
- Transports and secrets
- Networking
- Running it
- Security
- About the project
No. A secret is sixteen key bytes. The ee prefix, the dd prefix and the bare 32-hex form are three spellings of the same key, and the prefix only tells the client which door to knock on. Nothing is per-listener, so an existing user keeps one name, one quota bucket and one set of counters whichever way they arrive.
mtg-proxy access prints each user's key already spelled the way each configured socket expects:
mtg-proxy access /etc/mtg-proxy/config.tomlNew in v1.3.0: that output carries a listeners array with a ready-to-use link per user per accepted form, alongside the usual ipv4/ipv6 FakeTLS links and the web object. A loopback listener gets no links and says why — that is what a WEB relay backend is supposed to look like. See Connection-Types.
Yes. The same key is valid on bind-to (FakeTLS), on an obfuscated2 [[listener]] and through the WEB relay simultaneously. All of those connections attribute to the same [secrets] name: one connections count, one bytes_in/bytes_out pair, one quota_used. Handing someone a dd link does not create a second account. See Multi-User-and-API.
Telegram Desktop 7.1.2 or later, and nothing else today. 7.1.0 and 7.1.1 had a WebView2 bug that broke some WEB proxies on Windows, so 7.1.2 is the floor.
Be aware of how narrowly this has been exercised: Telegram Desktop 7.1.2 on Windows, end to end through Caddy. macOS (WKWebView) and Linux (WebKitGTK) are different engines running the same page and have not been through the acceptance checklist in contrib/web-relay/README.md. The tg://webproxy deep link is emitted for convenience, but its parameter names are copied from tg://proxy and are not confirmed against a real client — hand people the hostname and secret for the client's own two fields. See WEB-Proxy-Setup.
Because it is the ee spelling. A WEB-capable client refuses an ee secret before it opens a socket, so there is no FakeTLS here at all — the TLS on that hostname is real. WEB uses the dd form of the same sixteen key bytes, which is what the web object from mtg-proxy access prints. Nothing is re-issued; only the spelling changes.
Because a socket that answers obfuscated2 is fingerprintable as an MTProto proxy. There is no TLS to hide inside and no cover site to fall back to — the listener cannot domain-front a probe, because relaying a TLS alert back out of a plaintext port would be worse than a reset. public = true is your acknowledgement of that, and it is required on any non-loopback bind-to in a [[listener]]. It also arms handshakes-per-ip, which is left off on loopback because every connection there presents 127.0.0.1 and one shared bucket is a self-DoS.
bind-to at the top level serves FakeTLS (ee) only, and always will. There is deliberately no key that makes it accept dd or plain; that needs a separate stanza and a separate socket. See Connection-Types.
Yes, on both sides — they are separate settings.
Inbound, bind-to takes a literal IP (hostnames and * are rejected). "[::]:443" is dual-stack on Linux, macOS, FreeBSD and Windows, or you can list both families:
bind-to = ["0.0.0.0:443", "[::]:443"]Outbound to Telegram, prefer-ip has four values:
| value | meaning |
|---|---|
prefer-ipv6 |
both families, IPv6 first (the default) |
prefer-ipv4 |
both families, IPv4 first |
only-ipv6 |
IPv6 only |
only-ipv4 |
IPv4 only |
Set public-ipv6 if the address the outside world sees is not on a local interface — it is what mtg-proxy access builds links from, what mtg-proxy doctor checks the SNI-to-DNS match against, and, with an ad-tag set, what the middle-proxy handshake advertises. See Configuration.
Yes, in two tables. [network.timeout] takes tcp, http, idle and handshake; [network.keep-alive] takes disabled, idle, interval and count.
[network.timeout]
idle = "5m"
handshake = "10s"
[network.keep-alive]
disabled = false
idle = "15s"
interval = "15s"
count = 9Those two timeout values are the built-in defaults; the keep-alive numbers fall back to the Go runtime's own behaviour when you leave them unset. None of this hot-reloads — only [secrets], ad-tag, [secret-ad-tags] and [secret-limits] do, so a timeout change needs a restart. The WEB relay's own lifetimes (a two-minute bootstrap TTL, a two-minute reconnect grace) are compiled constants and are not configurable; nothing about them is negotiated with the client. See Configuration.
Nothing in this repository is tested behind a CDN, and no configuration for one ships. Treat it as unsupported and read the constraints before you try:
- FakeTLS on
bind-tois not HTTP. The client completes that TLS handshake with the proxy itself, so anything that terminates TLS in front of it breaks that path outright. - The WEB relay is genuine HTTPS with a WebSocket, so a reverse proxy in front is the intended shape — but the relay learns the browser's address only from a PROXY v2 header, or from the right-most
X-Forwarded-Forentry when the peer is loopback. There is no fallback to the socket's own peer, on purpose: behind a front proxy that address is127.0.0.1for every user alive, and silently attributing everyone to the loopback stops the blocklist matching and the per-IP limits counting with nothing in a log to say so. - Whoever terminates TLS for the WEB hostname sees the live session bearer, which travels inside
Sec-WebSocket-Protocol. That is also why the tested Caddy vhost discards its access log. - That hostname has to be a single gateway. A CDN error page, a cached object or a split vhost hands a prober a difference between two components' answers, which is exactly the distinguisher the design removes.
WEB-Proxy-Setup has the chain that has actually been run: HAProxy to Caddy to relay to proxy, with the client's address carried across all four hops.
Two ways, neither of which restarts the proxy. Add the secret to [secrets] in the config file and ask for a reload:
curl -X POST http://127.0.0.1:9090/reloadOr add it directly through the API:
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"name":"carol","secret":"ee367a…"}' http://127.0.0.1:9090/secretsBoth go through the same atomic swap. The new secret works immediately; every other user keeps their connection and their counters. Only a secret that was removed or re-keyed has its connections closed. API mutations are in-memory only, so write the file too — a later reload or a restart re-reads it and overrides them.
If you also run the WEB relay, it is a second process and does not share that listener — but it watches the file, and applies a changed [secrets] table by itself within a few seconds of the write. Nothing has to be sent. To apply it immediately rather than on the next look:
systemctl reload mtg-proxy-web-relayWhat the relay cannot see is a change made through the API and never written to the file. That user's WEB link is answered by the cover site — which, from their side, looks exactly like the censorship the relay exists to get around. Write the file. See Multi-User-and-API.
Every live WEB session ends. Session state is held in memory only, so a restart drops all of it; clients then run their own retry ladder, reload the bridge page and bootstrap a new session. Links do not change — the signing key lives on disk and the capability is derived from the hostname — so nothing has to be re-issued.
A reload — the automatic one when the file changes, or a SIGHUP — is the gentle path: it swaps the profile set and leaves live sessions alone. Anything else that changed under [web], or the backend listener's proxy-protocol flag, is refused whole with "restart required" rather than half-applied, and an unreadable or invalid file keeps the running set. Windows builds have no SIGHUP; the file watcher is the only trigger there. Rotating token-key invalidates every live session by design. See WEB-Proxy-Setup.
Yes, from v1.3.0, on a socket of its own:
[web]
stats-bind-to = "127.0.0.1:8100"curl http://127.0.0.1:8100/statsIt reports sessions, streams, bytes, reloads and why requests were refused. Empty is the default, which means no stats server. The address must be loopback and the relay refuses to start otherwise: this endpoint answers, with no credential at all, how many sessions are live and how many bytes have moved — the exact traffic analysis this transport exists to deny. It is also deliberately not a path on the public origin, where anything without a credential the relay minted is answered by the cover site. See Monitoring.
Yes. [defense.anti-replay] keeps a stable bloom filter of connection fingerprints, so a byte-flow repeated by a prober — immediately or hours later — does not get the same answer twice. On FakeTLS the fingerprint is the ClientHello session ID, and a repeat is routed to the fronting domain exactly like a wrong secret. On an obfuscated2 listener it is checked only after a secret matched (unauthenticated probes writing into a stable filter would evict the FakeTLS entries the public listener depends on), and a replayed frame takes the same exit as every other refusal, so it is not distinguishable from one.
One thing worth knowing: the default is off. example.config.toml ships with it on, but a minimal config that omits the block gets no cache at all. Turn it on explicitly:
[defense.anti-replay]
enabled = true
max-size = "1mib"
error-rate = 0.001The filter is approximate by design — max-size and error-rate trade memory against false positives. See Security.
No. Bind it to loopback:
api-bind-to = "127.0.0.1:9090"The API can add, re-key and delete secrets and set the ad-tag, so reaching it is equivalent to owning the proxy's users. If it must be reachable from elsewhere, set api-token and every endpoint requires Authorization: Bearer <token>; missing or wrong tokens get a 401.
A token is authentication, not encryption. The API server speaks plain HTTP — there is no TLS anywhere in it — so a token that crosses an untrusted network crosses it in the clear, along with every secret in the response. Put a TLS terminator, an SSH tunnel or a WireGuard link in front of it. See Security and Multi-User-and-API.
Only if you switch it on, and it is off by default on purpose:
track-client-ips = trueWith it on, /stats and /secrets add connected_ips (the distinct addresses holding a live connection right now) and last_ip (the most recent admitted connection's address) per user. This is a circumvention proxy: the people using it are frequently the reason it exists, and your answer to "who connected from where" is also the answer available to whoever ends up with the server. connected_ips adds no retention — those addresses are already held for the life of the connection and vanish with it — but last_ip outlives the connection it came from, so a deployment that never asks for either never accumulates one. Neither is written to disk in any form; usage-state-file persists quota counters alone.
Behind a front proxy both report what the PROXY header carried, which is the client's real address. Think about it before enabling it, and see Monitoring and Security.
Yes, a fork of 9seconds/mtg. Everything that makes it hard to block is upstream's and behaves as upstream documents it: FakeTLS, domain fronting, the anti-replay cache, the doppelganger traffic-shape mimicry, SOCKS5 chaining, IP blocklists and allowlists, Prometheus and statsd.
What this fork adds is the multi-user layer on top — named secrets with per-user fronting hosts, per-user stats, a secrets and ad-tag management API, hot reload without dropping users, the sponsored-channel ad-tag that upstream v2 dropped, fair-share connection throttling, per-user quotas with expiry and a disable switch, the obfuscated2 [[listener]] stanza, the WEB relay, a public-IP override and Docker-style environment variables. See Home.
mtg-proxy runs as a managed child process of the Penhoon panel, one process per MTProto inbound, each serving many client secrets. Four things are frozen contracts, and internal/config/panel_contract_test.go pins the first of them from this side:
-
The rendered config shape.
bind-to,api-bind-to,api-token,[throttle], a loopback[[listener]]carrying nopublickey, and[secrets]last. Section order is load-bearing: in TOML every key after a header belongs to that table, so anything written below[secrets]is parsed as a secret. -
The management API.
PUT /secretsandPOST /secrets/{name}/reset-quotain particular, with a bearer token. The panel writes[secrets],[secret-ad-tags]and[secret-limits]to the file first and calls the API second, because API mutations are memory-only. -
The
featuresarray on/stats.client-ips,faketls,listeners,quota-reset,secret-modes,secrets-api,web-relay. These strings are public API — never renamed or removed, only appended — so a panel can tell an old binary from a new one without parsing version numbers. -
Release asset names.
mtg-proxy-<version>-linux-<arch>.tar.gz, with an inner directory of the same name holding a binary named exactlymtg-proxy, plusmtg-proxy-<version>-checksums.txt.
The WEB relay is a separate process, and nothing in this repository starts it for you — a WEB deployment is wired up by hand or by whatever supervises the proxy. The relay watches the config file precisely so that whoever is the source of truth for the secret set has nothing to tell it: write the file and the relay follows. See Multi-User-and-API and Installation.
Start here
Running it
Operating it
When it goes wrong
Repository · Releases · Discussions · Report a vulnerability