🔬 Как устроен WEB-прокси Telegram изнутри: пропуск, кадры и четыре режима доставки

Обложка: устройство протокола WEB-прокси — кадры, окна и режимы доставки

О чём заметка

Технический разбор протокола WEB-прокси Telegram (tproxy, WEB proxy protocol v1) — того самого нового типа прокси, где трафик мессенджера едет внутри обычных запросов к сайту. Что это вообще такое и зачем — в обзорной заметке WEB-прокси Telegram: трафик внутри обычного сайта; здесь предполагается, что общая идея уже понятна, и разбирается сам контракт: как выводится пропуск, как выглядят кадры на проводе, как работает управление потоком и чем четыре режима доставки отличаются на уровне HTTP-запросов. Практическая установка — в отдельной заметке.

Откуда взяты данные

Всё ниже — разбор исходников серверной части tproxy-server и спецификации PROTOCOL.md из того же репозитория по состоянию на 22 августа 2026 года, а не официальная документация Telegram. Проект помечен автором как доказательство работоспособности (proof-of-concept) — демонстрация, что идея работает, а не готовый продукт. Там, где спецификация и код расходятся, расхождение отмечено явно — в таких местах поведение определяет код, а не текст.

TL;DR

  1. Пропуск на вход (bridge capability) — это HMAC-SHA256, где ключ — байты секрета MTProxy, а сообщение — строка tdesktop-web-proxy-bridge-v1\n плюс имя хоста. Результат — 32 байта, в URL это 43 символа base64url.
  2. Страница-мост — скрытая веб-страница, внутри которой работает скрипт-переносчик, — отдаётся только на точный запрос GET /?bridge=<43 символа>. Любое отклонение — лишний параметр, дубль, процентное кодирование — возвращает обычную главную страницу сайта.
  3. Кадр на проводе: тип (1 байт) | номер потока (3 байта) | длина (4 байта) | данные. Типов девять; реле не отправляет PING и BYE, так что в штатном обмене остаются семь — считая PONG, который реле принимает, но спровоцировать не может.
  4. Управление потоком — кредитное, по 4 МиБ в каждую сторону на поток. Реле возвращает кредит только после того, как байты реально записаны в сокет MTProxy.
  5. Очередь сессии разделена на три части с разными потолками, чтобы служебные кадры всегда могли встать в очередь, даже когда буфер данных забит под завязку.
  6. Отказ по одному потоку никогда не убивает сессию: клиент получает CLOSE для этого номера, остальные потоки работают дальше.

Два значения на входе и что из них получается

Пользователь вводит имя хоста и секрет MTProxy — те самые 32 шестнадцатеричных символа. Дальше клиент вычисляет производное значение, которое и уходит в сеть.

S = байты декодированного секрета, включая ведущий байт dd, если он есть
H = имя хоста в нижнем регистре, ASCII/IDNA A-label, без завершающей точки
context = UTF-8("tdesktop-web-proxy-bridge-v1\n" + H)
capability = base64url-без-выравнивания(HMAC-SHA256(ключ = S, сообщение = context))
URL = https://H/?bridge=capability

Здесь HMAC-SHA256 — стандартный способ получить из ключа и сообщения 32-байтовый отпечаток, который невозможно подделать, не зная ключа. Строка tdesktop-web-proxy-bridge-v1 — метка разделения контекстов: к доменному имени сервера она отношения не имеет, а гарантирует, что тот же секрет, использованный в другой задаче, даст другой результат. В спецификации отдельно оговорено, что имя метки заморожено ради совместимости и не делает протокол специфичным для Telegram Desktop.

Из этой формулы следуют три вещи.

Секрет не уходит в сеть. В запросе едет только производная. Скрипт на странице моста исходного секрета никогда не видит; в коде байты секрета обнуляются в памяти сразу после вычисления пропуска, а буфер файла профилей затирается после разбора.

Пропуск привязан к домену. Смена домена при том же секрете даёт другой пропуск, а значит, все ранее выданные пары «домен плюс секрет» становятся бесполезны на новом адресе. Заодно: один секрет, использованный на двух доменах, не связывает их через наблюдаемое в адресе значение.

