Repository navigation
Releases: Arman2122/mtg-proxy
Release list
v1.9.0
Two kinds of work since v1.8.0. A panel can now govern a user the way it sells them: a speed for each direction, which of their devices is active, and a kick for one device. And the ee door gets its speed back without giving up its disguise: an opt-in way to pace records that downloads at the speed of the link, an ad-tag leg rebuilt to stop costing a system call per 16 bytes, replay protection on by default, and a fake certificate sized like the real cover site's.
Do I need to do anything?
No. Nothing is renamed or removed, and release asset names are unchanged. Three defaults change, and each has a switch:
- Replay protection is on unless
[defense.anti-replay] enabled = false. Until now it was off unless the config said otherwise, so every panel-written config, and any config without that table, ran without it. - The proxy fetches
https://<host>/from each cover site itseesecrets name, once at startup and then every 6 hours, to size its fake certificate. To stop it, set[defense.doppelganger] scout-covers = false. - An idle
eeconnection holds about 9 KiB more memory (77 KiB, up from 68), because the record reader's buffer grew from 4 KiB to 16 KiB.
ee pacing does not change unless you ask: shaping defaults to "record", exactly as before.
For a panel: governing a user
- Speed per direction.
speed-upandspeed-downin[secret-limits](speed_upandspeed_downon the API) are each their own bucket, on top of the combinedspeedwhen that is set too. They read back asspeed_limit_upandspeed_limit_down, andthrottled_mscounts time held to any of the three. - Which device is active.
connected_ips_last_seensits besideconnected_ipsand gives the second of each address's latest byte. - Kick one device.
POST /secrets/{name}/kickcloses the user's streams from the addresses you name, matched at the /64 the address limit counts, and leaves their other devices connected. Naming a place the user is not at kicks nothing; it is not an error. - Feature strings on
/stats:speed-directions,client-ip-activityandkickfor the above, plusquota-ceiling(the v1.8.0 data-path quota) andsecret-read(GET /secrets/{name}), which had none. mtg-proxy run --check <config>parses and validates a file exactly asrundoes, refuses any key this binary does not know, and exits without binding a socket. It answers "will this build read every key I write?" before you swap a binary in.
ee at full speed: shaping = "burst", opt-in
Until now the ee door waited a delay drawn from a web page's timing before every 16 KiB TLS record. That capped a download near 20 Mbit/s per connection and added milliseconds to every exchange. [defense.doppelganger] shaping = "burst" waits once when a connection wakes from a second of quiet, then sends records back to back, the way a server streams a response. On a 2-vCPU Linux box over loopback:
record (default) |
burst |
|
|---|---|---|
| Download, one connection | 2.1–2.3 MiB/s | 462–506 MiB/s (dd: 388–476) |
| Round trip, p99 | 84–104 ms | 0.17–0.26 ms |
Each record's size is drawn the same way in both modes. burst sends more short records, because less has queued when nothing waits before each one. Timing and record sizes are things a censor can see, so burst stays opt-in until it has run on real networks without connections being blocked.
Both modes now bound what a slow client can pile up. At most 256 KiB waits for a client that stops reading; the buffer holding it peaks near 512 KiB, and is let go after a quiet second. Past that bound the proxy stops reading from Telegram instead of growing its memory.
Uploads on ee are 18–32% faster and allocate 72% less: the record reader now reads several records per call, straight into the caller's buffer.
Ad tags at full speed
The leg between the proxy and Telegram's middle proxies, which every ad-tagged secret uses, read and decrypted 16 bytes per system call and allocated once per AES block. Now:
- One read takes about 16 KiB, about 900 times fewer system calls for downloads.
- Each packet allocates nothing, or one small buffer, down from about 1,000 allocations.
- Throughput is 4× to 42× higher: about 343 MiB/s for 16 KiB answers, up from 8.
The bytes on the wire are identical. They are pinned by golden files captured before the rewrite, and an ee client with an ad tag is tested end to end through a real middle-proxy exchange.
A fake certificate sized like the real one
The ee handshake's single application-data record stands in for the real server's certificate chain, so its size should be the cover site's. It was sized from a generic 2,500–4,700 bytes unless [defense.doppelganger] urls was set, and even then one size served every host. Now, with no urls, the proxy measures each cover host its ee secrets name. It dials the address domain fronting would use, so a host that resolves back to this server is not measured against itself. Each client's handshake is then sized like the site in that client's SNI. A host is measured as soon as a secret names it, and uses the generic size until then. Only the size is taken from the site; record timing keeps the built-in model.
Fixes
eestreams lost the end of an answer. When Telegram answered and hung up, the FIN overtook the records still waiting to be paced out. On Linux aneeclient could receive as little as 3–13% of a 512 KiB answer. The rest is now delivered before the FIN, bounded by 5 s for a client that stopped reading. dd and plain were not affected.- The relay closed the wrong halves when a stream ended. Two arguments were swapped since the mtg-multi import, so the FIN reached the wrong peer first. On Linux it was masked; on Windows streams hung until the idle timeout. A stream now ends by delivering, then closing.
- The doppelganger's timing fit. With
urlsconfigured, one measured gap of zero — records that arrive in one read — pinned the fitted model to waits of minutes, or to negative waits. Zero gaps are now left out, a degenerate fit is refused and the current model kept, and no wait exceeds one second.
Build
- Go 1.26.8 everywhere: the image, the release tarballs and CI. 1.26.7 fixed
net/http, which the management API and the WEB relay serve. golang.org/x/cryptov0.56.0. Three advisories against v0.53.0 are fixed; none was reachable from this code.- golangci-lint 2.13.2, the version the lint config is written for.
Compatibility
Additive in shape:
- No config key or API field is renamed or removed, and release asset names are unchanged.
- The default
accept-modesis still["ee"], and the defaultshapingis still"record". - New keys:
[defense.doppelganger] shapingandscout-covers, andspeed-upandspeed-downin[secret-limits]. - New fields:
speed_limit_up,speed_limit_downandconnected_ips_last_seen, and five feature strings.
Different in behaviour:
- Replay protection is on without configuration. About one genuine connection attempt in a thousand is taken for a replay and sent to the cover site, and the client retries.
- The proxy fetches its cover sites' front pages every 6 hours.
- Idle
eeconnections hold about 9 KiB more.
Full Changelog: v1.8.0...v1.9.0
v1.3.0
The relay can be watched, the API answers in one call, and the repository has a wiki
The theme is observability. Everything here exists because an operator running this could not see enough of it.
The WEB relay reports on itself
The relay reported nothing. The proxy beside it has served /stats since before any of this; the relay — the internet-facing half, the one terminating every session and carrying every byte — answered four request paths and published none of it. The first sign of trouble was a user saying so.
Enable it with one key:
[web]
stats-bind-to = "127.0.0.1:8100"curl http://127.0.0.1:8100/statsSessions, streams, bytes in each direction, profile names, reload outcomes, and a breakdown of why requests were refused:
"refused": {
"cover_served": 1841,
"not_found": 3,
"bad_token": 0,
"session_limit": 0
}The breakdown is the point. At three in the morning, cover_served climbing (clients presenting capabilities this relay does not recognise — usually a hostname or secret-set mismatch after a reload) and session_limit climbing (capacity) are completely different problems, and a single refusal count cannot tell them apart. Reload outcomes are counted apart from successes for the same reason: you have to be able to tell "my SIGHUP did nothing" from "my SIGHUP was never delivered".
It is a second socket, deliberately, and it must be loopback. The relay refuses to start otherwise. Its public origin is published to the internet by a front proxy and holds one invariant: anything not carrying a credential the relay minted is answered by the cover site, on every path. A real endpoint there would break that — a 401, a JSON body, even a distinctive response time says this hostname is not the website it claims to be, and token-gating does not help because the gate itself is the fingerprint. And this endpoint answers, with no credential at all, how many sessions are live and how many bytes have moved: exactly the traffic analysis the transport exists to deny.
Off by default.
GET /secrets answers the whole question
/secrets described a user's configuration and said nothing about what that user was doing, so a panel rendering one person's detail page had to fetch the whole of /stats and filter it. It now carries connections, last_seen, connected_ips and last_ip — the same numbers /stats reports, from the same counters, with the addresses gated on track-client-ips exactly as /stats gates them.
connections and last_seen are not gated: they are not addresses, and withholding them would break every panel that shows an online marker without protecting anyone.
The two endpoints are built by different code that happens to read the same counters, and nothing forced them to agree. There is now a test that they do.
A wiki
Twelve pages, written against the code rather than from memory: installation, a five-minute quick start, a complete configuration reference, the three connection types, a full WEB proxy walkthrough with a working Caddyfile, the management API, limits and quotas, monitoring, the security model, troubleshooting by symptom, and an FAQ.
It answers things that were previously only discoverable by reading example.config.toml — that replay protection is a stable Bloom filter checked on both doors, that keepalives and timeouts are fully configurable under [network], that IPv6 has four prefer-ip modes.
The source lives in docs/wiki/ so corrections can arrive as pull requests, which a wiki cannot accept.
The repository is contributable now
Issue forms rather than templates, so a report arrives with the version, the entry path and a config that has already been asked to have its secrets removed. Blank issues are off, and the chooser sends questions to Discussions — now enabled — and vulnerabilities to a private advisory. A pull-request checklist specific to this codebase's actual traps, CONTRIBUTING.md, and Contributor Covenant 2.1.
The browser harness finally runs
The bridge page has a test that runs it in a real browser engine. It is the only test that can catch a page correct by every static reading that does nothing inside a client's WebView. Nothing ran it — no workflow installed Node or a browser, so it skipped silently on every CI run, and a skip on a dashboard is indistinguishable from a pass. Through v1.1.0 and v1.1.1 it protected nothing, and the mistyped RFC 6455 accept constant it exists to catch shipped twice before a real client found it.
There is now a job that installs the harness and runs it, and the test turns its skips into failures when that job asks it to. It passed on its first real run.
Also
mtg-proxy accessgained per-listener links in v1.2.0; the wiki documents them properly now.- The concurrency test went from 24 streams to 200 across 8 users, and asserts the goroutine count returns to its baseline — a correct counter says nothing about whether the goroutines behind it went away.
- Playwright pinned to 1.62.1.
Upgrading
Additive. No config key renamed or removed, no release asset renamed, default accept-modes still ["ee"], and both new surfaces default to off — so a deployment that changes nothing behaves exactly as it did on v1.2.1. features on /stats is unchanged from v1.2.0.
To start watching a relay, add stats-bind-to under [web] and restart it. That key is structural: a reload will refuse it, because moving a bound socket needs a restart and a reload that accepted a new address would report success while the counters answered at the old one.
Full Changelog: v1.2.1...v1.3.0
v1.2.1
A flaky test, fixed
The binaries are functionally identical to v1.2.0. No product code changed, and a deployment running v1.2.0 has no reason to reinstall. This tag exists so that the newest release is one whose full gate is green.
Two of the concurrency tests added in v1.2.0 tore down while a connection was still completing its Telegram handshake, and then counted the interrupted read as a proxy failure. A stream registers in admit, which runs before that handshake finishes, so waiting on the connection registry alone was not enough to know every stream was ready.
Locally the gap between the two is too small to hit; on a loaded CI runner it is wide enough that the Test job went red on the first run after the tests landed. The wait now covers both conditions, and the failure assertion moved ahead of teardown — where a failed handshake can only mean the proxy faulted, rather than that the test closed the socket underneath it.
The thing the tests were checking was correct throughout. They were wrong about when a stream is ready, not about what it does.
Everything from v1.2.0 still applies
Per-user client addresses on /stats behind track-client-ips (off by default), a tg:// link for every configured [[listener]] in the wire form that socket accepts, and release pages built from the tag annotation. See v1.2.0 for the detail.
Full Changelog: v1.2.0...v1.2.1
v1.2.0
Client addresses on /stats, and a link for every door
Two additions an operator running more than one entry path has been missing, plus release pages that finally say what changed.
Per-user client addresses
GET /stats can now report where a user's connections are coming from:
"alice": {
"connections": 2,
"connected_ips": ["198.51.100.7", "203.0.113.44"],
"last_ip": "203.0.113.44"
}connected_ips is the set of distinct addresses holding a live connection right now — sorted, deduplicated, and derived from the connection registry rather than counted separately, so it cannot drift from the connections gauge beside it. last_ip is the most recent admitted connection's address, and outlives that connection.
This is off by default, and turning it on is a decision worth making deliberately. This is a circumvention proxy: the people using it are frequently the reason it exists, and an operator's answer to "who connected from where" is also the answer available to anyone who ends up with the server. connected_ips adds no retention — those addresses are already held for the life of the connection and go with it — but last_ip outlives its connection, so a deployment that never asks accumulates nothing. Neither is written to disk in any form: usage-state-file carries quota counters alone, so no address survives a restart.
Enable it with track-client-ips = true. Behind a front proxy both fields report what the PROXY header carried, which is the client's real address — that is what makes them useful for a WEB deployment, where every connection otherwise arrives from 127.0.0.1.
New client-ips capability in the features array, so a panel can offer the switch without probing (the fields' absence is indistinguishable from an idle secret).
A link for every listener
mtg-proxy access described one door. It printed the ee secret against bind-to's port — precisely the form a client arriving on an obfuscated2 socket will not accept — so an operator who configured a [[listener]] had nothing to hand a user.
Each listener now gets its own block: that socket's port, and per user, per accepted wire form, a tg:// and t.me link with QR codes. The secret in each is the user's own sixteen key bytes spelled the way that socket expects, so handing someone a dd link does not create a second account — their quota bucket, counters and limits are the ones they already had. A loopback listener gets no links and says why in words, because that is what a WEB relay backend is supposed to look like.
Release notes
Every tag on this project is cut with a written account of what changed, and until now none of it reached anyone: the release step asked GitHub to generate notes, and GitHub's generated notes are a bare compare link when a release has no merged pull requests behind it — which is every release here. Four release pages said nothing but "Full Changelog".
The tag annotation is now read over the API and prepended to the generated notes. v1.1.0 through v1.1.3 have been backfilled by hand.
Testing
The plain wire form finally has a row in the shared entry cases, so every governance rule built on them — quota billing, expiry, disable, revocation, throttling — is now asserted on all three forms rather than two. It already held; nothing had asked.
New tests cover what the counters do when connections overlap: exact per-user attribution across users with deliberately uneven counts, one secret arriving through both doors counting into one bucket, and the connection registry draining completely once every stream ends, with a stats scraper running against live traffic throughout so the snapshot's locking is exercised under -race rather than against a quiet proxy.
A comment claiming CI runs the browser harness as its own job has been corrected. It never has — no workflow installs Node or a browser, so that test skips in CI every time and the job stays green. The false claim was worse than the gap: it is why the accept-key defect fixed in v1.1.2 was hunted through every layer except the one that had never actually executed.
Upgrading
Additive. No config key renamed or removed, no release asset renamed, default accept-modes still ["ee"], and track-client-ips defaults to off — so a deployment that changes nothing behaves exactly as it did on v1.1.3, and its /stats document gains one entry in features and nothing else.
Full Changelog: v1.1.3...v1.2.0
v1.1.3
Documentation
The binaries are functionally identical to v1.1.2. No code changed and there is no reason to upgrade a running deployment.
- The README now opens with a table showing the three ways a client reaches the proxy — FakeTLS, obfuscated2, WEB — what each looks like on the wire, and the fact that all three share one set of users.
- The WEB relay's SIGHUP reload is documented next to the proxy's own reload endpoint, including what it refuses rather than half-applies, and why a failed reload keeps the running set instead of emptying it. It was previously only in the deployment guide, so a reader would reasonably have concluded the relay needs a restart to pick a new user up.
- The WEB transport section now states what it has actually been tried against — one client, one platform — rather than leaving "supported" to be read as "exercised".
- Build, release, Go and licence badges.
The downlink benchmark added just after v1.1.2 was removed. It measured loopback in one process with no TLS and no front proxy, which is a number about a machine rather than about a deployment.
Full Changelog: v1.1.2...v1.1.3
v1.1.2
The WEB transport now works against a real Telegram client
v1.1.0 shipped the WEB proxy transport and it could not actually carry a client. Two wire-level defects, both invisible to the test suite, both found only by driving a real Telegram Desktop against a real deployment.
The WebSocket accept constant was mistyped. RFC 6455's GUID had one character rotated off the front onto the end, so every Sec-WebSocket-Accept this server produced was wrong and every browser refused the connection before onopen. The symptom was a client that connected, dropped and retried forever while the server logged a perfectly valid 101 and nothing named the cause.
Three test layers missed it: the Go test client carried its own copy of the same mistyped constant, so both sides agreed; the "pinned vector" had been generated from that constant rather than copied from the specification; and the browser harness mocks the socket, so Chromium never validated a real handshake. curl does not check the accept key either, which is why driving the handshake by hand looked perfect. There is now a test that uses the key and answer copied verbatim out of RFC 6455 section 1.3.
The bridge did not understand the shape a real client sends. Telegram Desktop delivers messages through the onmessage slot rather than addEventListener, and wraps the payload as an object carrying an ArrayBuffer instead of passing bytes directly. The bridge now unwraps three shapes and de-duplicates across both delivery paths, first channel wins.
Verified end to end against Telegram Desktop 7.1.2 on Windows, through Caddy, with both connection types working from one config.
If you tried the WEB transport on v1.1.0 or v1.1.1 and it sat at "connecting", this is why. Install this and restart the relay — no config change.
Full Changelog: v1.1.1...v1.1.2
v1.1.1
A secret reload could leave a revoked user connected
Upgrade if you revoke secrets. A reload that removed or re-keyed a secret raced the handshake of a stream that was still setting itself up: the revocation swept the connection list before that stream had published its socket, so the socket was never closed and the user it belonged to stayed connected until they disconnected on their own.
The stream context now serialises publication and teardown under one lock, and a socket handed to a context that has already been closed is closed immediately rather than orphaned. Revocation is now the immediate disconnect it always claimed to be.
Also in this release: the SIGHUP reload path is now driven by tests the way the signal itself drives it — including one that asserts a newly added client is actually served, not merely counted — and a test fixture that could produce two colliding secrets was fixed.
No config changes. No action needed beyond installing it.
Full Changelog: v1.1.0...v1.1.1
v1.1.0
Telegram's WEB proxy transport, and the two secret forms that reach it
The headline is a new connection type. Telegram Desktop 7.1 added a WEB proxy: the client loads a page from a hostname you control, inside its own WebView, and that page carries MTProto over a WebSocket. A network in between sees a browser visiting a website over genuine TLS — there is no obfuscated handshake to fingerprint and no FakeTLS to distinguish, because the TLS is real.
Reaching it needed the rest of this release. A WEB-capable client refuses an ee secret before it opens a socket, so serving one meant teaching the proxy the two pre-FakeTLS secret forms (dd and plain) and giving them their own listening sockets.
WEB proxy transport
mtg-proxy web-relay <config>runs the relay as a second process of the same binary, reading the same config file.--checkvalidates the[web]table and exits.mtg-proxy generate-web-key <path>writes its 32-byte token signing key.- Three-tier recognition on the public origin: a client holding a capability the relay minted gets the bridge page; an authentic-but-unusable one gets a local uncacheable 404 that is never forwarded; everything else gets your cover site, on every path — including the API ones. Nothing ships as a default cover page, because a starter page many operators installed would be a better signature than anything the protocol leaks.
- Flow control is bounded in both directions, per stream and per session, with a global backstop across the process.
mtg-proxy accessprints awebobject per secret, and/statsadvertisesweb-relayinfeaturesso a panel can tell whether the binary supports it.- contrib/web-relay/ carries the deployment: the Caddy vhost, the four hops that have to carry the client's address, and an acceptance checklist.
The three secret forms
A secret is sixteen key bytes. The ee / dd / bare-hex prefix only tells the client which door to knock on, so an existing user keeps one name, one quota bucket and one set of counters whichever form they use — nothing has to be re-issued.
- New
[[listener]]config table declares extra obfuscated2 sockets, each with its ownacceptset,publicacknowledgement,proxy-protocoland unknown-secret disposal policy. bind-tois, and stays, FakeTLS-only. One port never serves both entry paths and there is no sniffing./statsreports what each listener accepts, and per-secret modes.
Reload
The relay re-reads [secrets] on SIGHUP and swaps its profile set atomically. Only the secret set is reloadable — any other [web] change is refused whole with restart required rather than half-applied, and a reload that cannot read or validate the file keeps the running set.
Fixes worth knowing about
- FakeTLS: an unauthenticated client hello was buffered before its length was bounded.
- Config: byte sizes truncated on 32-bit builds.
- API: neither HTTP server had a header read deadline.
- Obfuscation: bytes returned alongside a read error were dropped instead of decrypted; the handshake plaintext prefix was not kept on the wire.
- Proxy: a PROXY-protocol header that would not format panicked.
- Doppelganger: noise size could exceed what a TLS record can carry.
- Middle proxy: padding was declared unconditionally; padded-intermediate padding is 0-15, not 0-3.
Supply chain
Every release now publishes a checksums file, an SPDX SBOM of the module graph, and a signed build provenance attestation covering all of it — including the checksums file itself. Verify with:
gh attestation verify <file> --repo Arman2122/mtg-proxy --signer-workflow Arman2122/mtg-proxy/.github/workflows/go.yml
Upgrading
Additive only. No config key was renamed or removed, release asset names are unchanged, and the default accept-modes is still ["ee"] — an existing deployment behaves exactly as it did on v1.0.1 until you add a [[listener]] or a [web] table.
Full Changelog: v1.0.1...v1.1.0