The Jerusalem PostUS military has lost 81 aircraft in Iran war according to Congressional reportRTP DesportoI Liga. Académico de Viseu-EstorilPunchTrump says Ukraine needs new president, blames Zelenskyy for diesel pricesESPN DeportesNo extrañan a Raphinha: Barcelona golea al Getafe y sigue con paso perfectoESPNFollow live: USWNT, Spain go head-to-head in friendlyBBC SportWatch Sportscene highlights of Saturday's Premiership actionZDF heuteAktuelle Pressemitteilungen des ZDFBBC MundoTrump dice que "es hora de que Ucrania tenga un nuevo presidente", tras las críticas de Zelensky al acuerdo sobre el diésel entre EE.UU. y RusiaNOSTot zeker zondagavond geen treinen tussen Rotterdam en Delft na brandstichtingColliderNetflix's 'East of Eden' Adaptation Is Even Better When It Goes Beyond the Book7sur7Evenepoel: “J’ai du mal à retrouver ma meilleure forme depuis le Tour de France”Rai NewsTrump: “Kiev trovi un nuovo leader che faccia un accordo”. Strage a Zaporizhzhia: 20 morti
The Daily Newsstand · Free, Always
Saturday, October 10, 2026

«Яму» убрали, обрывы остались: один живой адрес Telegram и молчаливый лимит на 13 соединений

Translate
Обложка: тринадцать линий к одному адресу

Тринадцать одновременных соединений к одному адресу: семь доходят, шесть гаснут без ответа

Предыдущий разбор закончился тем, что из клиентского пути убрали периодическую яму: раз в десять минут прокси проверял мёртвую первую ступень, клиент оплачивал эту проверку несколькими секундами ожидания и уходил, не получив ни одного байта. Окна исчезли, сообщение «прокси использует некорректные настройки» больше не появлялось — а через несколько часов телефон снова начал терять соединения. Лог при этом выглядел безупречно: ни одной строки ERROR за девять часов, ни одного перезапуска процесса, 2 415 клиентских сессий и 482 МБ скачанного трафика.

Дефект нашёлся по подписи из четырёх чисел: сессии, которые апстрим закрывал ровно за 0.1 с, после ~800 байт вверх и 8–28 байт вниз, и все до единой — на прямой ступени. Причина оказалась не в прокси, а в том, как из этой сети устроен путь к Telegram: из 1 270 проверенных адресов мессенджера по TCP отвечает ровно один, и это не дата-центр, а служебный узел, к которому к тому же действует молчаливый лимит на одновременные соединения. Телефон открывает пачку из тринадцати сокетов — семь проходят, шесть гаснут без единого ответного пакета. Ни один лог приложения такого не покажет.

Дальше — как это измерено, почему одна ступень Cloudflare не является резервом, что дала смена маршрута за девять часов работы и где границы этого результата.

Коротко: что выяснилось

  • «Яму» от cooldown — паузы, на которую отказавшая ступень выпадает из лестницы, — убрали в прошлый раз. Обрывы не прекратились, потому что под ней лежал второй, независимый дефект — и он не в конфигурации, а в сети.

  • Симптом: телефон молча терял соединения. Ни сообщения, ни ошибки в логе — только строки сессий, которые закрыл не клиент.

  • Подпись дефекта: 47 сессий, закрытых апстримом за 0.1 с, после ~800 Б вверх и 8–28 Б вниз, 100 % на прямой ступени, только два телефона. Контроль: на ступени Cloudflare — 0 из 1 137 за те же часы.

  • Карта адресов: из 1 270 адресов Telegram в пяти его сетях TCP отвечает один — 149.154.167.220. Это A-запись td.telegram.org, служебный узел (живой nginx, сертификат *.telegram.org), а не запасной дата-центр. Все прочие адреса молчат: SYN уходят и не возвращается ни SYN-ACK, ни RST, ни ICMP.

  • Лимит: 13 параллельных соединений к этому адресу — проходят 7, шесть гаснут молча. Ни RST, ни ICMP, ни строчки в логе; у погасших попыток — по пять ретрансмиссий SYN и таймаут.

  • Вторая ступень, cfproxy, существовала в бинарнике и просто не была включена. Двадцать доменов сообщества отвечают 101 Switching Protocols для всех DC, включая те, у которых прямого пути нет вовсе.

  • Фикс — две строки в конфиге плюс правка враппера, которая ничего не меняет сама по себе. Приёмка: девять часов, 2 415 сессий, 0 на прямой ступени, 0 коротких закрытий, 0 отказов сырого TCP, нулевых сессий 21 (0.9 % против 4.3 % до правки).

  • Чего этот разбор не доказывает: какое именно устройство отбрасывает пакеты; было ли исключение td.telegram.org намеренным; как это ощущается на самом телефоне (структурированного теста на клиенте не проводилось).

Что осталось после первого разбора

Первая статья была про механизм: раз в десять минут прокси уходил проверять мёртвую первую ступень, клиент платил за эту проверку N × --ws-connect-timeout — при тогдашних значениях это 4.5–8.8 с ожидания — и закрывал соединение, не получив ни байта, а Telegram показывал «прокси использует некорректные настройки» и выключал прокси. Лечилось двумя значениями в конфиге: cooldown вернули к дефолту демона (3600 с), таймаут уменьшили до 1 с. Максимальная задержка клиентской пробы упала с 8 849 мс до 531 мс, обрывы с нулём байт у телефона — до нуля, и на этом тот разбор закончился.

Речь о Rust-форке valnesfjord/tg-ws-proxy-rs (у апстрима Flowseal/tg-ws-proxy реализация на Python); он запущен на домашнем роутере Netcraze NC-1812 под OpenWrt 25.12.2 (aarch64, ядро 6.12), под procd в песочнице ujail, параметры читаются из /etc/tg-ws-proxy.env через враппер /usr/libexec/tg-ws-proxy/run.sh, лог — /var/log/tg-ws-proxy.log, слушает 192.168.1.1:1443. За роутером телефоны по Wi-Fi и два ПК по кабелю.

До Telegram прокси ведёт каждое клиентское соединение по лестнице из пяти ступеней: прямое WebSocket-подключение к адресу дата-центра, Cloudflare Worker, Cloudflare-прокси (cfproxy), внешний MTProto-прокси и последним — сырой TCP на 443-й порт; берётся первая работающая ступень, а ненастроенные пропускаются целиком. Дата-центры Telegram в логе нумеруются как DC1…DC5, у медиа-соединений к номеру добавляется m (фото, видео, файлы ходят в отдельные дата-центры). Для дата-центров, к которым клиенты ходят постоянно, прокси заранее держит пул из четырёх подключённых WebSocket-соединений — попадание в него избавляет клиента от рукопожатия. Правила лестницы и ступеней описаны в документации форка (docs/Fallbacks.md), механика второй ступени — в docs/CfProxy.md).

Дальше началось то, что интереснее: телефон продолжал терять соединения, но уже без сообщения. Пользователь видит просто «сообщение не отправилось», а в логе при этом нет ни ошибки, ни перезапуска, ни отказа: 2 415 сессий за девять часов, все — через Cloudflare, ноль на прямой ступени, 482 МБ трафика. Прокси не сделал ничего неправильного. Страдал клиент.