Ротация секрета MTProxy автоматически ротирует пропуск. Отдельный механизм отзыва не нужен — но и выборочного отзыва в этой схеме не существует.

Проверочные векторы из спецификации (оба проверены пересчётом):

Имя хостаБайты секрета (hex)Пропуск
proxy.example.com000102030405060708090a0b0c0d0e0fMHLEY5PmW1GWqJkSrlmJpvJUiLhBH_QKy6yKg8a0JPk
proxy.example.comdd000102030405060708090a0b0c0d0e0fIpJrt3e7sKtzPyoXy6w-Zj6GGEvsvclN66JzQEfPYLA

Обратите внимание на вторую строку: ведущий байт dd — унаследованный от MTProxy признак режима случайного дополнения — входит в ключ HMAC. Поэтому обычный и dd-вариант одного и того же секрета дают разные пропуски, и это сделано намеренно. А вот префикс ee, то есть режим маскировки FakeTLS, здесь не поддерживается вообще: в архитектурном документе он запрещён прямым текстом, потому что защищённое соединение уже обеспечивает связка «браузер — веб-сервер». Шестнадцатибайтовый секрет, случайно начинающийся с ee, при этом принимается как самый обычный — проверка касается только 17-байтового варианта, который обязан начинаться с dd.

Требования к имени хоста

Проверка строгая, и её стоит знать заранее, потому что она отсекает привычные варианты: имя обязано быть длиной до 253 символов, содержать хотя бы одну точку (однословные имена вроде localhost отвергаются), не быть IP-адресом, состоять только из строчных ASCII-символов, цифр и дефисов, не иметь завершающей точки. Интернационализированные домены нужно записывать в ASCII-форме xn--….

Причина последнего требования не косметическая. Telegram Desktop собран на графической библиотеке Qt, и её разные версии по-разному приводят к каноническому виду отдельные символы — немецкую ß, греческую ς, невидимые соединители. Домен, набранный вручную такими символами, на Windows и на Linux может дать разные пропуски, и подключение молча не состоится.

Как сервер решает, кому отдать мост

Реле — единственный обработчик всех запросов: веб-сервер перед ним проксирует каждый путь без исключений, никакой отдельной раздачи статики нет.

Мост отдаётся только при выполнении всех условий сразу: метод строго GET, путь строго /, в строке запроса ровно один параметр с одним значением, его имя bridge, длина значения ровно 43 символа, а сама строка запроса побайтно равна bridge=<значение>. Дальше значение декодируется как base64url без выравнивания, обязано дать ровно 32 байта, и обратное кодирование обязано совпасть с исходным текстом.

Проще говоря: подойдёт ровно один вид адреса. Лишний параметр, продублированный bridge, один символ, записанный процентной последовательностью, — любое отклонение возвращает обычную главную страницу с кодом 200.

Сравнение пропуска с настроенными профилями идёт в постоянное время: цикл проходит по всем профилям до конца, без досрочного выхода при совпадении. Более того, если параметра bridge в запросе не было вовсе, сервер всё равно прогоняет то же сравнение по нулевому «манекену» — чтобы по времени ответа нельзя было отличить запрос с пропуском от запроса без него.

Единообразие ответов как отдельная инженерная задача

Любой неаутентифицированный запрос — неизвестный токен, неверный метод, битые заголовки — получает ровно тот же ответ, что и запрос несуществующей страницы: тот же код, тот же набор заголовков, то же тело. На публичной поверхности вообще нет ответов вида «метод не разрешён» или «не авторизован».

В проекте это закреплено отдельным тестом, который перебирает декартово произведение из пяти методов, шести вариантов оформления запроса (среди них — с заголовком источника, с кукой, с попыткой перейти на веб-сокет и с корректно оформленным токеном) и всех транспортных путей, и требует побайтового совпадения кода, набора заголовков и тела с эталонным промахом по несуществующему адресу.

Отдельная деталь, которую легко упустить: правило кэширования тоже единое. Любой ответ с кодом 4xx и 5xx, как и любой запрос с непустой строкой параметров, получает Cache-Control: no-store, всё остальное — public, max-age=300. Это закрывает утечку, при которой страница с пропуском не кэшировалась бы, а обычная кэшировалась.

