Help us shape the future of Go! Take the 2026 Go Developer Survey

mtg-proxy

command module
v1.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 2, 2026 License: MIT Imports: 11 Imported by: 0

README

mtg-proxy

CI Release Go License

A fast, censorship-resistant Telegram MTProto proxy built to serve many users from one process.

Most MTProto proxies serve one secret. mtg-proxy gives every user their own named secret, their own fronting host, their own traffic counters and their own limits, and lets you change all of that on a running proxy without dropping a single connection. That is what makes it usable as the engine behind a panel, and it is equally happy running on its own from a TOML file.

It is a fork of 9seconds/mtg, whose FakeTLS, domain fronting, anti-replay and traffic-shape mimicry are what make the thing hard to block in the first place. See Credits.

What it adds

  • Multiple secrets — one named secret per user, each with its own fronting host.
  • Per-user stats — a JSON endpoint with live connection and byte counters.
  • Management API — add, remove, and re-key secrets and the ad-tag at runtime.
  • Hot reload — swap the secret set from the config file without dropping users.
  • Sponsored channel (ad-tag) — the promoted-channel feature, back again.
  • Connection throttling — automatic fair-share per-user connection caps.
  • Per-user quotas, speed, addresses, expiry & disable — data caps (with optional monthly reset), a speed limit, a limit on how many places a user may be at once, a validity deadline, and an on/off switch, persisted across restarts.
  • WEB proxy transport — the connection type Telegram Desktop 7.1 added: a page on your hostname carries MTProto over a WebSocket, so a network in between sees a browser visiting a website.
  • Public IP override and Docker-style environment variables.

Everything underneath — FakeTLS, domain fronting, the doppelganger traffic mimic, SOCKS5 proxy chaining, IP blocklists and allowlists, Prometheus and statsd metrics — is upstream's and behaves as upstream documents it.

How a client reaches it

Three entry paths, all sharing one set of users. A secret is sixteen key bytes; the prefix only tells the client which door to knock on, so the same person keeps one name, one quota bucket and one set of counters whichever they use.

door secret form what a network in between sees
FakeTLS bind-to, normally :443 ee… a real-looking TLS handshake to your fronting domain
obfuscated2 a [[listener]] dd… or bare hex a nondescript TCP stream, fingerprintable as an MTProto proxy — keep it on loopback
WEB your own hostname over HTTPS dd… a browser loading a page and opening a WebSocket, over genuine TLS

FakeTLS is the default and every client speaks it. WEB needs Telegram Desktop 7.1.2 or later, a hostname you control and a real certificate, and it is the only one whose TLS is not an imitation. See WEB proxy transport.

Documentation — installation, configuration reference, the WEB transport, monitoring, security and troubleshooting. Discussions for questions. Contributing if you want to send a change.

Table of contents

Quick start

Download a binary from Releases — then verify it, especially if you are packaging it for other people — or build from source (requires Go 1.26+):

git clone https://github.com/Arman2122/mtg-proxy.git
cd mtg-proxy
go build          # produces ./mtg-proxy

If you use mise, mise install && mise run build sets up the toolchain and builds the same binary.

Generate a secret for each user. The hostname is the site you front behind: it does not have to point at your server, but it should be a real, reachable HTTPS site — the classic choice is a large CDN such as storage.googleapis.com:

mtg-proxy generate-secret --hex storage.googleapis.com

Write a minimal config:

bind-to = "0.0.0.0:443"
api-bind-to = "127.0.0.1:9090"

[throttle]
max-connections = 5000

# [secrets] must be the LAST section in the global scope. In TOML, every key
# after a [section] header belongs to that table, so any top-level option
# placed below [secrets] would be parsed as a secret.
[secrets]
alice = "ee367a189aee18fa31c190054efd4a8e9573746f726167652e676f6f676c65617069732e636f6d"
bob   = "ee0123456789abcdef0123456789abcd9573746f726167652e676f6f676c65617069732e636f6d"

Run it:

mtg-proxy run /etc/mtg/config.toml

Then print the connection links and QR codes for your users:

mtg-proxy access /etc/mtg/config.toml

Verifying a download

Every release publishes, alongside the ten platform archives:

  • mtg-proxy-<version>-checksums.txt — one SHA-256 line per asset;
  • mtg-proxy-<version>-sbom.spdx.json — an SPDX SBOM of the module graph the binaries were built from;
  • a signed build provenance attestation covering every one of those files, including the checksums file itself.

