🇷🇺 Что нового (RU)
Коротко
v1.11.0 добавляет поддержку WEB-прокси — четвёртого типа прокси, появившегося в Telegram Desktop 7.1. Носителем соединения там служит скрытый WebView: клиент открывает https://<домен>/?bridge=<capability> и гоняет MTProto по WebSocket изнутри этой страницы. Снаружи это обычная HTTPS-сессия к обычному сайту — в этом и смысл.
Плюс четыре исправления, найденные, пока эта штука доводилась до работы с живым клиентом. Два из них чинят вещи, которые ломались и без всякого WEB: direct-пользователи не могли загрузить ни одного файла с CDN, а проверка конфигурации nginx в masking никогда не выполнялась.
WEB — строго opt-in: пока не выполнена mtbuddy setup web, ничего не меняется.
Что это такое
Клиент 7.1 умеет получать ссылку вида tg://webproxy?.... По ней он поднимает невидимый WebView, загружает вашу страницу и открывает из неё WebSocket. Дальше внутри этого WebSocket живёт обычный MTProto: мультиплексированные потоки с кредитным управлением потоком, каждый из которых на стороне сервера превращается в отдельное соединение к датацентру.
Для наблюдателя картина отличается от FakeTLS: там TLS-сессия к домену, который мы имитируем, здесь — настоящая TLS-сессия к настоящему сайту, который действительно отвечает HTML, если на него зайти браузером.
Реализовано серверное плечо целиком:
| файл | что делает |
|---|---|
src/web/frame.zig
| кадры v1: type:u8 | stream_id:u24 | length:u32
|
src/web/capability.zig
| base64url(HMAC-SHA256(secret, "tdesktop-web-proxy-bridge-v1\n" + host)), хост нормализуется ровно так же, как это делает клиент
|
src/web/ws.zig
| серверная сторона RFC 6455; немаскированные кадры клиента, неминимальные длины и фрагментированные control-кадры — протокольная ошибка |
src/web/http.zig
| минимальный HTTP/1.1 для обложки и bridge-страницы |
src/web/page.zig
| сама обложка и ES5-шим bridge-страницы: и нативный мост, и запасной путь через iframe |
src/web/relay.zig
| epoll-цикл: сессии, потоки, кредиты, отложенное закрытие |
Как поднять
mtbuddy setup web --domain proxy.example.com
Одна команда пишет vhost для nginx, получает сертификат по HTTP-01, ставит mtproto-web-relay.service, добавляет секцию [web] и выводит ссылки. Команда идемпотентна: повторный запуск ничего не переписывает и не перезапускает прокси. mtbuddy links печатает tg://webproxy рядом с обычными ссылками, mtbuddy status показывает relay, mtbuddy uninstall его убирает.
Если TLS у вас терминирует что-то своё — CDN или отдельный IP — есть --mode behind, тогда relay встаёт за вашим фронтом. И mtbuddy setup web --remove убирает всё обратно.
Что стоит знать заранее:
[web].domainтак же неизменяем, какtls_domain. Capability — это HMAC от канонического имени хоста, так что смена домена делает недействительной каждую выданную WEB-ссылку.- Нужен открытый 80-й порт для HTTP-01 и место в
max_connections: один WEB-клиент занимает одно маскируемое соединение плюс по одному на каждый MTProto-поток. - Long-poll как запасной транспорт сделан не был намеренно. Носитель — только WebSocket: ограниченный профиль WebView его разрешает, а stop-and-wait поверх long-poll упирался бы в RTT. Если сеть убивает WSS, страница честно сообщает об ошибке и клиент уходит на другие прокси.
Реальный адрес клиента переживает маскирующий хоп
[web].mask_backend указывает на второй слушатель nginx (127.0.0.1:8444 ssl proxy_protocol) на том же vhost. Когда SNI маскируемого соединения совпадает с [web].domain, прокси проксирует его туда, предваряя заголовком PROXY v2 из принятого адреса; nginx отдаёт $proxy_protocol_addr как X-Forwarded-For, а relay берёт правую запись — единственную, которую клиент не может подделать.
Без этого каждый WEB-пользователь приходил бы в MiddleProxy как 127.0.0.1: пер-IP защита от флуда схлопнулась бы в один ключ, а Telegram видел бы одного клиента вместо всех. Проверено на живом хосте — nginx видит настоящий адрес, каждая сессия пишет в лог client address: real.
Паддинг padded-intermediate был вчетверо шире допустимого
decapsulateS2C добавляла к каждому кадру «сервер→клиент» на транспорте .secure от 0 до 15 случайных байт. Но padded intermediate (0xdddddddd) не передаёт длину паддинга: приёмник восстанавливает полезную нагрузку, обрезая объявленную длину вниз до кратности 4 — то же правило, которое мы сами применяем к направлению c2s. Тела MTProto выровнены по 4, поэтому переживают только 0..3 байта; всё, что шире, оставляет 4, 8 или 12 байт мусора, приклеенных к сообщению.
Референс подтверждает: у alexbers/mtprotoproxy в MTProtoSecureIntermediateFrameStreamWriter стоит MAX_PADDING_LEN = 4.
Замерено на живом прокси до правки — истинная длина нешифрованного ответа равна ровно 20 + msg_len, так что паддинг вычисляется точно:
padding 0..3 байта : 15 кадров
padding 4..15 байт : 25 кадров ← 62% недопустимы
Со стороны клиента это поток TCP Error: bad SHA256 hash after aesDecrypt in message — 898 штук за 100 минут работы Telegram Desktop 7.1, медиа не грузится, соединение переподключается без остановки. После правки — 0/60.
Баг был спящим: до сих пор до этого пути ничего не доходило. fake_tls_only не пускает dd из интернета, а FakeTLS-клиенты согласуют другой внутренний тег. WEB делает dd каждым потоком — и баг зажёгся сразу.
Отдельно стоит записать, почему его не поймали тесты. Проверяющий клиент строил кадры с тем же паддингом 0..15 и не обрезал на приёме — то есть был сломан симметрично с сервером. И он гонял только req_pq_multi, а это нешифрованное сообщение: DC разбирает его по длине из конверта и спокойно игнорирует мусорный хвост. Ломается только шифрованный трафик. Теперь live_check.py проверяет инвариант напрямую, а юнит-тест прогоняет 200 кадров через decapsulateS2C и убеждается, что обрезка до кратности 4 восстанавливает полезную нагрузку байт в байт.
CDN-датацентр 203 у direct-пользователей уходил в никуда
constants.getDcAddressV4(203) возвращает 91.105.192.110:443 — это middle-proxy для DC 203 (proxy_for 203 ... в proxy-multi.conf), а не датацентр. У CDN-датацентров прямого адреса в таблице нет вообще.
Поэтому для всех, кто перечислен в [access.direct_users], каждый CDN-запрос открывал TCP-сессию к middle-proxy, говорил в неё сырым обфусцированным потоком клиента и не получал ничего: 912 таких соединений за один вечер на живом хосте, все закрылись с s2c=0 при ненулевом c2s.
CDN — это именно то, откуда непремиум-аккаунты тянут медиа. Так что выглядело это как «на direct-ссылке медиа не грузится» и легко принималось за нормальное поведение direct-режима. Это не оно: трафик уходил в чёрную дыру.
Теперь dc_abs == 203 — исключение из обхода middle-proxy для direct-пользователей, если middle-proxy для него известен; direct_fallback для него не ставится вовсе, потому что повтор туда лишь меняет неудачное рукопожатие на ту же чёрную дыру.
Правила против DPI больше не трогают loopback
Два правила mangle OUTPUT, которые ставит установщик, матчились по любому пакету, уходящему с 443-го порта, включая loopback:
-A OUTPUT -p tcp --sport 443 --tcp-flags SYN,ACK SYN,ACK -j TCPMSS --set-mss 88
-A OUTPUT -p tcp --sport 443 -j NFQUEUE --queue-num 200 --queue-bypass
Оба нужны против DPI на пути к клиенту: MSS 88 заставляет ClientHello фрагментироваться, чтобы stateless-экстрактор JA4 не собрал расширения из одного пакета, а nfqws делает fake/split2 desync. 127.0.0.1 сеть не пересекает, поэтому на loopback они не дают ничего и стоят дорого — а WEB-relay ходит в прокси именно через 127.0.0.1:443.
Замерено на живом хосте до правки. Сокеты relay:
mss:76 pmtu:65535 advmss:65483
и сторона прокси одного релеевого соединения, снимок раз в секунду:
Recv-Q Send-Q Local Peer
0 427706 127.0.0.1:443 127.0.0.1:57178 ← замерло
0 427706 127.0.0.1:443 127.0.0.1:57178
0 427706 127.0.0.1:443 127.0.0.1:57178
при Recv-Q 0 на другом конце той же пары всё это время. За одно четырёхминутное окно прокси отдал в релеевые соединения 8 252 572 байта, а relay прочитал 172 741 — разрыв в 48 раз.
Direct-пользователей это не задевало, потому что их трафик loopback не пересекает. Именно поэтому симптом выглядел как «пользователь через MiddleProxy ползёт, direct летает» и несколько часов уводил в сторону MiddleProxy.
Оба правила теперь несут ! -o lo. После правки loopback согласует mss 32768–65483, а внешние клиенты по-прежнему показывают rcvmss:76 — устойчивость к DPI не изменилась.
Важно для уже установленных хостов: ни скрипт TCPMSS, ни юнит nfqws обычным обновлением не перегенерировались — оба пишутся один раз при установке. Поэтому mtbuddy update теперь чинит их на месте: скрипт TCPMSS перерисовывается из PORT и MSS, уже зашитых в него (ручная настройка сохраняется), а правила в юните nfqws переписываются с исключением, причём каждый исправленный append предваряется парным delete, чтобы перезапуск не наращивал дубликаты. Старые delete-строки сохраняются — именно они убирают прежнее правило. Повторное обновление уже ничего не меняет.
masking: nginx -t никогда не выполнялся
masking.zig запускал nginx -t через sys.exec, то есть std.process.run с .expand_arg0 = .expand. На Zig 0.16 это возвращает error.FileNotFound для всего, что живёт только в /usr/sbin, даже когда /usr/sbin есть в PATH — проверено на живом хосте кросс-собранным пробником. Место вызова глотало ошибку запуска через catch null и считало это пройденной проверкой, так что валидация конфигурации nginx не выполнялась ни на одной установке. Разрешение пути переведено на sys.commandOrPath — идиому, которую репозиторий уже применяет для iptables и useradd.
Проверено
zig build test— 336/336, включая новые тесты на кадры, capability (по векторам самого клиента), WebSocket-фрейминг, HTTP-разбор, восстановление паддинга и переписывание правил nfqws.zig build web-bridge— 6/6: сценарный клиент прогоняет настоящую bridge-страницу.- Кросс-сборки
x86_64-linuxиaarch64-linuxчистые. - Живой хост, сквозная проверка (
test/web-e2e/live_check.py): обложка, гейт по capability,HELLO/WELCOME, настоящийreq_pq_multiчерез relay → прокси → MiddleProxy → DC, и сессия, выдержанная в простое дольше любого таймаута в цепочке. - Реальный Telegram Desktop 7.1 на обеих ссылках — и на пользователе через MiddleProxy, и на direct — включая загрузку медиа.
Changelog
- feat(web): WEB proxy support for Telegram Desktop 7.1 (#390)
🇬🇧 Release notes (EN)
TL;DR
v1.11.0 adds WEB proxy support — the fourth proxy type introduced in Telegram Desktop 7.1. Its carrier is a hidden WebView: the client loads https://<domain>/?bridge=<capability> and shuttles MTProto over a WebSocket from inside that page. To an observer it is an ordinary HTTPS session to an ordinary web site, which is the point.
Plus four fixes found while getting that to work against a live client. Two of them repair things that were broken without any WEB proxy involved: direct users could not fetch a single file from a CDN, and masking's nginx config test never actually ran.
WEB is strictly opt-in: nothing changes until you run mtbuddy setup web.
What it is
A 7.1 client given a tg://webproxy?... link spins up an invisible WebView, loads your page and opens a WebSocket from it. Ordinary MTProto lives inside that socket: multiplexed streams under credit-based flow control, each becoming its own connection to a datacenter on the server side.
The observable difference from FakeTLS matters: there, a TLS session to a domain we imitate; here, a real TLS session to a real site that genuinely serves HTML if you visit it in a browser.
The server half is implemented in full:
| file | what it does |
|---|---|
src/web/frame.zig
| v1 framing: type:u8 | stream_id:u24 | length:u32
|
src/web/capability.zig
| base64url(HMAC-SHA256(secret, "tdesktop-web-proxy-bridge-v1\n" + host)), with the host canonicalised exactly the way the client canonicalises it
|
src/web/ws.zig
| server-side RFC 6455; unmasked client frames, non-minimal lengths and fragmented control frames are protocol errors |
src/web/http.zig
| minimal HTTP/1.1 for the cover and bridge pages |
src/web/page.zig
| the cover page and the bridge page's ES5 shim, covering both the native bridge and the loopback-iframe fallback |
src/web/relay.zig
| the epoll loop: sessions, streams, credit, deferred close |
Bringing one up
mtbuddy setup web --domain proxy.example.com
One command writes the nginx vhost, obtains a certificate over HTTP-01, installs mtproto-web-relay.service, adds the [web] section and prints the links. It is idempotent: a re-run rewrites nothing and does not restart the proxy. mtbuddy links prints tg://webproxy alongside the existing links, mtbuddy status reports the relay, mtbuddy uninstall removes it.
If TLS is terminated by something of yours — a CDN, a spare IP — --mode behind puts the relay behind your own front instead. mtbuddy setup web --remove takes it all back out.
Worth knowing up front:
[web].domainis as immutable astls_domain. The capability is an HMAC over the canonical hostname, so changing the domain invalidates every WEB link you have handed out.- You need port 80 open for HTTP-01 and room in
max_connections: one WEB client costs one masked connection plus one per MTProto stream. - A long-poll fallback is deliberately absent. The carrier is WebSocket-only, which the client's restricted WebView profile permits and which avoids the RTT-bound stop-and-wait a long-poll bridge would impose. If a network kills WSS the page reports failure and the client falls back to its other proxies.
The client's real address survives the masking hop
[web].mask_backend points at a second nginx listener (127.0.0.1:8444 ssl proxy_protocol) on the same vhost. When a masked connection's SNI equals [web].domain, the proxy fronts it there behind a PROXY v2 header built from the accepted address; nginx passes $proxy_protocol_addr as X-Forwarded-For, and the relay takes the right-most entry — the only one a client cannot forge.
Without this, every relayed user would reach MiddleProxy as 127.0.0.1, collapsing the per-IP flood guard onto a single key and showing Telegram one client for everybody. Verified on the live host: nginx observes the real address and each session logs client address: real.
Padded-intermediate padding was four times wider than the protocol allows
decapsulateS2C padded every server→client frame on the .secure transport with 0..15 random bytes. But padded intermediate (0xdddddddd) carries no padding-length field: the receiver recovers the payload by truncating the declared length down to a multiple of 4 — the same rule we already apply to the c2s direction. MTProto bodies are 4-aligned, so only 0..3 bytes survive that round trip; anything wider leaves 4, 8 or 12 bytes of random garbage glued to the message.
The reference implementation agrees: alexbers/mtprotoproxy's MTProtoSecureIntermediateFrameStreamWriter uses MAX_PADDING_LEN = 4.
Measured against the live proxy before the fix. An unencrypted reply is exactly 20 + msg_len bytes long, so the padding follows precisely:
padding 0..3 bytes : 15 frames
padding 4..15 bytes : 25 frames <- 62% illegal
The client symptom is a flood of TCP Error: bad SHA256 hash after aesDecrypt in message — 898 of them across 100 minutes of Telegram Desktop 7.1 — with media that never loads and a connection that reconnects without pause. After the fix: 0/60.
The bug was dormant because nothing reached this path until now: fake_tls_only refuses dd from the internet, and FakeTLS clients negotiate a different inner proto tag. The WEB proxy makes every stream dd, so it lit up immediately.
Why the tests missed it is worth recording. The checker built its own frames with the same 0..15 padding and never truncated on receive, so client and server were wrong in matching ways. Worse, it only ever sent req_pq_multi — an unencrypted message, which the DC parses by the length in its envelope and which therefore tolerates a garbage tail. Only encrypted traffic fails the msg_key check. live_check.py now asserts the invariant directly, and a unit test drives 200 frames through decapsulateS2C and checks that the receiver's truncate-to-4 recovers the payload byte for byte.
CDN datacenter 203 was a black hole for direct users
constants.getDcAddressV4(203) answers 91.105.192.110:443, which is the middle proxy for DC 203 (proxy_for 203 ... in proxy-multi.conf), not a datacenter. CDN DCs have no entry in the direct address table at all.
So for everyone listed in [access.direct_users], every CDN request opened a TCP session to a middle proxy, spoke the raw obfuscated client stream at it, and got nothing back: 912 such connections in a single afternoon on the live host, every one closing with s2c=0 after a non-zero c2s.
CDN is exactly where non-premium accounts fetch media. So this presented as "media never loads on the direct link" and was easy to mistake for normal direct-mode behaviour. It is not: the traffic was going into a black hole.
dc_abs == 203 is now an exception to the direct-user bypass whenever a middle proxy for it is known, and direct_fallback is never set for it — retrying there would only trade a handshake failure for the same black hole.
The DPI rules no longer touch loopback
Two mangle OUTPUT rules the installer writes matched every packet leaving port 443, loopback included:
-A OUTPUT -p tcp --sport 443 --tcp-flags SYN,ACK SYN,ACK -j TCPMSS --set-mss 88
-A OUTPUT -p tcp --sport 443 -j NFQUEUE --queue-num 200 --queue-bypass
Both exist to defeat DPI on the path to the client: MSS 88 forces the ClientHello to fragment so a stateless JA4 extractor cannot read the extensions from one packet, and nfqws applies fake/split2 desync. 127.0.0.1 never crosses a network, so on loopback they buy nothing and cost a great deal — and the WEB relay reaches the proxy over 127.0.0.1:443.
Measured on the live host before the fix. The relay's sockets:
mss:76 pmtu:65535 advmss:65483
and the proxy's side of one relayed connection, sampled once a second:
Recv-Q Send-Q Local Peer
0 427706 127.0.0.1:443 127.0.0.1:57178 <- frozen
0 427706 127.0.0.1:443 127.0.0.1:57178
0 427706 127.0.0.1:443 127.0.0.1:57178
with the other end of that same pair sitting at Recv-Q 0 throughout. Over one four-minute window the proxy handed 8,252,572 bytes to relayed connections while the relay read 172,741 — a factor of 48.
Direct users were unaffected because their traffic never touches loopback. That is precisely why the symptom presented as "the MiddleProxy user crawls, the direct user is fine" and pointed at MiddleProxy for hours.
Both rules now carry ! -o lo. After the fix loopback negotiates mss 32768–65483 while external clients still report rcvmss:76 — the censorship resistance is unchanged.
Important for existing hosts: neither the TCPMSS script nor the nfqws unit is regenerated by an ordinary update — both are written once at install time. So mtbuddy update now repairs them in place: the TCPMSS script is re-rendered from the PORT and MSS already baked into it (operator tuning survives), and the nfqws unit's rules are rewritten to carry the exclusion, each corrected append preceded by a matching delete so a restart cannot stack duplicates. The pre-existing deletes are kept — they are what clears the old rule. Repeated updates change nothing.
masking's nginx -t never ran
masking.zig ran nginx -t through sys.exec, i.e. std.process.run with .expand_arg0 = .expand. On Zig 0.16 that fails with error.FileNotFound for anything living only in /usr/sbin even when /usr/sbin is in PATH — verified on the live host with a cross-compiled probe. The call site swallowed the spawn failure with catch null and treated it as a passed test, so nginx validation never ran on any deployment. Resolution now goes through sys.commandOrPath, the idiom the repo already uses for iptables and useradd.
Verified
zig build test— 336/336, including new coverage for the frame codec, the capability derivation (against the client's own vectors), WebSocket framing, HTTP parsing, padding recovery and the nfqws rule rewrite.zig build web-bridge— 6/6: a scripted client drives the real bridge page.- Cross-builds clean for
x86_64-linuxandaarch64-linux. - End to end on a live host (
test/web-e2e/live_check.py): cover page, capability gate,HELLO/WELCOME, a realreq_pq_multithrough relay → proxy → MiddleProxy → DC, and a session held idle past every timeout in the chain. - A real Telegram Desktop 7.1 client on both links — the MiddleProxy-routed user and the direct one — including media.
Changelog
- feat(web): WEB proxy support for Telegram Desktop 7.1 (#390)