Server for the WEB proxy type introduced in Telegram Desktop 7.1.
Telegram shipped a new transport: the app runs a hidden WebView, loads a page from your domain over ordinary HTTPS, and tunnels multiplexed MTProto traffic through that page. To an observer the connection is a browser visiting a web site.
Telegram also published a proof-of-concept server, telegramdesktop/tproxy-server. This is an independent implementation with different trade-offs — see Compared to the reference server. PROTOCOL.md is the specification recovered from the client sources here, cross-checked against the reference server's own protocol document.
Telegram Desktop ──hidden WebView──► bridge page (served by this relay)
│ WebSocket, or plain HTTPS
│ requests where it is blocked
▼
capability gate ──► frame demultiplexer ──► obfuscated2 ──► Telegram DC
└──► cover site for everyone else
One static binary does all of it: the cover site, the bridge page, the WebSocket endpoint, TLS with automatic certificates, and the MTProxy underneath.
go build -o tgwebproxy ./cmd/tgwebproxy./tgwebproxy -domain proxy.example.com -secret 000102030405060708090a0b0c0d0e0f -site ./mysitePoint the domain's A record at the host before starting: the certificate is
obtained from Let's Encrypt on the first TLS handshake. Without -secret the
relay generates one and prints it; without -site it serves a built-in page
(replace it, see Camouflage).
The relay prints what to enter in the client:
Telegram Desktop -> Settings -> Advanced -> Connection type -> Add proxy -> WEB
Web proxy hostname : proxy.example.com
Secret (default) : 000102030405060708090a0b0c0d0e0f
https://t.me/webproxy?secret=000102030405060708090a0b0c0d0e0f&server=proxy.example.com
The link configures the client in one step, which beats dictating thirty-two
hex characters. t.me does not route this path yet, so it may have to be
opened inside Telegram rather than from a browser.
| Flag | Env | Default | Meaning |
|---|---|---|---|
-domain |
TGWP_DOMAIN |
— | domain the relay answers on (required) |
-secret |
TGWP_SECRET |
generated | 16-byte secret, hex or base64url; dd prefix selects padded intermediate |
-site |
TGWP_SITE |
built-in | directory with the cover site (index.html required) |
-cert-dir |
TGWP_CERT_DIR |
certs |
ACME certificate cache |
-listen |
TGWP_LISTEN |
:443 |
TLS address (127.0.0.1:4600 when behind a proxy) |
-plain-listen |
TGWP_PLAIN_LISTEN |
:80 |
HTTP→HTTPS redirect and HTTP-01; empty disables |
-behind-proxy |
TGWP_BEHIND_PROXY |
off | plain HTTP for a front end that terminates TLS |
-max-streams |
— | 64 | concurrent MTProto streams per session |
-diagnose |
TGWP_DIAGNOSE |
off | one log line per request, never the capability |
-log-level |
TGWP_LOG_LEVEL |
info |
debug, info, warn, error |
For more than one client, use a configuration file instead of flags:
./tgwebproxy -config /etc/tgwebproxy/relay.tomldomain = "proxy.example.com"
[[secret]]
value = "000102030405060708090a0b0c0d0e0f"
label = "phone"
[[secret]]
value = "0f0e0d0c0b0a09080706050403020100"
label = "laptop"Each client gets its own secret, so one can be revoked without disturbing the
others, and logs name the client rather than the address.
relay.example.toml lists every setting. -config cannot
be combined with -domain or -secret: the file is either the source of truth
or it is not used at all.
One process can serve any number of names, each with its own secrets and cover site:
[[host]]
name = "files.example.com"
site = "/srv/site-files"
[[host.secret]]
value = "000102030405060708090a0b0c0d0e0f"
label = "phone"
[[host]]
name = "media.example.net"
site = "/srv/site-media"
[[host.secret]]
value = "0f0e0d0c0b0a09080706050403020100"
label = "laptop"Blocking a proxy by domain name is cheap, and no amount of camouflage inside the connection changes that. What helps is making domains plural and cheap to replace: when one is blocked the user edits a single field in the client, keeps the same secret, and the server is not touched. One certificate is obtained per name, and a capability minted for one name is refused by the others.
The two shapes cannot be mixed: either a top-level domain or [[host]]
blocks. With several domains the Host header is what tells them apart, so a
front end must pass it through unchanged; with a single domain any Host is
accepted, because there is nothing to route.
SIGHUP reloads the secrets and cover site of every domain, plus
max_streams and log_level — docker kill -s HUP <container>. Sessions
belonging to a removed secret are disconnected, while renaming a secret
leaves its session alone. Adding or removing a domain needs a restart, as do
the listen addresses, cert_dir and behind_proxy; such a change is refused,
as is a file that does not parse, and the running configuration stays in place
either way.
ee (fake-TLS) secrets are rejected: Telegram Desktop marks them unsupported
for this proxy type, since HTTPS already does what fake-TLS exists for.
docker build -t tgwebproxy .docker run -d --name tgwebproxy -p 80:80 -p 443:443 -v certs:/certs \
-e TGWP_DOMAIN=proxy.example.com -e TGWP_SECRET=<hex> tgwebproxyKeep the /certs volume, or every restart asks Let's Encrypt for a new
certificate and hits its rate limit.
When something else already owns port 443, let it terminate TLS and proxy to the relay on the loopback:
./tgwebproxy -domain proxy.example.com -secret <hex> -behind-proxyThe relay listens on 127.0.0.1:4600 and obtains no certificate. The domain
is still required — the capability is bound to it and the bridge page builds
its wss:// URL from it. The vhost must forward the WebSocket upgrade
(Upgrade/Connection headers) and should keep the query string out of the
access log, because the capability is a bearer credential.
The bridge page tries a WebSocket first and, if it does not come up, carries
the same session over ordinary HTTPS requests: numbered POST batches for the
uplink, and a GET the relay holds open until it has something to send. The
relay above the carrier cannot tell the two apart, and the client twin enforces
the same rules over both.
The page decides, not the server. A network where a WebSocket is blocked or silently held open is discovered at runtime by the page that is already running, which is one advantage of the carrier being ours to choose: the reference server picks a mode per profile at start-up instead.
Uplink batches carry a sequence number and downlink polls a cursor. This is not bookkeeping for its own sake — an HTTP request can be retried by anything in the path, and a batch applied twice would inject the same MTProto bytes twice and desynchronise the stream. A repeat of the previous number is answered from the previous result; a gap is refused outright.
[admin]
listen = "127.0.0.1:9600"/healthz says the process is alive, /readyz opens and drops a TCP
connection to a data centre — the one question a process cannot answer about
itself — and /metrics serves the Prometheus text format with no dependency
behind it:
tgwp_sessions_active{domain="files.example.com",label="phone"} 1
tgwp_bytes_up_total{domain="files.example.com",label="phone"} 148213
tgwp_bytes_down_total{domain="files.example.com",label="phone"} 9930118
tgwp_streams_opened_total{domain="files.example.com",label="phone"} 12
tgwp_upstream_dial_failures_total{domain="files.example.com",label="phone"} 0
tgwp_gate_rejections_total{domain="files.example.com"} 431
Because every series carries the secret's label, traffic is attributed per
client, which is what makes handing out access with a bot practical: issue a
labelled secret, watch what it uses, remove the line and SIGHUP when the
subscription ends.
The listener must be private — a loopback host:port, or a filesystem path
taken as a Unix socket. Anything routable is refused at start-up, and a test
asserts these paths do not exist on 443: an admin endpoint on the public port
would be a fingerprint that undoes the gate.
In Docker, use the socket. A container that binds its own loopback cannot
have that port published, so -p 127.0.0.1:9600:9600 reaches nothing. Put the
socket on a bind-mounted directory instead:
[admin]
listen = "/run/tgwebproxy/admin.sock"docker run ... -v /run/tgwebproxy:/run/tgwebproxy tgwebproxy -config /conf/relay.toml
curl --unix-socket /run/tgwebproxy/admin.sock http://admin/metricsAt start-up the relay also checks itself and says so in the log: whether this
host can reach a data centre at all, whether behind_proxy is set while
listening beyond the loopback, whether the certificate cache is writable, and
whether a domain is still serving the built-in placeholder page.
go run ./cmd/tgwebproxy-probe -domain proxy.example.com -secret <hex>The probe fetches the cover page, confirms that a wrong capability is answered
byte-for-byte the same, opens the bridge and the socket, performs the client's
transport handshake while enforcing every rule the client enforces, and then
completes a real req_pq_multi/resPQ exchange with a Telegram data centre
through the relay. A relay that passes is moving Telegram's own bytes end to
end.
The transport exists so that the domain looks ordinary. The relay is built around that:
- The bridge page and the WebSocket both live on
/. There is no second path to find. - Every request runs the same constant-time capability check. A wrong or
missing capability produces the cover site — same status, same headers, same
bytes. There is no
403and no timing difference; a test enforces this. - A stream whose MTProto handshake fails is closed without explanation.
- Nothing is logged per request unless
-diagnoseis on, and even then the capability is never written.
What the relay cannot do is make the cover site convincing. Point -site at
something you would plausibly host on that domain.
| Package | Responsibility |
|---|---|
cmd/tgwebproxy |
flags, wiring, graceful shutdown |
cmd/tgwebproxy-probe |
deployment check against a live relay |
internal/httpfront |
TLS/ACME, capability gate, cover site, WebSocket upgrade |
internal/webassets |
bridge page and built-in cover page |
internal/frame |
the 8-byte frame and the client's limits |
internal/relay |
session, stream multiplexing, flow control, keepalive |
internal/mtproto |
obfuscated2 in both directions |
internal/dc |
data-centre id → address |
internal/secret |
secret parsing, bridge capability |
internal/hostname |
the client's host normalisation |
internal/twin |
a Telegram Desktop stand-in that enforces the client's rules |
e2e |
the whole stack over real TLS against the twin |
The design follows one principle: the client is unforgiving, so the relay must be too. A single unexpected frame makes Telegram Desktop drop the whole transport — every stream in it, not just the offending one. So the relay reserves window credit before it reads from a data centre, and refuses to send any frame type the client has no handler for.
go test -race ./...The suite pins the constants and the two capability vectors compiled into tdesktop, covers the handshake in both directions, the flow-control rules, the indistinguishability of the gate, and an end-to-end run over TLS against the client twin.
Telegram's tproxy-server is the reference implementation, and it is the one to read if you want the protocol from the source. It is also a proof of concept with no license file, which means no one may legally redistribute or fork it.
The two answer different questions. The reference relay terminates the carrier and hands the demultiplexed streams to a stock official MTProxy running beside it; its installer builds MTProxy from a pinned commit, installs Caddy, four systemd units and nftables rules, and targets x86_64 Debian or Ubuntu. This relay speaks obfuscated2 itself and dials the data centres directly, so it is one static binary with no companion processes.
| this relay | tproxy-server | |
|---|---|---|
| License | MIT | none (all rights reserved) |
| Data-centre leg | built in | delegated to a stock MTProxy |
| Needs Caddy/nginx | no, ACME built in | yes, Caddy owns 443 |
| Deployment | any Go target, Docker | x86_64 Debian/Ubuntu installer |
| Data-centre transport | IPv4 with IPv6 fallback | whatever MTProxy does |
| Carriers | WebSocket, HTTP long poll, chosen by the page | WebSocket, HTTP long poll, per-stream lanes, chosen by the server |
| Frame batching | one frame per message | up to 4096 frames per message |
| Cover site | static directory or built-in | static directory or loopback upstream |
| Secrets | any number, hot reload and revocation | up to 32 profiles, restart to change |
| Domains per process | any number | one (an explicit non-goal for v1) |
Where the reference is ahead, it is ahead for good reasons: the HTTP long-poll carriers keep working where WebSocket does not, and batching costs it nothing. Both are worth having here and neither is implemented yet.
Working with Telegram Desktop. The protocol was read off the dev branch of
tdesktop at release 7.1.1 and checked against the reference server's
PROTOCOL.md — frame format, limits and capability vectors agree — and a real
client has since connected through a deployed relay and carried traffic.
The automated checks stand behind that: the probe completes req_pq_multi
against a production data centre over both carriers, and the test suite drives
the whole stack against a twin that enforces the client's own strictness frame
by frame.
MIT.