Checksum — catches a truncated download, a mangled CDN object or a mismatched version. Fetch the checksums file next to the archive and run:

VERSION=1.2.3
PKG=mtg-proxy-${VERSION}-linux-amd64

curl -sfLO "https://github.com/Arman2122/mtg-proxy/releases/download/v${VERSION}/${PKG}.tar.gz"
curl -sfLO "https://github.com/Arman2122/mtg-proxy/releases/download/v${VERSION}/mtg-proxy-${VERSION}-checksums.txt"
sha256sum -c --ignore-missing "mtg-proxy-${VERSION}-checksums.txt"

--ignore-missing checks whichever listed files you actually downloaded, and exits non-zero if none of them matched.

Provenance — catches a replaced asset. The checksums file is served from the same origin as the archive, so anyone who can swap one can swap the other; only the attestation is signed. It needs the gh CLI (already present on GitHub Actions runners) and no secret:

gh attestation verify "${PKG}.tar.gz" \
    --repo Arman2122/mtg-proxy \
    --signer-workflow Arman2122/mtg-proxy/.github/workflows/go.yml

--signer-workflow is the part that matters: without it, any workflow in the repository that can mint an OIDC token would satisfy the check.

The container image carries the same attestation. Verify a released tag rather than latest — attestation started with the release below, so an older image still sitting under a floating tag has none to check:

gh attestation verify oci://ghcr.io/arman2122/mtg-proxy:${VERSION} \
    --repo Arman2122/mtg-proxy \
    --signer-workflow Arman2122/mtg-proxy/.github/workflows/docker-publish.yml

If you package mtg-proxy for others — a panel, a distribution, an installer — please run at least the checksum, and preferably the attestation, and fail the build when it does not match.

Running with Docker

The image is published to the GitHub Container Registry and ships a working default config, so it can be configured entirely from environment variables in the style of the official telegrammessenger/proxy image:

docker run -d --name mtg -p 443:443 \
    -e SECRET=00112233445566778899aabbccddeeff \
    -e SECRET_HOST=storage.googleapis.com \
    -e TAG=3f40462915a3e6026a4d790127b95ded \
    -e MTG_BIND_TO=0.0.0.0:443 \
    ghcr.io/arman2122/mtg-proxy:latest

To run with a full config file instead, mount it over the bundled default at /config/config.toml:

docker run -d --name mtg -p 443:443 \
    -v /etc/mtg/config.toml:/config/config.toml:ro \
    ghcr.io/arman2122/mtg-proxy:latest

See Environment variables for the full list.

:latest tracks the newest tagged release. Every push to main is published too, but only under its sha-<commit> tag, so latest never means "whatever landed an hour ago". Pin to :1.2.3 if you want a specific build, and verify it with the command in Verifying a download.

Running the container as a non-root user

The image runs as root, because dropping privileges by default would break two common setups on the next docker pull. If neither applies to you — and for a process parsing hostile input off port 443, it is worth checking — add --user 65534:65534:

docker run -d --name mtg -p 443:443 --user 65534:65534 \
    -v /etc/mtg/config.toml:/config/config.toml:ro \
    ghcr.io/arman2122/mtg-proxy:latest

The two things to check first:

  • The config must be readable by uid 65534. A root-owned 0600 config — a reasonable thing to do with a file full of secrets — cannot be read after the switch, and the proxy exits at startup. 0640 with a matching group, or 0644 on a file whose directory is not world-traversable, both work.
  • Binding a port below 1024 inside the container. Docker sets net.ipv4.ip_unprivileged_port_start=0 in containers by default, so MTG_BIND_TO=0.0.0.0:443 still works. Kubernetes and some other runtimes do not: there, bind a high port inside (MTG_BIND_TO=0.0.0.0:8443) and publish it as 443 outside, or set the sysctl yourself.

If you set usage-state-file, its directory must also be writable by uid 65534.

Features

Multiple secrets

Define named secrets in the config, one per user. Each name is used as the label in per-user stats, and each secret may front behind a different hostname.

[secrets]
alice = "ee367a189aee18fa31c190054efd4a8e9573746f726167652e676f6f676c65617069732e636f6d"
bob   = "ee0123456789abcdef0123456789abcd9573746f726167652e676f6f676c65617069732e636f6d"

