docs/web-proxy-plan.md
The hosted half is specified in ../tproxy-server/PLAN.md. Its multiplexing frame
format and MessageChannel contract are authoritative. This document records the
reviewed Telegram Desktop design and the implementation now present in this tree.
The server-dependent execution procedure is intentionally separate in
docs/web-proxy-test-plan.md.
WEB is an MTProxy whose primary carrier is one process-wide hidden native WebView:
MTProto session threads
-> TcpConnection (existing MTProxy obfuscation and AES-CTR)
-> WebProxySocket (one logical stream)
-> process-wide WebProxy::Transport (one worker thread)
-> one hidden platform WebView
-> injected exact-origin TelegramWebProxy bridge
-> https://relay.example/?bridge=<derived-capability>#android=<nonce>
-> HTTPS carrier
-> hosted relay
-> stock MTProxy
-> Telegram
The central invariant is that Telegram Desktop opens no external MTProto socket while WEB is active. The hidden WebView's platform web engine makes the external HTTPS connection. The hosted relay sees only bytes already transformed by the existing MTProxy protocol layer; the MTProxy secret is never placed in HTML or JavaScript.
The previous local-page/system-browser path remains a fallback. Telegram offers it only after the hidden WebView is unavailable or has failed its ten-second startup or health deadline. It never opens a browser automatically.
The initial draft left several architectural choices open. They are now fixed:
QThread. QTcpServer, accepted fallback
sockets, WebSocket framing, mux state, and queues live there. The native WebView
stays on the application thread and exchanges bounded messages with the worker.WebProxySocket and the transport are compiled in the main Telegram target.
connection_tcp.cpp, the only factory site retaining the full ProxyData, is
already in that target. No reverse dependency from td_mtproto is introduced.host field stores only the canonical lowercase ASCII/IDNA
A-label hostname. Scheme, port, path, query, fragment, user info, IP addresses
(including WHATWG "ends in a number" shorthands such as 127.1 or 0x7f.1),
and single-label names are rejected. port is fixed to 443; password stores
the MTProxy secret. Operators should publish WEB hostnames in ACE (xn--…)
form: ACE input round-trips unchanged on every platform, while a hand-typed
Unicode host is mapped by the Qt version the build ships (IDNA2003/nameprep
on the Qt 5.15 Windows builds, UTS #46 nontransitional on Qt 6), so hosts
containing deviation characters (ß, ς, ZWJ, ZWNJ) or characters newer
than Unicode 3.2 can normalise to different strings — and therefore different
capabilities — per platform.tg://webproxy /
https://t.me/webproxy links (see §11); there is no tg://proxy?… form.RTCDataChannel between two same-page RTCPeerConnections. This is a
best-effort Chrome background-lifecycle guard: it uses no media, STUN, TURN, or
remote signaling, and failure to establish it never fails the carrier.MTP::ProxyData::Type::Web is appended to the enum and serialized as type code 4.
The existing five-field proxy blob remains unchanged:
type | host | port | user | password
WEB maps those fields as follows:
| Field | WEB meaning |
|---|---|
host | canonical lowercase ASCII/IDNA A-label hostname |
port | fixed value 443 |
user | empty |
password | existing MTProxy secret syntax |
Validation requires both a valid DNS hostname and a supported MTProxy secret. Plain
16-byte and dd random-padding secrets are accepted; ee TLS-emulation secrets are
rejected because the stock MTProxy would expect an inner TLS-emulation record that
this raw relay deliberately does not add. Unknown future serialized type codes
deserialize to None instead of reaching
Unexpected, so downgrades skip an unsupported proxy rather than crashing.
WEB behaves like MTProxy throughout the existing model:
secretFromMtprotoPassword() accepts WEB.NoProxy; WEB does not affect update or generic HTTP
traffic.initConnection reports the relay hostname and port 443 as client proxy metadata.WebProxySocketmtproto/details/mtproto_web_proxy_socket.* implements AbstractSocket as a
logical byte stream over the shared transport.
On connectToHost, it registers a new 24-bit stream id. The address and port
arguments are intentionally ignored. It emits connected after the active carrier
has completed the relay WELCOME handshake and the transport has sent OPEN for
that stream.
Writes concatenate the one-time MTProxy connection prefix and body before queuing a
DATA frame. Incoming DATA is buffered and exposed through partial read() calls.
Every successful read replenishes exactly that many bytes of receive credit with a
WINDOW frame. Transport loss emits disconnected; protocol violations, queue
overflow, and explicit transport failures emit error.
The existing TcpConnection continues to own all MTProxy protocol work. For WEB it
uses secretFromMtprotoPassword() and Protocol::Create(secret) exactly as for
MTProxy, then selects WebProxySocket at the one socket-factory call site.
mtproto/web_proxy/web_proxy_transport.* provides a main-thread lifecycle facade and
runs all I/O state on its worker thread.
Main-thread lifecycle:
Activate(proxy) creates the worker on first use, synchronously installs the
selected valid proxy and creates one hidden WebView when the selected WEB proxy
changes; the fallback listener stays unbound.OpenBrowser(proxy) binds the loopback listener if needed, mints a fresh
one-shot capability and opens a new tab on explicit user request after fallback
has been offered.Deactivate() closes streams, accepted clients, and the listener when the app
changes away from WEB, and destroys the hidden WebView.Shutdown() runs after MTP accounts have stopped and joins the worker thread.The WebView candidate performs its own HELLO / WELCOME handshake before the
worker adopts it. Startup, bridge initialization, write acknowledgement, and health
are each bounded. A failed candidate is destroyed and another candidate is tried
after an exponentially growing delay (2 s to 30 s); fallback is offered only once
the failure is terminal (see §8). If a retry succeeds while the browser fallback
is connected, logical streams reconnect through the WebView and the fallback socket
is closed; no relay session is migrated across carriers.
Session-thread interaction uses queued calls into the worker. Each stream stores its
socket context, and worker-to-socket delivery is queued to that socket's owning
thread. WebProxySocket destruction unregisters synchronously on the worker before
the QObject base destructor can invalidate the context. This creates a strict
ordering boundary: notifications already posted remain owned by Qt and are removed
with the QObject, while the worker cannot inspect or post through the context after
unregistration returns. The global transport pointer is atomic and remains alive
until all MTP sessions have been destroyed.
The principal state transitions surfaced to settings are:
Idle
-> Connecting
-> Connected
-> WaitingForBrowser (WebView unavailable, unhealthy, or failed)
-> Connecting (user confirmed the browser fallback)
-> Connected
WaitingForBrowser
-> Connected (a 30-second WebView retry succeeds)
All integers are big-endian. The implementation mirrors server plan section 7:
type:u8 | stream_id:u24 | length:u32 | payload:length
Each carrier message must contain one or more complete frames. The parser accepts concatenated frames and rejects an empty message or trailing partial frame. A payload is capped at 1 MiB. Known types are:
| Value | Name | Stream | Client behavior |
|---|---|---|---|
0x01 | OPEN | >0 | sent once after WELCOME |
0x02 | DATA | >0 | opaque MTProxy bytes |
0x03 | CLOSE | >0 | empty payload; closes one logical socket |
0x04 | WINDOW | >0 | four-byte credit delta |
0x05 | PING | 0 | relay-to-client keepalive; answered with PONG |
0x06 | PONG | 0 | sent only as the exact PING response |
0x10 | HELLO | 0 | client sends payload 01 for protocol v1 |
0x11 | WELCOME | 0 | empty payload; must be the first relay frame |
0x12 | AUTH_CHAL | 0 | reserved for relay-auth v2, rejected in v1 |
0x13 | AUTH_RESP | 0 | reserved for relay-auth v2 |
0x1f | BYE | 0 | fails current logical streams and closes the carrier |
An incoming OPEN, a stream frame on stream zero, a session frame on a nonzero
stream, malformed WINDOW, data beyond granted credit, an unknown live stream, or
an unknown type is a protocol error for v1. The client retains up to 4096 recently
closed stream ids. Well-formed DATA, WINDOW, and CLOSE already in flight for a
retained id are discarded; this prevents an ordinary cross-direction close race from
failing unrelated multiplexed streams.
Both directions start with an implicit 4 MiB per-stream window.
Downlink flow control is exact: relay DATA consumes client receive credit, and
Telegram Desktop grants it back only when WebProxySocket::read() drains bytes into
the MTProto engine. This naturally bounds each socket's unread data.
Uplink has a constraint the initial draft missed: AbstractSocket::write() returns
void and provides no writable/backpressure event, so it cannot stop the MTProto
caller and resume later. The client therefore:
DATA frame;If the active carrier makes no write progress for 30 seconds, the carrier fails and
normal MTProto reconnect logic replaces it. Exhausting a stream or transport budget
also fails promptly rather than allowing unbounded queued worker events. If
measurements show sustained multi-megabyte uploads can exhaust these bounds, a
future change must add writable backpressure to the AbstractSocket contract rather
than silently growing memory.
The hosted bridge batches up to 2 MiB and runs uplink and downlink concurrently. Each direction is sequenced stop-and-wait in v1, giving an RTT-only busy-direction bound of 40, 20, 10, and 4 MiB/s at 50, 100, 200, and 500 ms web-engine-to-relay RTT, respectively. Actual results include transfer time, the relay-to-MTProxy leg, and web-engine scheduling. The 4 MiB stream window is two carrier batches so returned credit does not reproduce the former 256 KiB bottleneck.
The built-in MTProto HTTP transport also copies request/response bodies and uses an
HTTP wait request, but QNetworkAccessManager may keep several POSTs active. WEB is
therefore more RTT-sensitive today. That serialization, fixed batch size, and most
buffer copies are implementation choices; a bounded ordered pipeline or compatible
streaming carrier can narrow them. Inherent WEB cost remains one platform web
engine, an extra relay/TLS path, a native JavaScript boundary, and shared-carrier
head-of-line exposure. The explicit browser fallback adds MessageChannel and
loopback crossings. With a well-placed relay, ordinary messaging and moderate media
should be in the same practical class as the built-in HTTP transport, while direct
TCP/MTProxy remains the latency and peak-throughput reference.
lib_webview exposes WindowMode::Hidden, HiddenSupported(), and Window::valid().
Hidden mode creates the platform web engine without a Telegram window or embedded
Qt widget:
WKWebView without wrapping it in a QWindow or widget;Hidden mode does not install the normal WebView dialog UI. New-window navigation is
rejected. The transport allows only the exact canonical HTTPS bridge navigation.
The bridge is injected only into the top-level document, and native messages are
accepted only from the configured HTTPS origin (compared in ACE form, so IDN hosts
on Qt-whitelisted TLDs work); a message without a source URL is rejected. The
scrubbed https://host/ history URL is accepted for messages but not as a fresh
navigation.
The carrier opens the WebView with restrictedOrigin set, which puts lib_webview
into its restricted profile: an ephemeral, per-carrier storage area; cookies never
accepted; downloads, new windows, subframe navigations, permission prompts,
authentication dialogs and non-https/wss requests to any other host refused;
and a document-start lock script, injected into every frame (so a fresh
about:blank realm cannot bypass it), that installs a <meta> CSP allowing only
inline script and connections to the exact origin, and defines undefined over
storage, workers, audio, speech, WebRTC (RTCPeerConnection and friends),
WebTransport, WebAssembly, notifications, payment, presentation, media capture,
file pickers, navigator.credentials/mediaDevices/getUserMedia/wakeLock/share/xr/ getGamepads/storage/locks/sendBeacon/permissions and similar. The operator's page
therefore only ever gets inline script plus fetch/WebSocket to its own origin.
Engine-level enforcement backs the script per platform:
--disable-features=msSmartScreenProtection
and IsReputationCheckingRequired = FALSE (so the capability URL is never
reported to SmartScreen) and
--force-webrtc-ip-handling-policy=disable_non_proxied_udp (no direct UDP);
requests to other origins are answered 403 from WebResourceRequested; page
messages are not echoed back and new-window requests never reach the system
browser.peerConnectionEnabled and mediaDevicesEnabled are turned
off through the same KVC path used for developerExtrasEnabled, and on macOS 13+
the configuration enables Lockdown Mode (no JIT, WebAssembly or WebGL). The
lock script is a WKUserScript with forMainFrameOnly:NO.enable-webrtc and enable-media-stream are
set to false when the installed WebKitGTK exposes them, the 4.0/4.1 API path
enables the web-process sandbox (webkit_web_context_set_sandbox_enabled),
the engine already runs in the separate -webviewhelper process, and the lock
script is injected with WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES. The helper's
script-message cap is 2 MiB, enough for a full 1 MiB relay frame in base64.Debug builds evaluate a probe after the first bridge message and log a line if
RTCPeerConnection, WebTransport or WebAssembly is still reachable in the
top frame or in a freshly created about:blank iframe.
The page receives an exact-origin TelegramWebProxy object at document start. This
uses the same deployed bridge contract as Android: a fresh 32-byte URL-safe nonce in
#android=, tproxy-android-init version 1, and raw relay frames. Since the common
desktop WebView API carries strings, binary frames cross the native boundary as
strict base64 and are acknowledged by monotonically increasing write sequence. The
native queue is bounded to 8 MiB / 1024 items.
The candidate must receive exactly one valid WELCOME, and only after the nonce'd
tproxy-android-init. The handshake deadline is ten seconds, extended by another
ten seconds each time the bridge reports status: connecting|reconnecting (the
bridge's own retry budget is ~23–28 s), up to 45 seconds from navigation start.
Once adopted, the main thread probes JavaScript every three seconds; ten seconds
without a valid bridge message, or ten seconds without the acknowledgement for a
native write, fails the carrier.
The transport keeps at most 512 frames outstanding towards the WebView (control
frames may use the last 64 of them) and coalesces WINDOW grants per stream,
emitting them once 256 KiB accumulate or after 20 ms, so the carrier's hard
1024-item cap is unreachable in normal operation.
When a carrier is dropped after being adopted, the close control is evaluated in
the page and the WebView is kept alive for a further 200 ms so the bridge can send
its DELETE /api/v1/session.
WebView failures are retried with exponential backoff from 2 to 30 seconds (reset
on every successful adoption). The row shows connecting throughout; the
WaitingForBrowser state, and the confirmation box offering the system-browser
fallback, appear only for a terminal failure: hidden WebViews unsupported on the
platform (checked once per activation; nothing is retried), WebView creation
failed, or three consecutive failures without a single adoption. Retries continue
in the background at the maximum interval after that.
The worker binds QHostAddress::LocalHost on an ephemeral port only when the user
asks for the fallback (Open browser), and advertises the numeric origin
http://127.0.0.1:<port>. The listener is closed again once a browser tab has
authenticated, when the minted capability expires unused, when the hidden WebView
takes over, and on deactivation, so there is no listening port unless the fallback
was requested. A page reload after that needs a fresh Open browser.
GET / serves the inline parent with no-store, nosniff, no-referrer, and a
fresh per-response script nonce. Its strict CSP permits only that nonce-bound
bootstrap, the configured HTTPS iframe origin, and its exact local WebSocket
endpoint.
GET /transport upgrades to RFC 6455 only when all of the following hold:
GET / or the /transport upgrade;Host is the exact numeric loopback host and current port;Origin is the exact loopback page origin;Upgrade, Connection, version 13, and a valid 16-byte key are present;Client WebSocket frames must be masked. The parser supports 7/16/64-bit lengths, text, binary, continuation, ping, pong, and close, with a 2 MiB message cap. Server frames are unmasked as required by RFC 6455.
An accepted local client must complete capability authentication within ten seconds. This bounds silent HTTP connections and unauthenticated WebSockets so they cannot hold all 32 local client slots indefinitely.
The first complete WebSocket message must be UTF-8 JSON:
{"t":"auth","token":"<capability>","browser":"<user agent summary>"}
The capability is 256 random bits, URL-safe base64, carried only in the fragment of the browser URL. The page removes it from the visible URL immediately. It is one-shot, expires after five minutes, and is replaced when another tab is opened. A newly authenticated tab replaces the previous authenticated tab and causes MTProto streams to reconnect rather than attempting unsupported cross-tab resume.
After authentication:
{"t":"bridge","url":"https://<host>/?bridge=<capability>"}; the derived
capability is therefore only ever handed to a tab that proved possession of the
one-shot fragment token, never embedded in the unauthenticated GET / page;{"t":"status","state":"connecting|connected|reconnecting|failed"};If an authenticated browser does not return the required WELCOME within 30
seconds, the client fails that carrier and closes its local WebSocket. This turns a
wrong bridge capability, iframe load failure, or ordinary public response into a
recoverable unavailable state instead of leaving the settings row connecting
forever.
The local parent reads and scrubs its independent one-shot loopback capability,
connects the local WebSocket, waits for the bridge text message, and only then
creates an iframe (with limited sandbox flags and referrerPolicy set before
src) for that URL, which must begin with relayOrigin + '/?bridge=', and
establishes a MessageChannel.
The parent also creates two same-page RTCPeerConnections with an empty ICE-server
list, exchanges their descriptions only in local JavaScript, rewrites exchanged host
candidates to 127.0.0.1, and retains an open, otherwise idle RTCDataChannel.
This avoids mDNS/interface-dependent self-connect behavior and keeps RTC packets on
loopback. No RTC state is exposed to the hosted iframe. The guard starts with the
authenticated loopback WebSocket, closes with it or on pagehide, and is recreated
on pageshow or with bounded backoff if the local RTC connection fails. Browsers
without usable WebRTC continue with the ordinary carrier. The guard reduces Chrome
background freezing, intensive timer throttling, and normal automatic discard risk,
but it is not a correctness dependency: manual tab closure, browser or OS
termination, and urgent discard remain ordinary transport loss.
For a canonical hostname H and decoded WEB secret bytes S, including the leading
dd byte when present, it computes:
context = UTF-8("tdesktop-web-proxy-bridge-v1\n" + H)
bridge = base64url-no-padding(HMAC-SHA256(key=S, message=context))
bridgeUrl = "https://" + H + "/?bridge=" + bridge
Normative vectors:
| Hostname | Decoded secret hex | bridge |
|---|---|---|
proxy.example.com | 000102030405060708090a0b0c0d0e0f | MHLEY5PmW1GWqJkSrlmJpvJUiLhBH_QKy6yKg8a0JPk |
proxy.example.com | dd000102030405060708090a0b0c0d0e0f | IpJrt3e7sKtzPyoXy6w-Zj6GGEvsvclN66JzQEfPYLA |
The derived capability is constructed in memory in tdesktop and is neither stored nor shown in proxy settings. On iframe load the parent sends exactly:
iframe.contentWindow.postMessage(
{ t: 'tproxy-init', v: 1 },
relayOrigin,
[channel.port2]);
The target origin is exact and never *. Binary messages are transferred as
ArrayBuffers in both directions. Frames received locally before iframe
initialization are queued briefly and transferred after initialization. The parent
does not parse shared relay frames and never receives the MTProxy secret. Both the
hosted uplink queue and the parent's local-WebSocket queue are capped at 32 MiB; the
hosted queue also caps retained buffer objects at 16384. Exceeding either bound
closes the carrier instead of growing browser memory without limit.
The iframe's status objects update the visible tab and are forwarded to tdesktop.
When the local WebSocket closes, the parent sends {t:'close'} so the bridge can
delete its relay session. Closing the fallback tab drops the local WebSocket and
disconnects its logical sockets. Telegram Desktop does not reopen a tab
automatically. It keeps trying the hidden WebView with the backoff from §8.
Reloading cannot reuse the scrubbed, one-shot loopback capability (and the
listener is closed once a tab authenticated); after another failure, the
confirmation or row menu can mint a fresh capability and open a new tab.
Proxy settings expose a fourth WEB radio option. The editor shows:
Rows display only the hostname. Inactive WEB rows show not tested without creating
a checker, WebView, or browser tab. Only the exact active WEB row shows the live
transport lifecycle. Open browser is offered only after the built-in carrier has
failed. WEB remains unsupported for calls. Because the backend is still MTProxy,
WEB keeps the existing sponsored-proxy disclosure (in the editor and in the link
confirmation) and promotion refresh behavior. Like MTProxy, the WEB hostname is
what initConnection reports as the proxy address; QNetworkAccessManager traffic
outside MTProto is not routed through the WEB carrier.
WEB links use webproxy, a canonical hostname, and the MTProxy secret. Port 443 is
implicit and is neither accepted from the link nor displayed in its confirmation:
https://t.me/webproxy?server=<hostname>&secret=<secret>
tg://webproxy?server=<hostname>&secret=<secret>
The parser also accepts host when server is absent for compatibility with the
Android fork. Generated public links always use server. Following either link
shows the hostname and secret with one connect action. It does not check status or
enable the proxy until that action is invoked. Saved WEB entries can be shared as a
public link or a direct-scheme QR link.
Application proxy changes configure/deconfigure the web transport before MTP
sessions restart. WEB follows the MTProxy path in Session, SessionPrivate, and
TcpConnection; the global Qt proxy remains disabled for it. Proxy rotation and the
settings availability checker deliberately skip inactive WEB entries instead of
opening a WebView or browser.
OPEN originates only
from tdesktop and the relay is expected to dial one configured stock MTProxy.sandbox="allow-scripts allow-same-origin".CLOSE is an abort in both directions: undelivered DATA on a closed stream is
dropped, exactly like tdesktop's existing TCP path, which never half-closes.tdata.AUTH_CHAL / AUTH_RESP) is not implemented in v1. Adding
it requires a fully specified challenge context and server test vectors; it must be
computed in tdesktop without passing the secret to JavaScript.The server must provide all of these before the separate test plan can pass:
https://<hostname>/?bridge=<derived-capability> implements the exact derivation,
ordinary-site fallback, MessageChannel, close, and status contracts above.http://127.0.0.1:*; X-Frame-Options must not block the embed.HELLO frame, establishes a reliable ordered carrier,
and returns WELCOME before stream traffic.OPEN dials only the configured stock MTProxy endpoint.Core transport:
Telegram/SourceFiles/mtproto/web_proxy/web_proxy_frame.{h,cpp}Telegram/SourceFiles/mtproto/web_proxy/web_proxy_transport.{h,cpp}Telegram/SourceFiles/mtproto/web_proxy/web_proxy_webview.{h,cpp}Telegram/SourceFiles/mtproto/details/mtproto_web_proxy_socket.{h,cpp}Native WebView support:
Telegram/lib_webview/webview/webview_common.hTelegram/lib_webview/webview/webview_embed.{h,cpp}Integration:
mtproto_proxy_data.*, core_settings_proxy.cppconnection_tcp.cpp, session.cpp, session_private.cpp, proxy_check.cppapplication.cpp, main_account.cppboxes/connection_box.{h,cpp}, lang.stringsTelegram/CMakeLists.txtThe client-side implementation is complete without the hosted server. Remaining
verification is the hosted protocol, native-WebView/platform matrix, and explicit
browser-fallback matrix in docs/web-proxy-test-plan.md.
AbstractSocket with true uplink writable backpressure.