Даже исчерпание внутренних лимитов замаскировано: если сервер не смог выдать одноразовый стартовый токен из-за ограничения скорости, он отдаёт обычную главную страницу, а не ошибку.

Стартовый токен и создание сессии

Вместе со страницей моста выдаётся стартовый токен (bootstrap) — 32 случайных байта, срок жизни две минуты. Он встраивается прямо в HTML и нужен ровно для одной операции: обменять его на сессию.

POST /api/v1/session
Authorization: Bearer <стартовый токен>
Content-Type: application/octet-stream
Тело: ровно один кадр HELLO
 
200 OK
X-Session-Token: <токен сессии>
X-Carrier-Mode: https | https-lanes | websocket | websocket-lanes
X-Down-Cursor: 0
Тело: ровно один кадр WELCOME

Обмен атомарный и идемпотентный. Идемпотентность реализована через отпечаток тела: у использованного токена сохраняется SHA-256 от того, что прислали в первый раз, и повтор с тем же телом вернёт тот же токен сессии. Повтор с другим телом — отказ. Это позволяет безопасно повторять создание сессии, когда ответ потерялся в сети.

Тонкость, которая объясняет одно проектное решение: стартовый токен не привязывается к IP-адресу. Браузер может загрузить страницу через один выходной адрес, а создать сессию через другой — из-за VPN, смены оператора или двойного стека IPv4/IPv6. В коде есть отдельный тест, где токен выдан с одного адреса, использован со второго и переспрошен с третьего, и всё это должно давать одну сессию.

Если после успешной аутентификации сервер упирается в лимит, он отвечает 503 с заголовком Retry-After: 1, не сжигая стартовый токен — побайтно идентичный запрос можно повторить.

Токены нигде не хранятся в открытом виде: в памяти лежат только их SHA-256. Проверка требует канонической кодировки, то есть неканонический base64 отвергается сразу.

Формат кадров

Все целые — беззнаковые, старшим байтом вперёд (порядок big-endian).

тип: 1 байт | номер потока: 3 байта | длина данных: 4 байта | данные
КодИмяНаправлениеПотокДанные
0x01OPENклиент → релененулевойпусто
0x02DATAобе стороныненулевойнепрозрачные, непустые
0x03CLOSEобе стороныненулевойпусто
0x04WINDOWобе стороныненулевойненулевая 4-байтовая дельта
0x05PINGреле → клиент0произвольный маркер
0x06PONGклиент → реле0точное эхо маркера
0x10HELLOклиент → реле0один байт 01
0x11WELCOMEреле → клиент0пусто
0x1fBYEреле → клиент0необязательная причина

Ключевые константы: заголовок 8 байт, максимум данных в кадре 1 МиБ, номер потока — 24 бита (до 16 777 215), в одном теле запроса не более 4096 кадров. Из сокета бэкенда реле читает кусками не больше 64 КиБ, но соседние кадры DATA одного потока склеиваются в очереди, поэтому кадр на проводе может дорасти до полного максимума в 1 МиБ — спецификация в этом месте обещает 64 КиБ и расходится с кодом.

Важное практическое замечание: PING и BYE в первой версии реле не отправляет вообще — это оговорено в спецификации и подтверждается кодом. Приёмная сторона к PONG при этом готова, и его данные ограничены 64 байтами (ограничение есть только в коде, в спецификации его нет).

Валидация формы кадров от клиента жёсткая. На нулевом потоке разрешён только PONG; всё остальное — фатальная ошибка. На ненулевом потоке: у OPEN и CLOSE данные обязаны быть пустыми, у DATA — непустыми, у WINDOW — ровно четыре байта с ненулевым значением. Пустое тело запроса тоже считается ошибкой, а не пустой операцией.

Фатальные ошибки, которые обрывают всю сессию: неизвестный номер живого потока, неизвестный тип, обрезанный кадр в конце тела, нулевая дельта WINDOW, DATA сверх выданного кредита, кадр не в том направлении. Общее правило: некорректный кадр никогда не доходит до MTProxy.

Жизненный цикл потока

Каждому TCP-соединению, которое приложение хочет открыть, соответствует один номер потока и один кадр OPEN. Номера не переиспользуются в пределах сессии.