The single-secret secret = "..." form from upstream still works and is treated as one secret named default.

Per-user stats API

Set api-bind-to to start a small HTTP server (bind it to loopback unless you also set an API token):

api-bind-to = "127.0.0.1:9090"
curl http://127.0.0.1:9090/stats
{
  "started_at": "2026-03-29T10:30:00Z",
  "uptime_seconds": 3600,
  "total_connections": 15,
  "features": ["client-ips", "faketls", "listeners", "quota-reset", "secret-modes", "secrets-api"],
  "listeners": [
    { "bind": "0.0.0.0:443", "accept": ["ee"], "public": true },
    { "bind": "127.0.0.1:2398", "accept": ["dd", "plain"], "public": false }
  ],
  "obfuscated": {
    "trials": 412,
    "misses": 7,
    "denied": 7,
    "draining": 0,
    "rate_limited": 0
  },
  "users": {
    "alice": {
      "connections": 8,
      "bytes_in": 1048576,
      "bytes_out": 2097152,
      "last_seen": "2026-03-29T11:25:30Z",
      "quota_used": 3145728
    }
  }
}

last_seen is null for a user who has never connected. The throttle object is included only when throttling is configured, and a user's quota, quota_remaining, quota_reset, expires_at, disabled, speed_limit and ip_limit fields appear only when that user has the corresponding limit set, and throttled_ms — the time a user's connections have spent held to their speed limit — active_ips — how many places they are in right now — and ip_limit_refused only once they are non-zero.

listeners and obfuscated appear only when at least one [[listener]] is configured — a deployment that serves bind-to alone sees exactly the response it saw before. listeners reports what each socket accepts; obfuscated counts the trial-decryption entry (trials connections tried, misses matched by no secret, denied refused for any reason, rate_limited turned away by the per-IP budget, and draining as a live gauge of refused connections currently being drained). A user also gains ad_tag_skipped once one of its ad-tagged streams took the direct path because its transport has no middle-proxy reframer, and ad_tag_failed once one did so because the middle proxy could not be reached or refused it — the first is by design, the second is an outage, apart from a few in the seconds after a restart while the middle-proxy config loads — see Sponsored channel.

features is a sorted list of capability names this build supports, meant to be read before a capability is used — it is how a panel can tell an older binary from a newer one without parsing version strings. Names are a public contract: existing ones are never renamed or removed, and new ones are only ever appended.

Client addresses

Off by default. With track-client-ips = true, each user also reports the addresses behind their connections:

"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 and deduplicated — it is derived from the connection registry rather than counted separately, so it cannot drift from connections beside it, and an entry disappears when its connection ends. last_ip is the most recent admitted connection's address and outlives that connection.

Why this is off by default. 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 whoever ends up with the server. connected_ips adds no retention — those addresses are already held for the life of the connection — 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, so no address survives a restart.

Behind a front proxy, both report what the PROXY header carried — the client's real address — which is what makes them useful for a WEB deployment. See proxy-protocol-listener.

Hot secret reload

The api-bind-to listener also serves a reload endpoint, so the [secrets] set can change without restarting the proxy:

curl -X POST http://127.0.0.1:9090/reload

On success it re-reads the same config file passed to mtg-proxy run and swaps the secret set in atomically:

  • added secrets start working immediately;
  • connections whose secret was removed or re-keyed are closed;
  • every other user stays connected and their stats counters carry over.

Only [secrets], ad-tag, [secret-ad-tags], and [secret-limits] are hot-applied. Changing the bind address, domain fronting, network, or throttle settings still needs a restart.

Responses: 200 {"status":"ok"} on success; 500 if the config cannot be read (the current set stays active); 503 when the proxy was started without a config file (simple-run); 405 for a non-POST request.

The WEB relay is a second process and does not share this listener. It watches the config file and applies a changed [secrets] table by itself within a few seconds; SIGHUP applies it immediately — see WEB proxy transport. A change made through this API alone, without writing the file, never reaches the relay.

Secrets & ad-tag management API

The same listener exposes CRUD endpoints so you can manage secrets and the ad-tag at runtime, without editing the file or restarting:

Method & path Action
GET /secrets List secrets (name, secret, host, mode, ad_tag, effective_ad_tag, plus unserved and quota/quota_used/quota_remaining/expires_at/disabled/speed_limit/ip_limit when set).
POST /secrets Add or update one: {"name","secret","ad_tag"?,"quota"?,"quota_reset"?,"expires"?,"disabled"?,"speed"?,"ip_limit"?}.
PUT /secrets Replace the whole set: {"secrets":{name:{secret,ad_tag?,quota?,quota_reset?,expires?,disabled?,speed?,ip_limit?}},"ad_tag"?}.
GET /secrets/{name} One entry of the list above, by name, with the same live counters (404 if unknown).
DELETE /secrets/{name} Remove one (404 if unknown, 409 if it is the last one).
POST /secrets/{name}/reset-quota Zero the secret's used-bytes counter (404 if unknown).
GET /secrets/{name}/external-ips The user's address view: ip_limit, active_ips, and the addresses when track-client-ips is on.
PUT /secrets/{name}/external-ips {"ips":[...],"ttl":"90s"} — report the addresses the user holds on other systems, so the address limit spans them. Expires after ttl.
DELETE /secrets/{name}/external-ips Clear that report.
GET /adtag Read the global ad-tag: {"ad_tag":"<hex>"|null}.
PUT /adtag Set the global ad-tag: {"ad_tag":"<32 hex chars>"}.
DELETE /adtag Clear the global ad-tag.

These go through the same atomic-swap machinery as /reload: a changed secret key closes that user's connections, while an ad-tag-only change does not.

mode is the wire form the secret serialises to (ee, dd or plain), and unserved is true when no configured listener honours that form — a dd or plain secret on a proxy with no [[listener]]. Such a secret is stored and valid, it just has no door; that is deliberately not an error, so one odd client can never take an inbound offline. The request bodies carry no mode field: the mode rides inside the secret string itself, so these routes are unchanged.

Direct API mutations are in-memory only — a later POST /reload (or a restart) re-reads the config file and overrides them. Treat the file as the source of truth and the API as a live override.

API authentication

By default the API is unauthenticated and protected only by binding to loopback. Because it can mutate secrets, you can require a bearer token on every endpoint:

api-token = "change-me"
curl -H "Authorization: Bearer change-me" http://127.0.0.1:9090/secrets

Missing or wrong tokens get 401. When api-token is unset, behavior is unchanged (no auth). Always set a token if the API is reachable from anything but localhost.

Sponsored channel (ad-tag)

Upstream mtg v2 dropped the promoted-channel feature; mtg-proxy brings it back. When an ad-tag is set, matching clients are routed through Telegram middle proxies (the RPC protocol) instead of directly to the data centers, and your sponsored channel appears at the top of their chat list.

ad-tag = "0123456789abcdef0123456789abcdef"
public-ipv4 = "1.2.3.4"   # this proxy's reachable public address

One global tag applies to every secret; you can override it per secret:

[secret-ad-tags]
bob = "fedcba9876543210fedcba9876543210"

Getting a tag. Register your proxy with @MTProxybot to obtain a 32-character hex ad_tag. When the bot asks for your secret, give it the bare 16-byte key only — 32 hex characters, without the ee prefix and without the appended hostname. For example, from the secret ee3610182353be658466cea76f358bf9bb7777772e... you paste only 3610182353be658466cea76f358bf9bb. Pasting the full FakeTLS secret makes the bot reply "Incorrect secret value. It must contain 32 hex characters" — that is the bot's format requirement, not an error in your proxy.

Requirements and behavior.

  • The middle-proxy path adds one network hop and needs a reachable public IP. On a host behind NAT or with multiple addresses, set public-ipv4 / public-ipv6 — the proxy's source address (and port) are mixed into the RPC key schedule, so the middle proxy must see the address you advertise. A machine behind a port-rewriting NAT cannot complete this handshake; run on a host with a real public IP (a VPS).
  • If a middle proxy cannot be reached, that connection falls back to a direct DC connection (logged as a warning) so the client stays online — the sponsored channel just won't show for that session.
  • The middle-proxy secret and address list are fetched from Telegram lazily on first use and refreshed hourly, so a proxy without an ad-tag never contacts those endpoints.
Connection throttling

Automatic per-user connection limits protect the server from overload. A background goroutine recomputes caps every few seconds using a fair-share algorithm: light users keep all their connections, and the remaining budget is split equally among heavy consumers. New connections from over-cap users are rejected; existing connections are never killed.

