Перейти к содержанию

HTTP-клиент

Каждая страница, которую отдаёт API, начинается с запроса к t.me. Кроме заголовков, сервер видит два отпечатка клиента: TLS ClientHello (JA3, JA4) и преамбулу соединения HTTP/2 (отпечаток Akamai: SETTINGS, WINDOW_UPDATE, приоритет и порядок псевдозаголовков). Если они указывают не на того клиента, которым представляется User-Agent, такой запрос заметить проще, чем запрос настоящего браузера или честной библиотеки.

Два клиента

TELEGRAM_CLIENT выбирает Fetcher:

browser (по умолчанию) reqwest
Библиотека wreq 0.16 + wreq-util 0.2, в rust/telegram-me-native pyreqwest
TLS BoringSSL, как в Chrome rustls
HTTP/2 SETTINGS, окно, приоритет и порядок заголовков профиля от hyper
Заголовки из профиля из профиля
Cookie хранятся в каждом воркере и отправляются обратно (Cookie) нет
Соединения пул HTTP/2, gzip/br/zstd/deflate то же

В продакшене работает browser; reqwest там не используется.

Оба берут заголовки из одного профиля: TELEGRAM_BROWSER_PROFILE на TELEGRAM_BROWSER_PLATFORM (по умолчанию chrome_156 на android) — User-Agent, sec-ch-ua*, accept, метаданные fetch и их порядок. Ни один заголовок не пишется под отдельный запрос, поэтому они не могут противоречить друг другу. Только у browser ещё и рукопожатие этого браузера.

Клиент отправляет то же, что браузер на странице канала:

  • навигацию (GET /s/<channel>, GET /<channel>): заголовки профиля с accept-language: ru;
  • XHR «загрузить ещё» (POST /s/<channel>?before=…): вызов jQuery со страницы, с x-requested-with, accept: application/json, …, sec-fetch-mode: cors, sec-fetch-site: same-origin, origin, referer, priority: u=1, i и порядком заголовков, который Chromium использует для него; порядок снят с браузера на Chromium 152 (Electron).

Наш профиль Chrome

Chrome теперь выходит раз в две недели; самый новый профиль wreq-util, chrome_149, отстаёт от стабильного канала на семь версий. На 2026-10-25 в стабильном канале был Chrome 156 (полная раскатка 2026-10-20; 157 раскатывали на несколько процентов, полностью — 2026-11-03). chrome_156 — наш собственный профиль этой версии, построенный на API эмуляции wreq в rust/telegram-me-native/src/http/chrome.rs:

  • TLS: 15 наборов шифров Chrome; группы X25519MLKEM768, X25519, P-256, P-384 с key share для первых двух; GREASE; перестановка расширений; ALPS для h2 с новым кодом (17613); сжатие сертификатов brotli; GREASE для ECH; запросы OCSP и SCT; возобновление через PSK.
  • HTTP/2: SETTINGS 1:65536, 2:0, 4:6291456, 6:262144, WINDOW_UPDATE на 15663105, псевдозаголовки m,a,s,p и приоритет HEADERS, как у Chrome: эксклюзивно от потока 0, вес 256. Профили Chrome из wreq-util отправляют вес 220, какого Chrome не отправляет.
  • Заголовки: порядок Chrome; User-Agent Chrome/156.0.0.0 (урезанная форма: Linux; Android 10; K на Android); sec-ch-ua с GREASE-брендом и порядком брендов, которые Chromium выводит из мажорной версии (для 156: "Not:A-Brand";v="8", "Chromium";v="156", "Google Chrome";v="156"); image/jxl в accept, который Chrome отправляет с тех пор, как декодирует JPEG XL (155).
  • ios отклоняется: Chrome на iOS — это WebKit на TLS-стеке Apple, так что рукопожатие BoringSSL противоречило бы его User-Agent.

Чего BoringSSL не умеет отправить

Chrome 155 отправляет ещё три вещи, которых wreq не может из-за собираемого им BoringSSL (btls-sys 0.5.6, версия API BoringSSL 37; wreq 0.16.1 и btls 0.5.6 — самые новые релизы):

  • Алгоритмы подписи ML-DSA 0x0904, 0x0905, 0x0906. Их имён нет в таблице алгоритмов подписи BoringSSL, а set_sigalg_prefs отвергает любой код, которого в ней нет: клиент с mldsa65 в списке не собирается, ошибка INVALID_SIGNATURE_ALGORITHM. wreq принимает список только именами, так что и сырые коды передать нельзя. Это заодно закрывает вопрос безопасности: сервер никогда не выберет алгоритм ML-DSA, который мы не смогли бы проверить, потому что мы не можем его предложить. t.me в любом случае отдаёт классические сертификаты (ECDSA).
  • GREASE-значение в signature_algorithms (Chrome ставит его первым; этот BoringSSL добавляет GREASE только в шифры, группы, версии и расширения).
  • Расширение trust_anchors (0xca34, TLS Trust Anchor IDs) с идентификаторами Chrome Root Store. В библиотеке на C оно есть (SSL_CTX_set1_requested_trust_anchors), но ни btls, ни wreq его не открывают, а крейт запрещает unsafe-код. Electron (Chromium 152) его тоже не отправляет; отправляет ли его Chrome на Android, не измеряли.

Из-за них последняя часть JA4 отличается от Chrome. Чтобы закрыть разницу, нужен релиз btls с более новым BoringSSL и API для trust anchors.

Отпечатки

Измерено 2026-10-25 через https://tls.peet.ws/api/all скриптом scripts/fingerprint.py (три соединения на строку, у каждого полное рукопожатие). Эталоны измерены в тот же день тем же сервисом: Google Chrome 155.0.8059.85 на macOS (headless, чистый профиль) и Chromium 152 в Electron (панель браузера настольного приложения). Chrome 156 для Android снять было не с чего.

Клиент JA4 Отпечаток Akamai для HTTP/2 Хеш Akamai Вес HEADERS
reqwest t13d1011h2_61a7ad8aa9b6_0d308c48d2a3 2:0;4:2097152;5:16384;6:16384\|5177345\|0\|m,s,a,p 9b5dcd077a77c5e324b91d8cc306fd5a —
browser, chrome_149 t13d1516h2_8daaf6152771_d8a2da3f94cd 1:65536;2:0;4:6291456;6:262144\|15663105\|0\|m,a,s,p 52d84b11737d980aef856699f885ca86 220
browser, chrome_156 t13d1516h2_8daaf6152771_d8a2da3f94cd 1:65536;2:0;4:6291456;6:262144\|15663105\|0\|m,a,s,p 52d84b11737d980aef856699f885ca86 256
Chrome 155 (эталон) t13d1517h2_8daaf6152771_cb7bf5808d99 1:65536;2:0;4:6291456;6:262144\|15663105\|0\|m,a,s,p 52d84b11737d980aef856699f885ca86 256
Chromium 152, Electron (эталон) t13d1516h2_8daaf6152771_806a8c22fdea 1:65536;2:0;4:6291456;6:262144\|15663105\|0\|m,a,s,p 52d84b11737d980aef856699f885ca86 256
  • reqwest предлагает 10 наборов шифров и 11 расширений, без GREASE, без ALPS и сжатия сертификатов: рукопожатие rustls под User-Agent Chrome. Его преамбула HTTP/2 — от hyper (окно 2 МиБ, псевдозаголовки m,s,a,p), такую Chrome никогда не отправляет.
  • browser — 15 наборов шифров, 16 расширений и преамбула HTTP/2 Chrome; часть JA4 с шифрами (8daaf6152771) и хеш Akamai совпадают с обоими эталонами. Хеши JA3 меняются от соединения к соединению, как у Chrome (он переставляет расширения), поэтому их не сравнивают; JA4 их сортирует.
  • Последняя часть JA4 — хеш расширений и алгоритмов подписи. У Chrome 155 среди 17 расширений есть 0xca34, а алгоритмы подписи начинаются с 0x0904,0x0905,0x0906; у Chromium 152 те же 16 расширений, что у browser, плюс алгоритмы ML-DSA. И то и другое — описанные выше ограничения BoringSSL, поэтому у chrome_156 то же рукопожатие, что у chrome_149.
  • Группы и key share — как у Chrome: GREASE, X25519MLKEM768, X25519, P-256, P-384, с key share для GREASE, X25519MLKEM768 и X25519.

Порядок заголовков навигации после псевдозаголовков у browser и у Chrome 155: sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform, upgrade-insecure-requests, user-agent, accept, sec-fetch-site, sec-fetch-mode, sec-fetch-user, sec-fetch-dest, accept-encoding, accept-language, priority. reqwest отправляет тот же порядок (псевдозаголовки m,s,a,p).

XHR «загрузить ещё» у browser и у Chromium 152: content-length, sec-ch-ua-platform, x-requested-with, user-agent, accept, sec-ch-ua, sec-ch-ua-mobile, origin, sec-fetch-site, sec-fetch-mode, sec-fetch-dest, referer, accept-encoding, accept-language, priority. reqwest отправляет заголовки навигации и дописывает в конец x-requested-with и content-length.

С cookie Chromium ставит cookie между accept-language и priority — и в навигации, и в XHR; browser делает так же.

Cookie

Что ставит t.me (проверено 2026-10-25):

  • Одну cookie, stel_ssid, на каждый вид наших запросов: страницу канала /s/<channel>, POST «загрузить ещё» и превью /<channel>. Атрибуты: path=/; samesite=None; secure; HttpOnly, срок жизни — 24 часа с момента выдачи.
  • Если её отправить обратно, t.me не ставит её заново, так что срок не продлевается. Неизвестное значение заменяется новым — значит, t.me его проверяет.
  • Без cookie каждый запрос выглядел как новый посетитель: t.me выдавал каждому новый stel_ssid.
  • t.me ещё и вписывает значение в страницы — в ссылки «Скачать Telegram» (telegram.org/dl?tme=<value>). Парсер эти ссылки не сохраняет; tests/unit/test_session_privacy.py проверяет, что ни один записанный токен не попадает ни в разобранные страницы (их и хранит кеш), ни в ответы.

Клиент browser держит хранилище cookie (хранилище wreq по RFC 6265, сортирующее cookie так же, как Chromium) и отправляет их обратно. Сессия — одна личность браузера: хранилище, соединения пула и их TLS-тикеты. Клиент заменяет её новой — с пустым хранилищем и новыми соединениями, — как только случится первое из этого:

  • проходит TELEGRAM_SESSION_LIFETIME, ±25 % случайно, чтобы воркеры всего парка не начинали заново одновременно (по умолчанию 4 часа, то есть от 3 до 5);
  • её cookie истекли или t.me их удалил;
  • t.me отвечает 4xx, кроме 404 (403 или 429 могут значить, что личность пометили; 404 — несуществующий канал).

Запросы в полёте завершаются в той сессии, в которой начались; её соединения закрываются после последнего из них. Первые запросы новой сессии, отправленные разом (обновление ленты), уходят без cookie и могут получить каждый свой stel_ssid; хранилище оставляет последний — как браузер, в котором открыли сразу несколько вкладок.

Одна личность на процесс-воркер, а не общая через Redis или слой пиров. t.me не связывает с cookie ничего, чем стоило бы делиться: страницы с ней и без неё одинаковые. Одна cookie на все воркеры парка была бы «браузером», который говорит сразу с нескольких IP-адресов, с таким же числом TLS-сессий и во много раз чаще браузера, — это меньше похоже на браузер, а не больше; и к запросам добавился бы лишний поход в Redis. Каждый воркер — один браузер, как если бы он работал на своей машине.

Cookie никогда не покидают нативный клиент: fetch возвращает только статус и тело, вывод Debug клиента не показывает хранилище, вызывающий код не может задать заголовок cookie, и ни одна строка лога его не содержит. Тесты на Rust (tests/http_client.rs) проверяют хранение и отправку cookie, их место в порядке заголовков, каждый способ завершения сессии и то, что cookie остаются внутри.

Скорость и память

Против живого t.me с одной машины (12 загрузок страниц подряд, затем 16 одновременно):

Первый запрос p50 на прогретом пуле 16 параллельно Пиковый RSS процесса
reqwest 246–296 мс 98–121 мс 172 мс* 58 МиБ
browser 180–281 мс 93–105 мс 160–178 мс 60–61 МиБ

* в одном из двух прогонов параллельная пачка заняла 2 с; медленным был t.me, а не клиент.

В Docker-образе с ENABLE_FEED=true после smoke-матрицы воркеры занимали 105–108 МиБ RSS с browser и 117–128 МиБ с reqwest. Запросы browser выполняются в общем рантайме tokio с двумя рабочими потоками.

Асинхронный мост

BrowserClient.fetch возвращает asyncio-future, созданный pyo3-async-runtimes: запрос выполняется в рантайме tokio без GIL; по завершении блокирующий поток один раз берёт GIL и передаёт результат через call_soon_threadsafe цикла событий. Отмена asyncio-задачи уничтожает Rust-future, а с ним и запрос. Это поддерживаемый мост PyO3, поэтому модуль не держит собственную интеграцию с циклом событий.

Сборка

BoringSSL — это C и C++, собираемые cmake (btls-sys); bindgen нужен libclang, а крейт накладывает свои патчи через git. Стадия сборки Docker ставит g++ make cmake clang libclang-dev git; в рабочий образ ничего из этого не попадает.

Холодная сборка, arm64 (нативно) Холодная сборка, amd64 (эмуляция на arm64) Образ
до 121 с 172 с 317,1 МБ (arm64), 69,7 МБ (amd64)
с wreq 196 с 386 с 324,4 МБ (arm64), 72,2 МБ (amd64)

В CI arm64 собирается под QEMU, как в release.yml. Холодная сборка этого образа на ubuntu-latest заняла 17 мин 26 с на main и 28 мин 12 с с wreq (+10 мин 46 с). Задание релиза собирает обе платформы за один прогон, поэтому релиз, который пересобирает крейт, станет примерно на столько же дольше. Кэш слоёв избавляет от этого релизы, не затрагивающие rust/.

Обновление профиля

Chrome выходит раз в две недели, и между версиями его рукопожатие меняется редко. Чтобы перейти на новый стабильный Chrome, увеличьте MAJOR и NAME в rust/telegram-me-native/src/http/chrome.rs (User-Agent и sec-ch-ua следуют за ними), затем значение по умолчанию TELEGRAM_BROWSER_PROFILE (settings, DEFAULT_BROWSER_PROFILE в telegram/client.py, три файла configuration.md, .env.example) и тесты, где оно упоминается. Прежде чем менять что-то ещё, сравните с настоящим Chrome на том же эхо-сервисе:

uv run python scripts/fingerprint.py --runs 3

Dependabot обновляет wreq и wreq-util вместе с остальными крейтами cargo. Когда релиз btls сможет предлагать ML-DSA или trust anchors, добавьте их в профиль и измерьте заново.