OPEN s=17      → реле открывает одно TCP-соединение к настроенному бэкенду
DATA s=17      → ограниченная кредитом запись в этот сокет
чтение бэкенда → DATA s=17 в очередь на отдачу вниз
CLOSE s=17     → закрытие соединения
EOF бэкенда    → CLOSE s=17 клиенту
неудача дозвона → CLOSE s=17, сессия продолжает работать

Подтверждения у OPEN нет: клиент может слать DATA сразу, в том же теле запроса. Пока идёт дозвон, данные копятся, но не больше начального окна.

CLOSE — это именно обрыв, а не полузакрытие: данные, стоявшие в очереди для этого потока, отбрасываются. В спецификации это объясняется тем, что штатный путь Telegram Desktop тоже никогда не делает полузакрытие на сокете MTProto.

Отдельно стоит отметить механизм памяти о закрытых потоках. Сервер помнит до 4096 недавно закрытых номеров и молча игнорирует корректно оформленные запоздалые DATA, WINDOW и CLOSE для них — иначе гонка при одновременном закрытии с двух сторон убивала бы сессию. Побочное следствие: когда номер вытесняется из этой памяти, реле уже не отличает запоздалый кадр от нового потока — поэтому запрет на переиспользование номеров лежит целиком на клиенте.

Управление потоком: кредиты и обратное давление

Каждый поток начинается с 4 МиБ кредита в каждую сторону. Единица — байты.

Механика в направлении «клиент к серверу»: клиент вправе прислать не больше, чем есть кредита. Реле возвращает кредит кадром WINDOW только после того, как байты реально ушли в сокет MTProxy. Это и есть настоящее обратное давление: медленный сервер назначения означает, что запись блокируется, кредит не возвращается, клиент упирается в свои 4 МиБ на поток и вынужден остановиться.

В обратную сторону симметрично: чтения из сокета бэкенда расходуют кредит, выданный клиентом. Когда кредит исчерпан, поток чтения внутри реле (в языке Go это горутина) паркуется и физически перестаёт читать из сокета — TCP-окно к бэкенду схлопывается само. Никакого чтения «в память про запас» нет ни на одном участке; в архитектурном документе прямо записано требование не использовать неограниченное копирование.

Тут есть асимметрия, которую стоит запомнить. Кредит, который реле возвращает в свою сторону, не может превысить начальные 4 МиБ — попытка выдать больше отвергается. А вот кредит, который выдаёт клиент, потолком в 4 МиБ не ограничен: сложение насыщающее и упирается только в максимум 32-битного числа. Само окно в 4 МиБ выбрано намеренно больше одного тела запроса (2 МиБ), чтобы поток мог продолжать передачу, пока возвращаемый кредит едет во встречном направлении.

Три раздела очереди: почему служебные кадры не делят буфер с данными

Очередь сессии на отдачу вниз разделена на три класса с разными потолками, и это решает конкретную проблему: если бы служебные кадры делили общий буфер с данными, то забитая под завязку очередь данных не дала бы отправить даже CLOSE, и пришлось бы рвать сессию.

КлассПотолок при настройках по умолчанию
Служебные кадры (CLOSE, WINDOW)32 МиБ, 16 384 элемента
Приём тела запроса31,9 МиБ, 15 984 элемента
Данные вниз (DATA)28,9 МиБ, 11 888 элементов

Резерв под служебные кадры считается как 16 + число_потоков_на_сессию × 3 элементов, при стандартных 128 потоках это 400 элементов, то есть около 105 КиБ по внутреннему учёту. Второй резерв гарантирует, что целое тело запроса на 2 МиБ всегда найдёт куда встать.

Дополнительно есть предохранитель на старте: если резерв под служебные кадры, помноженный на максимальное число сессий, съедает весь глобальный буфер, процесс отказывается запускаться с внятным сообщением. Логика в комментарии кода честная: иначе это была бы тихая мина, которая рванёт под нагрузкой.

Учёт ведётся и в байтах, и в штуках, причём каждый элемент очереди дополнительно «стоит» 256 байт накладных расходов — консервативная оценка реальной стоимости выделения памяти.

Что происходит при упоре в лимиты

Реакция зависит от того, где именно упёрлись:

Где упёрлисьРеакция
Тело запроса не помещается в бюджет503 с Retry-After: 1, номер последовательности не подтверждён, ни один кадр не применён, сессия жива
Данные вниз не помещаютсяЧтение из бэкенда паркуется; если совсем никак — закрывается только этот поток
OPEN сверх лимита потоковCLOSE только для этого номера, сессия и остальные потоки живы
Неудача дозвона до бэкендаCLOSE этого потока, сессия жива
Нарушение протоколаСессия закрывается целиком

Отдельно проверено тестами, что отказ по лимиту профиля не расходует глобальную ёмкость и что упор в потолок потоков не рвёт родительскую сессию.

Четыре режима доставки

Режим задаётся на сервере, в профиле, и вшивается в сгенерированную страницу моста. Клиенту не нужно ни настроек, ни нового кода: он получает от сервера значение в заголовке X-Carrier-Mode и сверяет его с тем, что вшито в страницу.

Создание сессии во всех четырёх режимах одинаковое — обычным запросом по HTTPS. Дальше пути расходятся.

https — базовый и самый консервативный. Один запрос на отправку и один долгий ожидающий запрос на приём, оба сериализованы. Потолок в каждую сторону — размер тела, делённый на время оборота, то есть около 2 МиБ на RTT.

https-lanes — те же два эндпоинта, но по независимой паре запросов на каждый логический поток; поток указывается заголовком X-Lane-ID. Каждая полоса имеет собственные счётчики и собственное состояние повторов, поэтому две сессии Telegram могут обе идти с первого номера и не мешать друг другу. Режим убирает взаимную блокировку между логическими соединениями, но предполагает нормально работающий HTTP/2: на старом HTTP/1.1 браузер ограничен шестью соединениями к одному адресу, и множество одновременных ожидающих запросов туда просто не поместится.

websocket — один постоянный двусторонний канал на всё. Убирает цикл «запрос — ответ» и его потолок по задержке, но все потоки делят одно соединение со всеми последствиями: общая очередь, общий контроль перегрузки, общая точка отказа. Обрыв канала закрывает сессию и все потоки, и возобновления частично доставленной сессии в эталонной реализации нет.

websocket-lanes — отдельный канал на каждый поток, номер потока указывается прямо в имени подпротокола. Изолирует очереди: загрузка большого файла не подвешивает интерактивную переписку. Плата — больше соединений и рукопожатий. Изоляция контроля перегрузки при этом не гарантирована: если движок браузера сложит несколько каналов в одно HTTP/2-соединение, они снова окажутся в общей очереди.

Разница в реакции на ошибку между двумя полосными режимами существенна и легко упускается: в websocket-lanes нарушение протокола в одной полосе убивает только её, а в https-lanesвсю сессию.

Свойствоhttpshttps-laneswebsocketwebsocket-lanes
Соединений0 постоянных0 постоянных1по одному на поток
Состояние повтороводно на сессиюсвоё на полосузаменено гарантиями каналазаменено гарантиями канала
Головная блокировкаестьнетнет на уровне HTTPнет
Ошибка в одной полосеполос нетрвёт всю сессиюполос нетрвёт только полосу
Требует HTTP/2нетфактически данетжелательно

Долгий ожидающий запрос и его тайминги

В режимах на HTTP приём устроен через долгое ожидание: клиент отправляет запрос и сервер держит его до 25 секунд, ожидая появления данных. Если данных не появилось, приходит пустой ответ, и клиент спрашивает снова.

Политика конкуренции здесь нетипичная: побеждает новый запрос. Когда приходит второй ожидающий запрос при уже припаркованном первом, новый забирает роль, а старый немедленно возвращается пустым ответом с неизменным курсором. Мотивация в комментарии кода: старое соединение, скорее всего, уже молча умерло, и отказывать новому было бы хуже. Для отправки правило противоположное — два одновременных запроса на отправку не допускаются, второй получает 503.

Надёжность обеспечивается двумя счётчиками. Отправка нумеруется последовательностью, начиная с единицы; сервер принимает либо следующий номер, либо побайтно идентичный повтор последнего подтверждённого. Приём использует курсор: повтор старого курсора воспроизводит ту же порцию байт в байт. Клиент продвигает курсор только после того, как полностью передал полученное приложению, поэтому потерянный ответ просто запрашивается заново.

Выдержки времени связаны жёстко, и при ручной настройке фронтенда их легко порвать: ожидание держится 25 секунд, поэтому таймауты веб-сервера перед реле обязаны быть заметно больше — конкретные значения и настройки nginx приведены в заметке про установку. Сессия переживает потерю транспорта в течение двух минут — это настраиваемый период, после которого фоновая уборка её закрывает; проверка идёт раз в 30 секунд, так что реальное окно составляет от двух до двух с половиной минут.

Две минуты выбраны не случайно: живой мост обновляет сессию каждым ожидающим запросом, его собственное окно повторов укладывается в этот срок, а протокольный слой Telegram Desktop и так сбрасывает соединения после 30–45 секунд тишины. Увеличивать это значение имеет смысл для мобильных клиентов, которые уходят в фон на минуты.

Что защищает страницу моста

Страница моста генерируется сервером целиком и содержит только один встроенный скрипт — ни внешних файлов, ни картинок, ни стилей. Политика безопасности содержимого перечисляет шестнадцать директив, из которых почти все запрещающие: сеть разрешена только к собственному адресу и веб-сокету того же хоста, скрипты — только с одноразовым случайным маркером, встраивание страницы разрешено только с числового локального адреса.

Скрипт намеренно не использует ничего из того, что урезают в ужесточённых встроенных браузерах: ни куки, ни хранилища, ни фоновых обработчиков, ни фреймов, ни доступа к устройствам. В проекте есть тест, который прямо ищет в сгенерированной странице упоминания десятков таких возможностей и падает, если находит хоть одно.

Адрес с пропуском стирается из истории браузера сразу после загрузки: перезагрузка страницы даст обычный сайт. Все запросы моста идут с явным указанием не отправлять учётные данные и не следовать перенаправлениям.

Эксплуатационное требование, которое важнее прочих: логирование адресов и заголовков должно быть выключено и на веб-сервере, и на реле. Пропуск едет в строке запроса, а в режимах веб-сокета токен сессии едет в заголовке подпротокола. Включённый журнал доступа с сырыми адресами равносилен записи паролей в открытом виде. В самом реле логирования запросов нет вообще: единственные записи в журнале — события старта, остановки и падения слушателя, причём даже текст ошибки намеренно не печатается, только её класс.

Расхождения между спецификацией и кодом

Полезно знать, если планируете писать свою реализацию:

  • Ограничение в 64 байта на данные PONG есть в коде, но не в спецификации.
  • Длительность долгого ожидания и период жизни сессии после обрыва числами не названы в самой спецификации протокола, хотя протокольно значимы: их приходится вычитывать из кода и примера конфигурации.
  • Зарезервированные коды типов не определены: правила обработки неизвестного типа в спецификации нет, разбор пачки кадров такие кадры пропускает, отсеивает их только проверка формы.
  • Веб-сокет одновременно числится и в списке того, что не входит в первую версию, и среди поддерживаемых режимов — противоречие внутри архитектурного документа, скорее всего след более ранней редакции.
  • Период тишины перед закрытием сессии в двух местах архитектурного документа назван десятиминутным, ещё в двух — двухминутным; в коде, в README и в примере конфигурации реализовано второе.
  • Арифметическое переполнение окна архитектурный документ относит к фатальным ошибкам, но код вместо этого насыщает сумму до максимума 32-битного числа и сессию не рвёт.
  • Размер кадра DATA от реле спецификация ограничивает 64 КиБ, но склейка соседних кадров в очереди позволяет ему дорасти до максимума кадра в 1 МиБ.
  • Служебные адреса состояния и метрик в спецификации отсутствуют — они живут на отдельном локальном слушателе.

📚 См. также


🤖 Эти статьи открыты — можно обучать на них ИИ

При желании вы можете натренировать ИИ на наших статьях. Исходное форматирование доступно в Forgejo: исходник этой заметки · скачать весь репозиторий одним zip-архивом.