[throttle]
max-connections = 5000
check-interval = "5s"

Example — limit 100, with users A=1, B=1, C=90, D=110: A and B stay at 1, and the remaining budget of 98 is split so C and D are each capped at 49.

Throttle state is exposed in the stats response:

{
  "throttle": {
    "active": true,
    "limit": 5000,
    "caps": { "heavy-user": 2450 }
  }
}
Per-user quotas, expiry & disable

Each named secret can carry governance limits — a data quota, a speed limit, an address limit, a validity deadline, and an on/off switch — so you can run mtg-proxy as a reseller or multi-tenant proxy. Add a [secret-limits.<name>] table for any secret in [secrets]; a secret without one is unlimited, never expires and is enabled.

# Persist quota usage across restarts (optional but recommended for quotas).
usage-state-file = "/var/lib/mtg/usage.json"

[secret-limits.alice]
quota = "10GB"            # human size or a bare byte count; omit for unlimited
quota-reset = "monthly"   # "none" (default, lifetime cap) or "monthly"
speed = "5mbit"           # sustained cap, all of alice's connections together
ip-limit = 2              # how many places alice may be at once; omit for unlimited
expires = "2026-12-31"    # RFC3339 or YYYY-MM-DD; omit for never
disabled = false          # true rejects the secret without removing it

[secret-limits.bob]
quota = "500MB"

Speed is per user, not per connection. Every connection alice has, on every door, draws from one allowance, so two of her devices share 5mbit. It is a sustained rate with a one-second burst, upload and download combined, enforced by the proxy itself — the only hop that knows which user a byte belongs to — and it never buffers: the bucket only delays, so backpressure lands in the client's own TCP window. Bit units are decimal (5mbit is 625,000 bytes/s), byte units are base 2 (1MB/s), the minimum is 64kbit. A change reaches live connections on reload without dropping them, and throttled_ms on /stats says how long a user has spent held to it.

An address limit counts places, not sockets. All of a device's connections come from one address and count once; an IPv6 address counts as its /64. A connection from a place alice already holds is always admitted, one from a new place only while she is below the limit — refused at admission, after the secret matched, so only a holder of the valid secret can ever see it. The check and the registration are one atomic step, a slot is freed the moment the last connection from that place ends, and a limit lowered on a running proxy never drops anyone. A panel that also runs alice's VPN can report the addresses she holds there through PUT /secrets/alice/external-ips, and they count against the same limit until the report's ttl lapses. ip_limit, active_ips and ip_limit_refused on /stats show it working.

When a user is over quota, past its expiry, or disabled, new connections are transparently routed to the fronting domain — exactly like a wrong secret — so a prober cannot tell a limited user from an invalid one. A quota is a ceiling: the stream that reaches it ends at that byte, the user's other streams end on their next byte, and quota_cutoffs on /stats counts them. Disabling or expiring a secret closes its live connections, at once via reload or the API and within five seconds when the clock crosses the expiry on its own.

Usage is exposed per user in /stats and /secrets:

{
  "users": {
    "alice": {
      "connections": 2,
      "bytes_in": 1048576,
      "bytes_out": 2097152,
      "quota_used": 3145728,
      "quota": 10737418240,
      "quota_remaining": 10734272512,
      "quota_reset": "monthly",
      "expires_at": "2026-12-31T00:00:00Z"
    }
  }
}

Set usage-state-file so quota_used survives restarts; it is flushed atomically every ~30 seconds and on shutdown. With quota-reset = "monthly" the counter resets at the start of each calendar month. Clear a user's usage manually with POST /secrets/{name}/reset-quota. Limits set through the management API are in-memory only and are overridden by the next reload — the config file remains the source of truth.

WEB proxy transport

Telegram Desktop 7.1 added a WEB proxy connection type. The client loads a page from your hostname inside its own WebView, and that page carries an MTProto stream over a WebSocket. On the wire it is a browser visiting a website over ordinary HTTPS — no obfuscated2 handshake to fingerprint and no FakeTLS to distinguish, because the TLS is real.

mtg-proxy serves it from a second process of the same binary, reading the same configuration file:

mtg-proxy generate-web-key /etc/mtg-proxy/web.key
mtg-proxy web-relay --check /etc/mtg-proxy/config.toml   # validates and exits
mtg-proxy web-relay /etc/mtg-proxy/config.toml
[[listener]]
bind-to = "127.0.0.1:2398"
accept = ["dd", "plain"]
proxy-protocol = true          # carries the browser's address to the proxy

[web]
enabled = true
host = "web.example.com"       # real certificate, port 443, no IP literals
bind-to = "127.0.0.1:8099"     # where the front proxy reaches the relay
backend = "127.0.0.1:2398"     # must name the [[listener]] above
token-key = "/etc/mtg-proxy/web.key"
cover-dir = "/srv/site"        # or cover-upstream = "http://127.0.0.1:8080"

mtg-proxy access then prints a web object per secret. Four things are different from every other link in this README, and all four come from the client:

  • The secret is the dd form. A WEB-capable client refuses an ee secret before it opens a socket. It is the same sixteen bytes; only the spelling changes, so nothing has to be re-issued.
  • There is no address and no port. The client's form has one field, a hostname, and forces https and 443. Changing the hostname invalidates every WEB link ever issued, exactly like changing an ee secret's fronting domain.
  • A real certificate is required. The WebView validates it normally and offers no way past an error, so this needs a front proxy that terminates TLS — Caddy in the deployment guide.
  • Telegram Desktop 7.1.2 or later. Earlier 7.1 builds had a WebView2 bug that broke some WEB proxies on Windows.

The hostname must also serve a plausible website. Everything that arrives without a credential the relay minted gets it — on every path, including the API ones — and nothing ships as a default, because a starter page many operators installed would be a better signature than anything the protocol leaks.

Adding and removing users without a restart. The relay watches its config file. A changed [secrets] table is applied by itself within a few seconds of the write settling, and swapped in atomically; nothing has to be told. SIGHUP applies it immediately rather than on the next look:

systemctl reload mtg-proxy-web-relay        # or: kill -HUP <pid>

Live sessions keep running, a newly added secret is recognised on its next page load, and a removed one stops being recognised. Only the secret set is reloadable: any other change under [web], or to the backend listener's proxy-protocol, is refused whole with restart required rather than half-applied — once, not again on every look. A reload that cannot read or validate the file keeps the running set — an emptied registry would serve the cover site to every real user while reporting nothing wrong. Windows has no SIGHUP; the watcher is the only trigger there, and it is enough.

Watching it. The relay reports its own counters on a socket of its own:

[web]
stats-bind-to = "127.0.0.1:8100"
curl http://127.0.0.1:8100/stats

Sessions, streams, bytes in each direction, profile names, reload outcomes, and a breakdown of why requests were refused — cover_served, not_found, bad_token, session_limit. The breakdown is the point: a climb in cover_served alongside users reporting failures means a hostname or secret-set mismatch, and a climb in session_limit means capacity, and one number cannot tell those apart.

It must be a loopback address and the relay refuses to start otherwise. It is deliberately a second socket rather than a path on bind-to: that one is published to the internet by the front proxy, where everything without a credential the relay minted is answered by the cover site — a real endpoint there would be a fingerprint however it was gated. It is off by default.

contrib/web-relay/ is the deployment: the Caddy vhost, the four hops that have to carry the client's address, and the acceptance checklist for a real client. /stats advertises web-relay in features when a binary can serve this.

What this has been tried against. Telegram Desktop 7.1.2 on Windows, end to end through Caddy, with both connection types working from the same config, and the tg://webproxy link opening the client's WEB proxy form filled in (confirmed 2026-09-07). The other client platforms have not been through the checklist yet; mtg-proxy access's printed hostname and secret configure any of them by hand.

Public IP override

Useful when automatic detection via ifconfig.co is unavailable, or when the address the outside world sees differs from any local interface.

public-ipv4 = "1.2.3.4"
public-ipv6 = "2001:db8::1"

These addresses are used by mtg-proxy access to build links, by mtg-proxy doctor to validate the SNI-to-DNS match, and — when an ad-tag is set — as this proxy's own address in the middle-proxy handshake.

Environment variables

For parity with the official telegrammessenger/proxy image, mtg-proxy run overlays a few environment variables on top of the config file. The environment always wins over the file, and it is re-applied on every config read, so an env-pinned secret or tag survives a POST /reload.