Превратить «иногда отваливается» в измеримую величину помогла наблюдаемость. На каждую клиентскую сессию лог печатает две строки: какой ступенью она пошла, и чем закончилась — с байтами в обе стороны и длительностью. Этого достаточно, чтобы разделить «клиент передумал» и «сервер оборвал»: у первого ноль байт вниз и произвольное время жизни, у второго — характерные объёмы и тайминги. Отдельная удача — что этот лог вообще есть: /var на роутере это симлинк на /tmp, то есть лог живёт в памяти и стирается при перезагрузке, поэтому его приходится постоянно выносить наружу. Прошлый инцидент был потерян именно так.

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

Дефект, у которого есть подпись

Единственное, что отличало «плохие» сессии от всех остальных, — четыре числа. Вот несколько строк из лога, в исходном виде (лог печатает UTC; в тексте время везде по Новосибирску — НСК, UTC+7; здесь 08:23 НСК):

01:23:14.592 INFO [192.168.1.194:48602] DC4m → WS connected via 149.154.167.220
01:23:14.703 INFO [192.168.1.194:48602] DC4m WS session closed by upstream: ↑4.8KB  ↓68.0B  0.1s
01:23:16.845 INFO [192.168.1.194:48620] DC2 WS session closed by upstream: ↑268.0B  ↓28.0B  0.1s
01:23:24.418 INFO [192.168.1.194:48652] DC4m WS session closed by upstream: ↑4.5KB  ↓8.0B  0.1s

Ступень — прямая. Жизнь — десятая доля секунды. Вверх ушло несколько сотен байт или килобайты, вниз вернулось от 8 до 68 байт. И так сорок семь раз за утро:

Свойство

Значение (окно до правки, n = 47)

Ступень

прямая: direct 30 + direct-pool 17 — 100 %

Устройства

192.168.1.194 — 42, 192.168.1.169 — 5; два телефона, ни одного ПК

Вверх

медиана 872 Б, разброс 122 Б … 21.9 КБ

Вниз

8–28 Б (медиана 12 Б)

Время жизни

0.100 с — у всех сорока семи без исключения

Дата-центры

DC2 — 33, DC4 — 14

Подпись проверяется контролем, который стоит дороже всего в любой диагностике: те же устройства, те же минуты, другая ступень. У телефона 192.168.1.194 на прямой ступени отбраковано 42 сессии из 191 (22 %), у второго телефона — 5 из 10 (50 %). У тех же телефонов через Cloudflare — 0 из 657 и 0 из 29. По всему окну: 0 из 1 137 сессий на ступени Cloudflare против 47 из 337 на прямой. Это и есть разница между «вероятно, DPI» и «ступень».

Отбраковка сессий по устройствам и ступеням

Доля сессий, которые апстрим закрыл быстрее чем за полсекунды, отдав меньше 64 байт: два телефона на прямой ступени против нуля на Cloudflare

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

Ложные друзья. За то же утро в логе есть сотни закрытий «апстримом» с объёмами 200–300 Б вниз, и они выглядят похоже, если смотреть только на слово upstream. Это не дефект, а серверный таймаут простоя: прокси не держит WebSocket-keepalive, и простаивающее соединение закрывает сервер.

01:22:11.044 INFO [192.168.1.60:54716] DC203 WS session closed by upstream: ↑446.0B  ↓276.0B  270.3s
01:23:05.771 INFO [192.168.1.60:54602] DC2   WS session closed by upstream: ↑151.0B  ↓253.0B  91.8s

Две характерные длительности — 91.8 с (DC2) и 270.3 с (DC203) — повторяются с точностью до десятых: за окно до правки таких закрытий 16 и 85 соответственно. Данные в этих сессиях уже прошли, клиент мгновенно переподключается, ноль байт вниз там не бывает. Похоже на дефект по слову в логе, но по числам это нормальный конец долгой сессии. Отличать помогает та же подпись: меньше половины секунды и меньше 64 байт вниз, а не «закрыто апстримом» само по себе.

Что чувствует пользователь. Инцидент 11:57:27 виден в логе как 24 сессии, закрытые в пределах 100 мс, из них 19 — на прямой ступени: адрес 149.154.167.220 на несколько секунд перестал отвечать, сессии, стоявшие на нём, кончились, прокси перевёл клиентов на Cloudflare, клиенты переподключились. Снаружи это «Телеграм отвалился, потом сам заработал». Массовые закрытия бывают и здоровыми: после правки их 104, и ни в одном нет ни нулевой сессии, ни короткого закрытия — это приложение закрывает пачку сокетов (смена сети, уход в фон), а в девятнадцати кластерах к клиентским закрытиям добавляется серверный таймаут простоя. Поэтому детектор «много закрытий в одну миллисекунду» без разбора байт и ступени даёт ложные срабатывания — считать надо подпись, а не время.

Клиентская проба через прямую ступень в этом окне отвечала примерно за 500 мс и отказывала примерно в одной попытке из трёх. Само TCP-соединение до .220 при этом поднималось за ~0.1 с: остальное время уходило на TLS, WebSocket-апгрейд и сам обмен MTProto. А сырой TCP-хвост (последняя ступень лестницы, соединение напрямую с IP дата-центра) за утро стоил 13 заходов по десять секунд каждый: двенадцать закончились ничем, в одном клиент успел получить 164 байта. Это и был второй дефект: 47 отбраковок на 337 сессий — это не «иногда», это каждое пятое соединение телефона.

Карта: из десяти адресов отвечает один

Прямая ступень ведёт на адрес из конфигурации. Прежде чем что-то с ней делать, требовалось понять, что вообще достижимо с этой линии. Проверка простая: взять имена, которые Telegram отдаёт по DNS, и адреса из его собственных сетей, и попробовать TCP-соединение на 443-й порт.

Адрес

SNI

TCP

TLS

Ответ HTTP

Вывод

149.154.167.220

kws2 / kws4

ок (мигает)

ок

101 Switching Protocols

работает — но только для DC2/DC4

149.154.167.220

kws1 / kws3 / kws5

ок

ок

302 Found

этот адрес другие DC не обслуживает

149.154.167.99

kws2 / kws4

таймаут

—

—

а именно его отдаёт DNS

149.154.174.100

kws1 / kws3

таймаут

—

—

тоже из DNS

149.154.170.100

kws5

таймаут

—

—

тоже из DNS

149.154.175.50, .175.100, 91.108.56.130

—

таймаут

—

—

классические адреса DC1/DC3/DC5

Карта эндпоинтов Telegram с этого стенда

TCP, TLS и WebSocket до адресов Telegram: зелёный — работает, красный — молчание, жёлтый — отказ уровня приложения

302 Found в этой таблице — важная деталь, из-за которой легко сделать неверный вывод. Адрес живой, TLS проходит, HTTP-сервер отвечает — но отказывается обслуживать чужие дата-центры. Это не «сломанный эндпоинт», а «этот IP не для них». Практическое следствие: для DC1, DC3 и DC5 прямой ступени не существует в принципе, и добавлять их в --dc-ip бессмысленно — прокси будет открывать соединение, которое не может закончиться успехом.

