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:
SETTINGS1: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-AgentChrome/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-AgentChrome. Его преамбула 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 на том же эхо-сервисе:
Dependabot обновляет wreq и wreq-util вместе с остальными крейтами cargo.
Когда релиз btls сможет предлагать ML-DSA или trust anchors, добавьте их в
профиль и измерьте заново.