Variable Meaning
SECRET Proxy secret. Either a full mtg secret (ee… hex or base64) or a bare 16-byte hex key in the official-image format — the latter also needs SECRET_HOST. Replaces the whole [secrets] set and [secret-ad-tags].
SECRET_HOST Domain-fronting hostname combined with a bare 16-byte SECRET into a FakeTLS secret. Ignored when SECRET is already a full secret.
TAG Advertising tag from @MTProxybot; same as ad-tag in the config. An empty value clears the tag. Like the official image, it is not persisted — provide it on every run.
MTG_BIND_TO Comma-separated host:port list overriding bind-to.

The MTG_-prefixed variants (MTG_SECRET, MTG_SECRET_HOST, MTG_TAG) take precedence over the bare names, so a generic name like SECRET in a shell can't be captured by accident outside a container.

WORKERS and SECRET_COUNT from the official image are not applicable — one Go process already uses every CPU core, and multiple secrets are configured through [secrets] or the API. Setting either is ignored with a startup warning.

Command reference

Command Description
mtg-proxy generate-secret [--hex] <hostname> Generate a new secret for the given fronting hostname. --hex/-x prints hex instead of base64.
mtg-proxy run [--check] <config> Run the proxy from a config file (supports the full feature set and hot reload). --check parses and validates the file the way run does, refuses any key this binary does not read, and exits without listening.
mtg-proxy simple-run <bind-to> <secret> Run without a config file, from flags only (no [secrets], no reload).
mtg-proxy access [--ipv4 IP] [--ipv6 IP] [--port N] [--hex] <config> Print tg:// / t.me links and QR-code URLs for the configured secrets.
mtg-proxy doctor [--skip-native-check] <config> Check connectivity, clock skew, fronting reachability, and SNI-to-DNS match. Run this first when something is off.
mtg-proxy web-relay [--check] <config> Run the WEB proxy relay for the secrets in a config file. --check validates the [web] table and exits.
mtg-proxy generate-web-key <path> Write the WEB relay's 32-byte token signing key.
mtg-proxy version Print the version.

Configuration

example.config.toml documents every option with its default value and inline notes. A real config only needs the options you actually change — every key has a sensible default.

Extra listeners ([[listener]])

bind-to is, and stays, FakeTLS-only. To also serve the two pre-FakeTLS secret forms — dd (dd + 32 hex) and plain (a bare 32-hex key) — add a [[listener]] block. It is a separate socket: one port never serves both entry paths, and there is no sniffing.

bind-to = "0.0.0.0:443"        # unchanged, FakeTLS only

[[listener]]
bind-to = "127.0.0.1:2398"
accept  = ["dd", "plain"]

The intended consumer is Telegram's own WEB proxy front-end (tproxy-server), whose backend address is restricted to loopback and which speaks plain obfuscated2 to it.

key type default meaning
bind-to host:port, host must be a literal IP required address for this obfuscated2 listener
accept string array, non-empty subset of {"dd","plain"} required which secret forms this socket honours. "ee" is rejected — run two listeners if you need both
public bool false your acknowledgement that a non-loopback obfuscated2 port is fingerprintable as an MTProto proxy. Required on any non-loopback bind-to
proxy-protocol bool false expect a PROXY header on this listener. The top-level proxy-protocol-listener applies to bind-to only
on-unknown-secret "close" | "drain" "close" disposal of a connection no secret matches. drain reads and discards under a bounded budget instead of resetting
handshakes-per-ip uint 30 per-IP failed-handshake budget per second (burst 2×). Armed only when public = true, because behind a loopback carrier every connection presents 127.0.0.1 and one shared bucket is a self-DoS

Secrets are not per-listener. A secret is 16 key bytes, and the same key is valid in all three wire forms; the prefix only tells the client how to connect. So an existing ee user keeps one name, one quota bucket and one set of stats whichever door they arrive through — nothing needs to be re-issued.

mtg-proxy access prints a listeners array alongside the usual links, with a ready-to-use link per user per accepted form:

"listeners": [
  {
    "bind_to": "0.0.0.0:8444",
    "accept": ["dd"],
    "public": true,
    "ipv4": {
      "ip": "203.0.113.9",
      "port": 8444,
      "secrets": {
        "alice": {
          "dd": {
            "secret": "dd367a189aee18fa31c190054efd4a8e95",
            "tg_url": "tg://proxy?port=8444&secret=dd367a…&server=203.0.113.9"
          }
        }
      }
    }
  },
  {
    "bind_to": "127.0.0.1:2398",
    "accept": ["dd"],
    "public": false,
    "unreachable": "bound to loopback, so it is reachable only from this host (a WEB relay backend is meant to look like this)"
  }
]