Дальше — масштаб. Пять сетей Telegram (149.154.167.0/24, 149.154.174.0/24, 149.154.170.0/24, 149.154.175.0/24 и 91.108.56.0/24) — это 1 270 адресов; по три попытки TCP:443 на каждый:

Сеть

Проверено адресов

Живых

149.154.167.0/24

254 × 3

только 149.154.167.220 (3/3, 99–102 мс)

149.154.174.0/24

254 × 3

нет

149.154.170.0/24

254 × 3

нет

149.154.175.0/24

254 × 3

нет

91.108.56.0/24

254 × 3

нет

Один живой адрес из 1 270 — и он лежит в той же /24, что и 149.154.167.99, который DNS отдаёт для фронтендов DC2/DC4: одна и та же сеть, разная судьба. Оставшиеся 23 239 адресов объявленного пространства Telegram проверены по одному разу — не ответил никто. Однократная проверка, впрочем, нечувствительна: первый проход по тем же 1 270 адресам не нашёл вообще ничего, включая .220. Одиночная попытка на таком пути ничего не доказывает.

Куда деваются пакеты. Съёмка на внешнем интерфейсе роутера (WAN) во время серии подключений снимает вопрос «а может, они не уходят»:

Назначение

SYN наружу

SYN-ACK обратно

RST

FIN

ICMP

149.154.167.99:443

15

0

0

0

0

149.154.167.99:80

15

0

0

0

0

149.154.174.100:443

15

0

0

0

0

149.154.175.50:443

13

0

0

0

0

91.108.56.130:443

11

0

0

0

0

149.154.167.220:443

7

2

0

2

0

Пакеты уходят с внешнего интерфейса и не возвращается ничего: ни ответа, ни отказа, ни сообщения о недостижимости. Это не «нет маршрута» и не «TCP-порт закрыт» — это тишина. Причины на своей стороне проверены по списку: правила файрвола (ни одно не упоминает сети Telegram), очередь обхода DPI (она видит только 443-й порт, а адреса мертвы и на 80, 5222, 8443, 8080), политики маршрутизации (только дефолтные), DNS (обычный UDP-резолвер отдаёт те же адреса, что DoH; блокировка сохраняется при подключении по голому IP без всякого SNI). traceroute здесь бесполезен: рабочий адрес выглядит ровно так же, как заблокированный, — на этой линии ICMP TTL-exceeded не приходит ни от одного узла на пути к Telegram.

Взгляд снаружи. Те же «мёртвые» адреса проверены внешними узлами, по десять узлов на адрес:

Те же адреса живы почти везде

Проверка TCP:443 внешними узлами: из Европы, США и Азии адреса отвечают, из России — нет

149.154.167.99 отвечает узлам Испании, Венгрии, Израиля, Турции и США и не отвечает российскому и иранским. 149.154.175.50 (DC1) и 91.108.56.130 (DC5) живы, например, из Швейцарии, Израиля, Японии, Молдовы, Швеции, Германии, Нидерландов, Украины и Австралии — и мертвы из России и Ирана. Российский узел проверки (Москва, AS14576) воспроизводит ту же картину на другом операторе: .99 мёртв, .220 жив. Значит, это не особенность этого роутера и не местный провайдер.

Списки. Ни один из семи адресов, включая .220, не найден ни в зеркале реестра блокировок (52 720 записей на 9 октября), ни в пяти выгрузках первичного реестра (~330 тыс. строк). В диапазоне 149.154.0.0/16 в зеркале есть ровно шесть адресов, и ни один из них не принадлежит инфраструктуре Telegram. То есть блокировка идёт не через публичный реестр, а на стороне оператора, оборудованием класса ТСПУ (технические средства противодействия угрозам — их операторам предписано ставить на своих сетях), у которого собственный набор адресов.

Откуда взялся единственный живой адрес

149.154.167.220 — не «какой-то уцелевший IP». Это A-запись td.telegram.org: имя Telegram для раздачи Telegram Desktop. Проверено тремя резолверами (Google, Яндекс, локальный AdGuard) — один и тот же ответ, TTL 300, повторы стабильны. Хост живой: https://td.telegram.org/ отвечает 403 от nginx, сертификат — CN=*.telegram.org. Исторически этот же адрес обслуживал Bot API: самые старые публичные упоминания строки — сообщения о таймаутах ботовских библиотек 2018 года, а в 2026-м он числится в списке запасных адресов api.telegram.org.

Он же отвечает 101 Switching Protocols на WebSocket-апгрейд для виртуалхостов kws2/kws4 — то есть служебный узел заодно годится в качестве фронтенда мессенджера. Сообщество нашло это раньше и независимо: публичный hosts-файл 2026 года подставляет .220 всем веб-именам Telegram сразу (это DNS-обход IP-блокировки), а обе реализации прокси — и Python-апстрим, и Rust-форк — везут пару {2: 149.154.167.220, 4: 149.154.167.220} в значении по умолчанию.

Картина, которая из этого складывается: набор адресов фильтра перечисляет мессенджер — опубликованные адреса дата-центров и веб-фронтенды kws*, — а служебный узел раздачи и Bot API в набор не попал. Это формулировка, которую показывают данные. Утверждать, что узел исключили намеренно, оснований нет.

Слабое место единственного адреса. У .220 есть собственные потери TCP, и это отдельный дефект, который нельзя приписывать фильтру. В замерах: 11 успешных подключений из 15 с паузами по 4 с (73 %), 11 из 20 подряд (55 %), на 80-м и 443-м портах — по 13 из 20 (65 %), при этом ICMP к тому же адресу — 10 пакетов из 10. Потери идут эпизодами по две-три подряд, одновременно на обоих портах. Порты тут важны как контроль: правила обхода DPI на этом стенде ставят в очередь только 443-й, а 80-й теряет ровно столько же, — значит, дело не в обходе. Механизм не установлен (ограничение частоты SYN на стороне самого хоста и частично применяемый фильтр одинаково согласуются с данными), и в «Границах результата» это записано отдельным пунктом.

Ловушка метода. Пробник, который не отправляет заголовок Sec-WebSocket-Protocol: binary, получает от .220 ответ 404 Not Found и делает вывод «эндпоинт умер». С тем же заголовком приходит 101. Любая проверка такого эндпоинта обязана повторять набор заголовков клиента целиком — иначе измеряется не сеть, а собственная ошибка.

Молчаливый лимит

Адрес, до которого нельзя дотянуться, — это половина картины. Вторая половина выяснилась, когда к .220 начали подключаться не по одному, а пачкой. Три серии параллельных подключений — 13, 20 и снова 13 сокетов одновременно:

Пачка

Прошло

Погасло молча

13 одновременных

7

6

20 одновременных

13

7

13 одновременных (повтор)

8

5

У прошедших всё нормально: TCP устанавливается за 324–356 мс, ответ resPQ приходит за 530–621 мс, соединение закрывается штатно. У погасших не происходит вообще ничего.

Лимит одновременных соединений

Три пачки параллельных проб: проходят 7–13 соединений, остальные не получают ни одного ответного пакета

Съёмка на WAN во время двух последних пачек показывает механизм до пакета:

Наблюдение

Значение

SYN наружу

81

SYN-ACK обратно

21

Попыток, не получивших ни одного ответа

12

Ретрансмиссий SYN у каждой такой попытки

5 (исходный и четыре повтора через 1, 3, 7 и 15 с)

RST со стороны адреса

0

ICMP (любого типа, в обе стороны)

0

Установившиеся соединения

закрыты по FIN через 0.48–0.58 с, отдав ~6.9 КБ (TLS + WebSocket + resPQ)

То есть адрес принимает примерно 7–13 одновременных соединений, а всё сверх этого проглатывает без ответа: ни RST (порт закрыт), ни ICMP (хост недостижим), ни таймаута на уровне приложения — просто тишина, в которой операционная система клиента честно повторяет SYN пять раз, а потом сдаётся. Соединения, попавшие в лимит, при этом работают идеально.

Почему этого не видно в логах. Прокси не знает, что SYN проглотили: для него это таймаут подключения, то есть «сеть не ответила». Клиент не знает тем более: приложение видит, что соединение не поднялось, и повторяет. Ни одна из сторон не фиксирует событие, которое можно посчитать, — в отличие от RST, который оставил бы в логе внятный след. Отсюда и странность поведения: чем активнее телефон, тем хуже ему становится, а суммарные счётчики ошибок молчат.

Почему страдал именно телефон. Мобильный Telegram открывает пачку соединений сразу — под медиа, под несколько дата-центров, под фоновые задачи; в этом окне типичная пачка — около тринадцати сокетов. Настольный клиент держит два-четыре долгих соединения и в лимит не упирается. Это ровно та асимметрия, которую видно в логе: 42 отбраковки у телефона, ноль у обоих ПК.

Оговорка про слово «лимит». Измерено именно поведение: одновременно проходят 7–13, остальные гаснут. Кто и на каком оборудовании считает соединения — отсюда не видно. Проверено, что это не блокировка по имени: подключение идёт по голому IP, без SNI, и всё равно упирается в потолок. И что счётчик стоит не на этом роутере: съёмка показывает, что SYN уходят с внешнего интерфейса, а обратно не приходит ничего.

Вторая ступень, которой не было

К этому моменту картина была такая: единственная живая ступень до Telegram стоит на одном адресе, у адреса есть лимит на одновременные соединения, а вторая ступень — Cloudflare Worker — работает и лимита не имеет. Резервом это не является: один домен *.workers.dev, один аккаунт Cloudflare, одна точка отказа. Плохо и то, что третьей ступенью в лестнице стоял сырой TCP на мёртвые адреса — то есть за отказом Worker’а шли не альтернативный путь, а десять секунд ожидания.

И тут выяснилось, что альтернативный путь всё это время лежал в бинарнике. У прокси есть ступень cfproxy: WebSocket на wss://kws{N}.{домен}/apiws, где {домен} — обычный домен в чьей-то зоне Cloudflare, у которого A-записи указывают на адреса дата-центров Telegram. Cloudflare терминирует TLS и передаёт поток к источнику как обычный HTTP. Нужен только домен: ни аккаунта, ни воркера, ни кода.

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

Проверка заняла минуты. Встроенный режим проверки доменов дал двадцать доменов и двадцать [OK] за 328–395 мс. Дальше — байт-в-байт повтор рукопожатия прокси против трёх доменов и четырёх дата-центров:

Дата-центр

Домен-пример

Ответ

Задержка

DC1

kws1.pclead.co.uk

101 Switching Protocols

494–519 мс

DC2

kws2.pclead.co.uk

101 Switching Protocols

290–300 мс

DC3

kws3.pclead.co.uk

101 Switching Protocols

501–514 мс

DC5

kws5.pclead.co.uk

101 Switching Protocols

649–794 мс

Двенадцать из двенадцати. Существенно здесь не то, что ступень работает, а для каких дата-центров она работает: DC1, DC3 и DC5, у которых прямого пути нет вообще (302), через неё проходят насквозь. Это и есть второй независимый путь к Telegram: другие имена, другой аккаунт, никакого workers.dev.

Цена доверия, которую стоит назвать вслух: домены принадлежат третьим лицам — это чужие зоны Cloudflare, и список сообщества меняется. Через чужую зону проходит установление соединения (метаданные: кто, когда, к какому дата-центру), но не содержимое: MTProto остаётся обфусцированным от клиента до дата-центра, Cloudflare терминирует только внешний TLS. Для этого разбора обмен сознательный: альтернатива — единственный адрес и лимит на нём.

Двадцать доменов, которые были в списке на дату замера (публичный список проекта, приводится как есть):

pclead.co.uk            offshor.co.uk          cakeisalie.co.uk       noskomnadzor.co.uk
lovetrue.co.uk          sorokdva.co.uk         pyatdesyatdva.co.uk    kartoshka.co.uk
sorokodin.co.uk         pyatdesyatodin.co.uk   notelega.co.uk         ebally.co.uk
nebally.co.uk           havegreatday.co.uk     pomogite.co.uk         fixtelega.co.uk
sadnews.co.uk           onedaychamp.co.uk      stopblocking.co.uk     nothingthere.co.uk

Список меняется (его ведёт сообщество), и проверять его перед использованием нужно заново — команда для этого есть в разделе про диагностику.

Фикс: две строки и подготовленный откат

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

# /etc/tg-ws-proxy.env (фрагмент; секрет и прочие значения опущены)
TGWS_DEFAULT_DOMAINS="true"
TGWS_PINNED_UPSTREAM="cfworker,cfproxy,ws"

Что

Было

Стало

Откат

Ступень cfproxy

не настроена

TGWS_DEFAULT_DOMAINS="true" (20 доменов, список тянется при старте)

удалить строку, перезапустить сервис

Порядок лестницы

по умолчанию (прямая → Worker → cfproxy → MTProto → сырой TCP)

TGWS_PINNED_UPSTREAM="cfworker,cfproxy,ws"

удалить строку, перезапустить сервис

Про пин нужно сказать точнее, потому что от его семантики зависит всё остальное. --pinned-upstream фиксирует порядок ступеней списком, и перечисленный порядок и есть цепочка фолбэка: отказавшая ступень передаёт соединение следующей. Ступени, не попавшие в список, не пробуются вообще. Класс трафика, у которого отказали все перечисленные, роняется — и это здесь важнее, чем кажется: именно поэтому из клиентского пути исчез сырой TCP-хвост, который до правки стоил по десять секунд на заход. И третья деталь: медиа-класс (фото, видео, файлы — дата-центры с отрицательным индексом) ходит по тому же пину, потому что отдельный --pinned-media-upstream не задавался.

Состав списка стоит оговорить отдельно: в первой статье как вариант упоминался пин cfworker,cfproxy,mtproto,ws,tcp, здесь же ступеней три. Внешний MTProto-прокси на этой инсталляции не настроен, а сырой TCP к мёртвым адресам добавляет только десять секунд ожидания — и то, и другое в клиентском пути лишнее.

Почему правок две, а не одна. Пин, называющий ступень, которая не настроена, не даёт демону стартовать. Поэтому cfworker,cfproxy,ws без включённого cfproxy — это не «конфигурация без резерва», а нерабочий сервис. Две строки имеют смысл только вместе.

И почему правка враппера. В этой сборке враппер (run.sh, который procd запускает внутри песочницы) не прокидывал в командную строку ни --cf-domain, ни --default-domains, ни --pinned-upstream: переменные окружения читались, но до демона не доходили. Добавлены четыре блока по образцу соседних — каждый передаёт флаг, только если переменная непустая, так что сам по себе дифф ничего не меняет.

Дифф враппера: четыре блока, 24 строки
# --- добавлено (фрагмент run.sh) ---------------------------------------------
# Cloudflare-proxied domain(s) for the cfproxy tier (comma-separated).
if [ -n "${TG_CF_DOMAINS:-}" ]; then
  set -- "$@" --cf-domain "$TG_CF_DOMAINS"
fi

# Use the upstream default Cloudflare-proxy domain list.
if [ "${TGWS_DEFAULT_DOMAINS:-false}" = "true" ]; then
  set -- "$@" --default-domains
fi

# Pinned upstream tier order, e.g. cfworker,cfproxy,ws.
if [ -n "${TGWS_PINNED_UPSTREAM:-}" ]; then
  set -- "$@" --pinned-upstream "$TGWS_PINNED_UPSTREAM"
fi

# Same for media connections (photo/video/file DCs).
if [ -n "${TGWS_PINNED_MEDIA_UPSTREAM:-}" ]; then
  set -- "$@" --pinned-media-upstream "$TGWS_PINNED_MEDIA_UPSTREAM"
fi

Про версии: правка относится к конкретной сборке форка (в ней враппер не знал этих переменных). В более новых версиях пакета набор переменных может отличаться — проверять run.sh перед тем, как что-то включать.

Сознательно не делалось: патч форка (обе возможности уже есть), удаление прямой ступени из конфигурации (она осталась третьей в списке — как последний шанс, а не как рабочий путь) и правка --dc-ip для DC1/DC3/DC5 (их фронтенды отдают 302, добавлять их в прямую ступень — значит гарантированно открывать соединение, которое не может закончиться успехом).

Авария, которую стоит описать. Между двумя правками потребовалось на несколько минут вернуть прежний порядок ступеней, чтобы снять пакеты прямой ступени. Файл /etc/tg-ws-proxy.env был отредактирован на месте, командой sed -i — а busybox-версия sed -i не правит файл, а заменяет его новым. Новый файл получил другие права, и песочница ujail перестала его читать:

ujail: /usr/libexec/tg-ws-proxy/run.sh: .: line 10: can't open '/etc/tg-ws-proxy.env': Permission denied
procd: Instance tg-ws-proxy::instance1 in a crash loop 6 crashes

Прокси не поднимался 64 секунды — в приложениях это выглядело как «Подключение…». Лечится восстановлением прав и перезапуском. Правило, которое из этого следует и которое дешевле любого объяснения: этот файл не редактируется на месте. Сначала копия, потом chmod/chown по образцу (ls -la до и после), потом замена копированием, и сразу проверка статуса сервиса и одной живой пробы. Цена ошибки — не в минуте простоя, а в том, что причину ищут не там: в момент аварии сервис выглядел как «прокси, который не читает свой конфиг».

Если второй ступени нет

Вторая ступень не появляется сама: её либо берут из списка сообщества, либо поднимают на своём домене. Есть три варианта, и все они дешевле, чем ещё один разбор обрывов.

  1. Встроенный домен. Достаточно --default-domains, и прокси возьмёт список домена сообщества. Плюс: ноль усилий. Минус: при старте он ходит на GitHub за списком, а домены принадлежат третьим лицам и меняются.

  2. Свой домен в Cloudflare. Нужен домен (свой или дешёвый новый), переведённый на Cloudflare, и A-записи kws1…kws5 на адреса дата-центров Telegram: пять записей в панели, TLS-режим Flexible, никакого кода. Такой путь не зависит ни от списка сообщества, ни от workers.dev, и он же снимает главную проблему встроенного домена — лимит Cloudflare на одновременные WebSocket-соединения на одно имя.

  3. Ничего. Тоже вариант, но тогда резервом остаётся один Worker, то есть один аккаунт, одно имя и один класс отказа. Именно эта конфигурация и привела к разбору: единственная живая ступень стояла на одном адресе с молчаливым лимитом.

Проверка любого из вариантов — одна команда и минута времени: --check --default-domains (или --check --cf-domain <домен>) показывает список доменов и статус каждого. Если хотя бы один отвечает — ступень рабочая.

Приёмка: девять часов

Окно приёмки — с момента подъёма прокси после правки: 13:02:28 → 22:02:28 НСК, ровно девять часов, 2 415 закрытых клиентских сессий. Критерии сформулированы до правки и не менялись:

#

Критерий (задан заранее)

Результат за девять часов

Вердикт

1

ноль клиентских сессий на прямой ступени

0 из 2 415 (2 399 — Cloudflare, 16 — cfproxy)

выполнен

2

ноль коротких закрытий апстримом (тех самых: до половины секунды и не больше 64 байт вниз)

0 (до правки: 47, все на прямой ступени)

выполнен

3

ноль отказов сырого TCP

0 (до правки: 13 заходов, каждый около 10 с)

выполнен

4

клиентская проба исправна

47/47 OK за окно, отказов нет

выполнен

5

не появилось новых нулевых сессий

21 из 2 415 (0.87 %), все 21 закрыл клиент (до правки: 64 из 1 475, 4.3 %)

выполнен

Приёмка: девять часов на закреплённой лестнице

Сессии по часам: ни одного сегмента прямой ступени и ни одного сегмента сырого TCP за девять часов

За окно скачано 482 МБ (вверх 8.3 МБ), шесть устройств: десктоп 192.168.1.60 — 1 357 сессий, ПК 192.168.1.50 — 562, телефоны — 317, 116, 37 и 26. Смешение по дата-центрам:

DC

Сессий

Комментарий

DC2

1 157

DC203

1 066

отдельный номер в логе: тестовый/альтернативный дата-центр Telegram; для WebSocket прокси обрабатывает его как DC2, а через cfproxy ведёт на отдельное имя kws203

DC4

153

DC1

27

прямого пути нет: на имена этих дата-центров .220 отвечает 302

DC5

12

то же

Про DC1 и DC5 легко сделать неверный вывод. Они не «заработали» после правки — у них никогда не было прямой ступени, они и раньше ездили через Cloudflare. Изменилось другое: раньше их лестница заканчивалась тупиком — сырым TCP на мёртвый адрес по десять секунд, и в логе это было видно как every fallback failed 26 раз за утро. После правки таких событий ноль: класс, у которого отказали все перечисленные ступени, роняется сразу, а не платит десять секунд.

Про ноль байт. Считать эту категорию нужно с оговоркой, потому что в неё легко случайно включить другое. Здесь считаются сессии, в которых клиент не получил ни одного байта, и это не то же самое, что сырой TCP-хвост: двенадцать сессий сырого TCP по определению не переносят данных, у них даже соединение не устанавливается. Без них (1 487 − 12 = 1 475): до правки 64 из 1 475 (4.3 %), после — 21 из 2 415 (0.87 %), и все 21 закрыл сам клиент. Ни одного случая, когда бы апстрим оборвал сессию меньше чем за две секунды, за девять часов не было ни разу — ни с нулём байт, ни с любым другим объёмом.

Ложная тревога, которую стоит объяснить заранее. В окне 1 184 закрытия «апстримом» — и это не возврат дефекта. У 1 024 из них длительность ровно двух видов: 270.3 с (902 сессии) и 91.8 с (122) — это серверный таймаут простоя, в логе у них 200–300 байт вниз, то есть данные в сессии уже прошли. Остальные 160 — сессии возрастом от десяти секунд до четырёх часов, тоже с реальными объёмами (от 185 байт до 5.3 МБ). Подписи дефекта — меньше половины секунды и меньше 64 байт вниз — нет ни у одной из 1 184. Разница между «сервер закрыл простаивающее соединение» и «сессия не состоялась» — в двух числах, и без них счётчик закрытий апстримом выглядит пугающе.

Отказ Worker’а, который был. Один раз за окно первая ступень не ответила — 13:48:27 НСК, — и лестница это отработала целиком (в цитате время лога, UTC):

06:48:27.302 WARN CF Worker DC203 timed out on <worker>.workers.dev
06:48:27.302 WARN [192.168.1.60:55550] DC203 CF Worker <worker>.workers.dev failed, cooldown 60s
06:48:27.303 WARN CF Worker DC2   timed out on <worker>.workers.dev
06:48:27.303 WARN [192.168.1.60:55555] DC2   CF Worker <worker>.workers.dev failed, cooldown 60s
06:48:27.555 INFO [192.168.1.60:55550] DC203 pinned → CF proxy connected
06:48:27.555 INFO [192.168.1.60:55550] DC203 WS session closed by client: ↑271.0B  ↓0.0B  0.0s
06:48:27.597 INFO [192.168.1.60:55555] DC2   pinned → CF proxy connected
06:48:27.597 INFO [192.168.1.60:55555] DC2   WS session closed by client: ↑142.0B  ↓0.0B  0.0s

Две сессии, шестьдесят секунд cooldown’а у первой ступени, обе сессии подхвачены второй ступенью через 250 мс. Клиент к этому моменту уже бросил их сам (ноль байт вниз) — то есть пользователь этого не заметил. Но это ровно тот случай, ради которого в закреплённой лестнице оставлены три ступени. Все 14 строк WARN за окно разобраны: восемь — прогрев пула при старте прокси, шесть — два эпизода отказа Worker’а (четыре строки в 13:48 и две в 21:29).

Второй эпизод (21:29:03) требует отдельной оговорки: он попал в те минуты, когда с этого же стенда шла принудительная тяжёлая проверка линии — сканирование тысяч адресов и съёмка на внешнем интерфейсе. Считать его «естественным» отказом нельзя. Но он показывает то, чего не показывает первый: что делает вторая ступень, когда первая в cooldown’е. За минуту cooldown’а и следующую за ней через cfproxy прошли 14 сессий и 4.2 МБ, включая пять сессий по 0.8–0.9 МБ: одиннадцать закрыл клиент, три долгие — сервер по тому же таймауту простоя, отбракованных нет ни одной. Второй путь не просто «числится в конфиге» — он несёт реальный трафик.

Задержка: поправка к собственному числу. Сразу после правки было записано «29/29 успешных проб при 155–173 мс». Число верное, но относится к прогретому пулу соединений, а не к обычному случаю. Устойчивая картина такая: попадание в пул — 165–190 мс, холодное подключение — 400–442 мс (при старте, когда пул пуст, или когда соединение из пула состарилось). Плановая проба раз в пять минут почти всегда приходит в холодный пул, поэтому её 400 мс — это стоимость установления соединения, а не задержка сообщения, и платится она один раз на сессию. За девять часов: 47 проб, ни одного отказа, 14 попаданий в пул (109–198 мс), 32 холодных (377–469 мс) и одна проба на 11.8 с в 21:44 — та самая, что попала в минуты принудительной проверки линии. Для сравнения, клиентская проба через прямую ступень до правки отвечала за ~500 мс и отказывала примерно в одной попытке из трёх.

Чего это стоило. Процесс поднят в 13:02:28 и не перезапускался девять часов, ни одной строки ERROR, ни одного события every fallback failed, ни одного пропуска прямой ступени по cooldown’у (пропускать больше нечего). Все изменения — две строки в конфиге и 24 строки во враппере.

Границы результата

  • Опыт на самом телефоне не измерен. Метрика — лог и проба. Субъективное «стало лучше» не собиралось, структурированного теста на клиенте (переключение авиарежима, ручные сценарии) не было.

  • Все живые ступени — Cloudflare. Прямая мертва, независимого от Cloudflare пути в клиентском маршруте нет. Это осознанный размен, а не решение: два домена и два аккаунта переживают отказ одного, но полный отказ Cloudflare не переживается ничем.

  • Механизм «800 Б вверх → 8–28 Б вниз → 0.1 с» не подтверждён. Побайтовые пробы такого поведения не воспроизвели: 21 установившееся соединение в съёмке вернули нормальный ответ. Ведущее объяснение — гонка на стороне клиента, и оно помечено как непроверенное.

  • Окно — один будний день. Шесть устройств, дневное и вечернее время. Ночной и выходной профиль не измерялись.

  • Cooldown прямого адреса живёт только в памяти процесса. Любой перезапуск снова делает прямую ступень кандидатом — из клиентского пути её убирает пин, а не таймер.

  • Пакеты отбрасывает ТСПУ. Это фильтрация класса ТСПУ на российском маршруте. На неё указывает вся совокупность измерений: SYN уходят с внешнего интерфейса и обратно не приходит ничего, ни одно локальное правило и ни один сервис на стенде к этому не причастны, те же адреса отвечают из Европы, США и Азии, российский узел на другом операторе повторяет ту же картину (.99 мёртв, .220 жив), и ни одного из этих адресов нет ни в зеркале реестра, ни в выгрузках первичного реестра. Из дома не видно другого: какое именно устройство стоит на пути и по какому правилу оно перечисляет адреса — измерения заканчиваются на границе провайдера.

  • Почему уцелел именно служебный узел — открытый вопрос. «В наборе адресов фильтра его не оказалось» — это то, что показывают данные. «Его исключили намеренно» — предположение, и в тексте оно так и помечено.

  • Атрибуция «закрыто апстримом» имеет слабое место: это сторона, чей EOF прокси увидел первым, и клиент, бросивший гонку, может выглядеть так же. Контроль — те же клиенты в те же минуты дают ноль на ступени Cloudflare (0 из 1 137 против 47 из 337).

  • Детектор инцидентов по одному времени не работает. 104 «массовых закрытия» после правки — в основном приложение, закрывающее пачку сокетов; ни в одном из них нет подписи дефекта, но отличить их от него можно только по байтам и ступени.

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

  • У единственного живого адреса есть собственные потери TCP — в замерах от 55 % до 73 % успеха в разные минуты при 10/10 по ICMP. Это отдельный дефект того же адреса, и приписывать его фильтру нельзя. IPv6 на этой линии нет, и он не проверялся: все выводы относятся к IPv4.

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

Инженерные выводы

  1. Резерв из одного провайдера — не резерв. Worker и cfproxy — оба Cloudflare. Разные аккаунты и разные имена переживают отказ одного, но общий отказ Cloudflare не переживает ничто. В этой лестнице третий путь (прямая ступень) существует только для DC2/DC4 и половину времени не отвечает.

  2. «Не настроено» — это класс дефекта. Рабочая ступень лежала в бинарнике и не была включена. Смотреть нужно не только на то, что инструмент делает, но и на то, что он умеет: в этой истории всё нужное уже было в поставке.

  3. Молчаливый дроп SYN не виден приложению. Ни один лог прокси не покажет лимит на стороне сети: для него это таймаут. Нужна активная проверка пути — пачка соединений плюс съёмка, — а не собственные счётчики ошибок.

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

  5. Атрибуция требует контроля. «Те же устройства, те же минуты, другая ступень» — самый дешёвый способ отличить причину от совпадения: 22 % и 50 % отбраковки на прямой ступени против 0 % на Cloudflare за те же часы.

  6. Пин лестницы в этой фазе блокировки не стоит ничего. Попадание в пул Cloudflare (165–190 мс) быстрее прямой ступени (~500 мс на клиентской пробе и отказ в трети случаев). Цена пина — не задержка, а отказ от маршрута, который сейчас всё равно не работает.

Диагностика: шесть проверок с командами

Если конструкция похожа (прокси на роутере, Telegram, «иногда отваливается» без сообщений в логе), проверки идут в таком порядке.

LOG=/var/log/tg-ws-proxy.log

# 1. Короткие закрытия: кто оборвал сессию, за сколько и с каким объёмом
grep 'closed by upstream' $LOG | awk '{print $NF, $(NF-1)}' | sort | uniq -c | sort -rn | head

# 2. Сколько сессий вообще не получило данных
grep -c 'closed by client' $LOG; grep 'closed by client' $LOG | grep -c '↓0.0B'

# 3. Какая ступень несёт клиентов (и есть ли вообще прямая)
grep -c 'WS connected via' $LOG          # прямая ступень
grep -c 'CF Worker connected' $LOG       # Worker
grep -c 'CF proxy connected' $LOG        # cfproxy

# 4. Порядок ступеней и состав лестницы — из справки и из стартового лога
tg-ws-proxy --help | grep -A2 pinned
grep -E 'Cloudflare proxy domain|pinned' $LOG | head

# 5. Достижимость: ICMP и TCP до адреса. ICMP отвечает — значит хост жив,
#    и молчание TCP в этом случае уже диагноз.
ping -c 5 -W 2 149.154.167.220
for i in 1 2 3 4 5; do curl -m 4 -s -o /dev/null -w '%{time_connect}\n' https://149.154.167.220/ || echo timeout; done

# 6. Лимит на одновременные соединения: пачка из 13 и 20, и обязательно
#    съёмка на внешнем интерфейсе — иначе не отличить лимит от таймаута
tcpdump -i wan -n -s 96 -w /tmp/cap.pcap 'host 149.154.167.220'

Норма — не «ноль ошибок в логе», а ноль сессий без данных при живых пробах. Если короткие закрытия есть, а нулевых нет, смотреть надо на ступень: доля закрытий меньше половины секунды с объёмом вниз до 64 байт на одной ступени и ноль на другой — это не сеть вообще, а конкретный путь. И отдельно: прежде чем искать DPI, проверьте лимит. Пачка из тринадцати соединений стоит минуту времени и отвечает на вопрос, который лог не показывает никогда.

Приложения: код

Три инструмента, на которых держатся все числа этой статьи. Рукопожатие obfuscated2 — то, чем клиент открывает MTProto-сессию и что проверяет Telegram, оценивая прокси, — здесь не повторяется: оно разобрано в спойлерах первой статьи. Ниже то, что появилось в этом разборе.

Классификатор сессий: подпись дефекта и детектор массовых закрытий
import re
from datetime import datetime

TS     = re.compile(r"^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z)")
LABEL  = re.compile(r"\[([\d.]+:\d+)\]")
CLOSE  = re.compile(r"WS session closed by (\w+): ↑([\d.]+)(B|KB|MB) +↓([\d.]+)(B|KB|MB) +([\d.]+)s")
TIER = [(name, re.compile(rx)) for name, rx in (
    ("direct",        r"→ WS connected via ([\d.]+)"),
    ("direct-pool",   r"→ pool hit via ([\d.]+)"),
    ("cfworker",      r"CF Worker connected"),
    ("cfworker-pool", r"CF Worker pool hit"),
    ("cfproxy",       r"CF proxy"),
    ("tcp",           r"TCP fallback ([\d.:]+)"))]

def to_bytes(v, unit):
    return float(v) * {"B": 1, "KB": 1024, "MB": 1024 ** 2}[unit]

def parse(path):
    """Одна запись на клиентскую сессию: ступень, байты в обе стороны, кто закрыл."""
    sessions, last_tier = {}, {}
    for line in open(path, errors="replace"):
        m_ts, m_lab = TS.match(line), LABEL.search(line)
        if not (m_ts and m_lab):
            continue
        ts, label = m_ts.group(1), m_lab.group(1)
        for name, rx in TIER:                       # куда ушла сессия
            if rx.search(line):
                last_tier[label] = name
                break
        m = CLOSE.search(line)                      # чем закончилась
        if not m:
            continue
        who, up, upu, dn, dnu, dur = m.groups()
        sessions[label] = {"ts": ts, "tier": last_tier.pop(label, "unknown"),
                           "up": to_bytes(up, upu), "down": to_bytes(dn, dnu),
                           "dur": float(dur), "by": who}
    return sessions

def short_close(s):
    """Подпись дефекта: апстрим оборвал сессию быстрее 0,5 с, отдав не больше 64 байт.
    Серверные таймауты простоя (91,8 и 270,3 с) под неё не попадают по построению."""
    return s["by"] == "upstream" and s["down"] <= 64 and s["dur"] < 0.5

def bursts(sessions, gap=0.1, size=4):
    """Пачки сессий, закрывшихся в пределах 100 мс. Само по себе это не инцидент:
    приложение закрывает сокеты пачкой при смене сети. Отличают подпись и ступень."""
    def t(s):
        return datetime.fromisoformat(s["ts"].replace("Z", "+00:00"))
    ss = sorted(sessions.values(), key=lambda s: s["ts"])
    out, i = [], 0
    while i < len(ss):
        j = i
        while j + 1 < len(ss) and (t(ss[j + 1]) - t(ss[i])).total_seconds() <= gap:
            j += 1
        if j - i + 1 >= size:
            out.append(ss[i:j + 1])
        i = j + 1
    return out
Пробник WebSocket-апгрейда: набор заголовков, от которого зависит вывод
import base64, os, socket, ssl, time

def ws_upgrade(ip, dc, host=None, path="/apiws", timeout=6.0):
    """Байт-в-байт то, что отправляет прокси. Без Sec-WebSocket-Protocol: binary
    реальный фронтенд отвечает 404 — и рабочий эндпоинт выглядит мёртвым."""
    host = host or f"kws{dc}.web.telegram.org"
    req = ("GET %s HTTP/1.1\r\n"
           "Host: %s\r\n"
           "Upgrade: websocket\r\n"
           "Connection: Upgrade\r\n"
           "Sec-WebSocket-Key: %s\r\n"
           "Sec-WebSocket-Version: 13\r\n"
           "Sec-WebSocket-Protocol: binary\r\n\r\n") % (
        path, host, base64.b64encode(os.urandom(16)).decode())
    t0 = time.perf_counter()
    ctx = ssl.create_default_context()
    ctx.check_hostname = False          # сертификат проверяется отдельно, вручную
    ctx.verify_mode = ssl.CERT_NONE
    with socket.create_connection((ip, 443), timeout=timeout) as raw:
        with ctx.wrap_socket(raw, server_hostname=host) as s:
            s.sendall(req.encode())
            head = s.recv(4096).decode("latin1")
    return head.split("\r\n", 1)[0], round((time.perf_counter() - t0) * 1000)

# 101 → ступень работает;  302 → адрес живой, но этот DC не обслуживает;
# 404 → забыт Sec-WebSocket-Protocol;  таймаут → адрес недостижим.
Разбор съёмки: SYN, ретрансмиссии, RST и FIN без внешних библиотек
import struct

def packets(path):
    """Минимальный читатель pcap: заголовок файла, затем записи с длиной."""
    data = open(path, "rb").read()
    endian = "<" if struct.unpack("<I", data[:4])[0] in (0xA1B2C3D4, 0xA1B23C4D) else ">"
    off, out = 24, []
    while off + 16 <= len(data):
        sec, usec, incl, _ = struct.unpack(endian + "IIII", data[off:off + 16])
        off += 16
        out.append((sec + usec / 1e6, data[off:off + incl]))
        off += incl
    return out

def tcp_flags(pkt):
    """(порт назначения, флаги) для TCP поверх IPv4, иначе None."""
    if len(pkt) < 54 or pkt[12:14] != b"\x08\x00" or pkt[23] != 6:
        return None
    ihl = (pkt[14] & 0x0F) * 4
    tcp = pkt[14 + ihl:]
    return struct.unpack(">H", tcp[2:4])[0], struct.unpack(">H", tcp[12:14])[0] & 0x1FF

# 0x02 без 0x10 — попытка подключения, 0x12 — ответ SYN-ACK,
# 0x04 — RST, 0x01 — FIN. Число SYN на одну попытку и есть ретрансмиссии:
# в этом разборе 21 установившееся соединение дали 21 SYN-ACK,
# а 12 неподтверждённых попыток — 60 SYN, по пять на каждую.

Как строилась эта диагностика

Порядок работы здесь оказался важнее отдельных находок.

  1. Сначала только чтение. Первый проход — снимки логов, ps, ss, md5sum и активные пробы доступности. Изменения на роутере не вносились, пока не появилась гипотеза, которую можно опровергнуть.

  2. Каждой гипотезе — критерий опровержения. «Прокси сломался» — проверяются аптайм, счётчики ошибок и перезапуски. «Виноват Wi-Fi телефона» — окна отсутствия клиента в точке доступа сверяются с окнами в логе. «Виноват фильтр по имени» — подключение по голому IP без SNI. «Виноват обход DPI на этом стенде» — тот же адрес на 80-м порту, который в очередь обхода не попадает. Гипотеза, не прошедшая проверку, отбрасывается.

  3. Лог даёт корреляцию, эксперимент — причину. Поэтому появились пачки параллельных проб, съёмка на внешнем интерфейсе и внешние узлы проверки: они отделяют «на этом роутере что-то не так» от «так ведёт себя путь».

  4. Один параметр за раз и заранее подготовленный откат. Две строки конфига, перезапуск, наблюдение; откат — удаление строки и перезапуск. Вариант «включить всё сразу» не рассматривался.

  5. Результат проверяется на клиенте, а не только в логе. Критерий приёмки — не «в логе ноль обрывов», а «на телефоне соединения не теряются».

Виртуальный стенд. Часть работы шла не на роутере, а на стенде из четырёх частей: эмулятор клиента (тот же obfuscated2 и настоящий req_pq_multi/resPQ, сценарии «одна проба», «серия», «гонка», «простой», «шторм»), поддельный дата-центр и поддельный Cloudflare Worker с режимами «ок», «зависнуть», «сбросить», «отказать», «редирект», «задержка N», «404», сам прокси — та же сборка форка, но собранная под x86-64, — и раннер сценариев. Стенд воспроизводит офлайн три живые формы отказа: отказ первой ступени с переходом на Worker (сегодняшняя ситуация DC1/DC3/DC5), 302 на прямом адресе (то, что .220 отвечает для чужих дата-центров) и «все ступени мертвы» (сырой TCP-хвост). Две вещи стенд вытащил наружу сразу: релей инициализирует шифры по сырым ключам, а клиентская линка — по SHA256(prekey ‖ secret), из-за чего первая версия поддельного дата-центра не могла расшифровать ничего; и без заголовка Sec-WebSocket-Protocol: binary настоящий фронтенд отвечает 404. Обе подробности затем пригодились вживую.

Сборщик доказательств. Параллельно с разбором работал отдельный процесс, который раз в минуту копировал лог прокси наружу (в tmpfs он не переживёт перезагрузку), раз в пять минут делал клиентскую пробу и раз в десять минут снимал карту эндпоинтов. Он и дал серию проб для раздела о приёмке — и он же стал источником одной из ошибок ниже.

Три ошибки этого разбора

  1. Правка конфигурационного файла на месте. sed -i в busybox заменяет файл, а не редактирует его; песочница перестала читать конфиг, сервис ушёл в crash loop, простой — 64 секунды. Правило после этого: копия, проверка прав, замена копированием, проверка статуса.

  2. Сборщик молчал пять часов. Вспомогательный процесс, который копирует лог наружу и раз в пять минут делает клиентскую пробу, перестал работать в 15:55 и вернулся только в 21:00 НСК. Обрыв серии проб обнаружился не по тревоге, а при очередной сверке. Данные не потерялись — лог самого прокси копируется целиком при следующем подключении, и пропущенное окно удалось восстановить, — но пять часов наблюдение велось только вручную, а инструмент, который должен был сообщить о собственной остановке, молчал вместе с процессом. Наблюдаемость должна следить и за собой.

  3. Неверное число про задержку. Сразу после правки было записано «29/29 успешных проб при 155–173 мс» — и это число едва не ушло в выводы как характеристика новой лестницы. На самом деле оно описывает прогретый пул соединений; штатный случай — 400–442 мс на холодном подключении. Разницу видно в логе по строкам pool hit и connected, то есть ошибка была не в измерении, а в том, что измерение не разделили на два режима.

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.