The secret in each link 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, and their quota, counters and limits are the ones they already had. A loopback listener gets no links and says why: that is the shape of a WEB relay backend, not a misconfiguration.

TOML ordering. [[listener]] is an array of tables, so every top-level key written after it belongs to the listener rather than to the global scope — the same rule that already forces [secrets] to come last. Put your [[listener]] blocks after all global keys, and [secrets] after those.

Older mtg-proxy binaries ignore unknown TOML keys, so a config carrying [[listener]] still runs on one that predates this feature: it simply serves ee on bind-to as before, and no user of any existing mode is affected.

Credits

mtg-proxy is a fork of 9seconds/mtg by Sergey Arkhipov and contributors. All of the core proxy engineering is theirs; this fork adds the multi-user layer on top. The middle-proxy (ad-tag) implementation is ported from the last mtg v1 release that shipped it.

Documentation

Overview

mtg is just a command-line application that starts a proxy.

Application logic is how to read a config and configure mtglib.Proxy. So, probably you need to read the documentation for mtglib package first.

mtglib is a core of the application. The rest of the packages provide some default implementations for the interfaces, defined in mtglib.

Directories

Path Synopsis
Antireplay package has cache implementations that are effective against replay attacks.
Antireplay package has cache implementations that are effective against replay attacks.
benchmarks
cmd/echo command
Echo server — runs on Amsterdam, simulates Telegram DC.
Echo server — runs on Amsterdam, simulates Telegram DC.
cmd/realnet command
cmd/relay command
Relay server — the process we measure.
Relay server — the process we measure.
This is a minimal package that contains _essentials_ of mtglib and its complimentary packages.
This is a minimal package that contains _essentials_ of mtglib and its complimentary packages.
Events has a default implementations of EventStream for mtglib.
Events has a default implementations of EventStream for mtglib.
internal
cli
webrelay
Package webrelay is the server side of the WEB proxy v1 transport a Telegram client reaches over HTTPS.
Package webrelay is the server side of the WEB proxy v1 transport a Telegram client reaches over HTTPS.
webrelay/frame
Package frame implements the shared-frame codec of Telegram's WEB proxy v1.
Package frame implements the shared-frame codec of Telegram's WEB proxy v1.
webrelay/page
Package page generates the bridge document: one dynamically built, no-store HTML response carrying a single inline nonce-bearing script and nothing else.
Package page generates the bridge document: one dynamically built, no-store HTML response carrying a single inline nonce-bearing script and nothing else.
webrelay/ws
Package ws is the server half of RFC 6455, written rather than imported.
Package ws is the server half of RFC 6455, written rather than imported.
Package ipblocklist contains default implementation of the mtglib.IPBlocklist for mtg.
Package ipblocklist contains default implementation of the mtglib.IPBlocklist for mtg.
files
files defines a set of abstraction for 'files': an openable entities that could be read after.
files defines a set of abstraction for 'files': an openable entities that could be read after.
Package logger has implementation of loggers for mtglib.Logger interface.
Package logger has implementation of loggers for mtglib.Logger interface.
mtglib defines a package with MTPROTO proxy.
mtglib defines a package with MTPROTO proxy.
dcprobe
Package dcprobe verifies that a TCP endpoint is a real Telegram DC by performing the unauthenticated first step of the MTProto handshake (req_pq_multi -> resPQ) on top of mtg's existing obfuscated2 transport.
Package dcprobe verifies that a TCP endpoint is a real Telegram DC by performing the unauthenticated first step of the MTProto handshake (req_pq_multi -> resPQ) on top of mtg's existing obfuscated2 transport.
internal/middleproxy
Package middleproxy implements the client side of Telegram's MTProto middle proxy (RPC) protocol.
Package middleproxy implements the client side of Telegram's MTProto middle proxy (RPC) protocol.
Network contains a default implementation of the network.
Network contains a default implementation of the network.
v2
Network contains a default implementation of the network.
Network contains a default implementation of the network.
Stats package has implementations of events.Observer for different monitoring systems.
Stats package has implementations of events.Observer for different monitoring systems.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL