Skip to main content
All posts
$less posts/network-isnt-broken-just-here.txt⌘C
posts/network-isnt-broken-just-here.txt0 min

Сброс конкретных соединений при исправной сети: точечная диагностика

author Nikita Pochaevdate category Networking

Через мобильный интернет Telegram не поднимает соединение: клиент висит на «Connecting…». Через Wi-Fi тот же клиент подключается мгновенно. Между попытками меняется только сеть: точка подключения и оператор. Этого достаточно, чтобы трафик до одного и того же сервера в одной сети шёл, а в другой обрывался на рукопожатии.

Сеть при этом формально исправна: DNS резолвится, другие сайты открываются, ping до 8.8.8.8 ходит без потерь. Отваливаются конкретные соединения, и всегда одни и те же. Это почерк DPI: коробка на стороне оператора разбирает трафик и роняет соединения, которые подходят под сигнатуру. Остальное проходит.

Для нагруженной сети такая асимметрия давно норма. Мобильный оператор не ограничивается маршрутизацией: он снимает отпечатки TLS- и QUIC-рукопожатий, режет трафик по соединениям, занижает MTU и сбивает ECN; промежуточная коробка глушит соединение, которое домашний роутер пропустил бы не глядя. Итог предсказуем: одно направление мертво, соседнее живо, и любой глобальный тумблер «включить везде» гарантированно неправ хотя бы для одного из них.

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

RIPDPI сначала ставит диагноз.

Диагноз

Диагностику ведёт цепочка из четырёх Rust-крейтов: ripdpi-diagnostics-candidates готовит входные данные для проб, ripdpi-diagnostics-probes задаёт трейт Probe, который реализует каждая проверка, ripdpi-diagnostics-classification превращает сырые наблюдения в вердикт, а ripdpi-diagnostics-runner гоняет всю обойму. Проб больше десятка: целостность и подмена DNS, доступность доменов и QUIC, проверка ECH-рукопожатия, доступность MTProto для Telegram, пропускная способность, опрос DoH-JSON-резолверов. Захардкоженного сервера нет ни у одной: цель передаётся в рантайме через ProbeContext, и проба бьёт ровно по тому адресу, который реально пытались открыть.

TcpRunner открывает одну TLS-сессию и шлёт до 16 padded HTTP HEAD-запросов, каждый крупнее предыдущего, и сверяет накопленный объём с порогом 16 КиБ (FAT_HEADER_THRESHOLD_BYTES = 16 * 1024). Многие коробки держат состояние соединения лишь в пределах внутреннего буфера; стоит этим запросам не поместиться в буфер — коробка рвёт соединение. Проба засекает, на каком именно байте всё ломается. Сброс или таймаут после примерно 14 КиБ отправленных данных — или после ответа, когда прокачалось хотя бы 8 КиБ, — пишется как tcp_16kb_blocked, а не как рядовой сброс: значим именно байт обрыва. Сбросы она различает и по времени: RST в пределах удвоенного RTT по SYN-ACK списывается на промежуточный узел, а не на сервер; если он приходит позже — значит, трубку вешает уже сам сервер, и лечится такое иначе.

Проба сводит каждый прогон к одному тегу исхода:

tcp_fat_header_ok            сессия дошла до 16 KiB без обрыва
tcp_16kb_blocked             обрыв на пороге ~14 KiB
tcp_freeze_after_threshold   зависание за порогом
tcp_reset                    сброс до порога
tcp_timeout                  нет ответа
tcp_connect_failed           не подключился
tls_handshake_failed         TLS не установился
Размеченная ось байтов пробы толстым заголовком в пределах одной TLS-сессии. Отметки на 8 KiB (late_stage_cutoff, когда уже виден ответ), 14 KiB (fat_threshold_reached, 16384 минус 2048 байт) и 16 KiB (FAT_HEADER_THRESHOLD_BYTES). Обрыв на пороге пишется как tcp_16kb_blocked -- главный сигнал. Ниже classify_rst_origin делит сброс по времени: in_path_rst в пределах двойного RTT после SYN-ACK, server_rst позже.

Схема показывает логику решения; а вот она в реальном прогоне — три запуска пробы на локальной сетевой фикстуре репозитория, которая на loopback заменяет промежуточную коробку:

outcome            bytesSent  rstTimingMs  rstOrigin   confidence
tcp_fat_header_ok  147664     -            -           none
tcp_reset          8273       12           server_rst  medium
tcp_16kb_blocked   16680      3            server_rst  high

Это реальные результаты пробы, а не макет. С этим порогом связаны три числа, которые легко перепутать: 16384 — это и есть порог в 16 КиБ (FAT_HEADER_THRESHOLD_BYTES); ~14 КиБ — это порог минус 2 КиБ запаса, начиная с которого проба трактует обрыв как признак толстого заголовка, а не рядовой сброс; а 16680 — это сколько байтов фактически успело уйти к моменту обрыва, чуть за порогом, поэтому срабатывает window_cap, и исход — tcp_16kb_blocked. Оговорка про loopback-стенд: RTT после SYN-ACK ≈ 0, поэтому любой RST классифицируется как server_rst; отделить in_path_rst от server_rst правилом 2×RTT можно только на сетевом пути с измеримым RTT, а не на loopback.

Каждый исход пробы попадает в одну из четырёх категорий ProbeOutcomeBucket: Healthy, Attention, Failed, Inconclusive. У каждого исхода есть уровень события: info, warn или error. Если соединение на пути активно отбросили, отказу присваивают класс из FailureClass, один из шестнадцати (DnsTampering, TlsAlert, HttpBlockpage, IpBlockSuspect и прочие). Inconclusive — осторожная категория. Случайный таймаут, сработавший до первых осмысленных данных, уходит сюда и не запускает автоматическую смену стратегии. Угадывать по шуму — верный способ назначить неправильное лечение.

Слой классификации сводит всё это к четырём вердиктам — они и определяют, что будет с трафиком. TRANSPARENT_OK: напрямую всё работает, трогать нечего. OWNED_STACK_ONLY: сайт открывается только через собственный TLS-стек приложения — туда соединение и уходит. NO_DIRECT_SOLUTION: никакая операция над пакетами на устройстве этот адрес не вытащит, нужен туннель. IP_BLOCK_SUSPECT: на уровне IP не отвечает никто.

До последнего вердикта добраться намеренно трудно: нужно, чтобы ни один IPv4-адрес из DoH не ответил на SYN, ни один запасной IPv6 — тоже, и чтобы это подтвердило второе независимое соединение. Пока подтверждения нет, раннер сидит в PendingSecondFlow и вердикт не выносит. Ложное срабатывание здесь загнало бы соединение на ненужный relay, поэтому раннер ждёт доказательств. Когда вердикт всё же выносится, он ставит arm_gate = OwnedStackOnly и даже не пытается чинить соединение на уровне TLS: переписывать пакеты бессмысленно, если по адресу никого нет.

Схема решения от исхода пробы к одному из четырёх вердиктов политики. Исход раскладывается по ProbeOutcomeBucket (Healthy, Attention, Failed, Inconclusive); ветка Failed несёт FailureClass. Четыре вердикта: TRANSPARENT_OK, OWNED_STACK_ONLY, NO_DIRECT_SOLUTION и IP_BLOCK_SUSPECT. IP_BLOCK_SUSPECT закрыт пунктирным узлом PendingSecondFlow, который ждёт второго независимого соединения, прежде чем вердикт сработает.

Самое лёгкое средство

Когда вердикт требует операции над пакетами, включается вторая система. Каждое средство реализует трейт DesyncStrategy из крейта ripdpi-strategy-trait: plan собирает сами шаги, остальные три метода — служебные. Шаги — варианты enum’а DesyncAction, и идея у всех одна: показать промежуточной коробке не то, что увидит сервер. Split { offset, disorder } дробит TCP-сегмент. WriteFake { ttl, sni_mode, payload_file } подсовывает обманку с заниженным TTL, чтобы та сгорела в пути и до сервера не доехала. Дальше — тяжелее: от игр с TCP-окном и TTL до IP-фрагментации и наложения данных по номерам последовательности. По умолчанию это работает на обычных непривилегированных сокетах; то, что требует сырых сокетов, вынесено в опциональный root-хелпер (ripdpi-root-helper) и молча пропускается, когда root недоступен.

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

Десять стратегий встроены и регистрируются через distributed slices из linkme, так что добавить новую — одна запись и никакого центрального match. Имена утилитарные: split дробит сегмент, seq_overlap накладывает данные по номерам последовательности; остальные в том же духе. Ещё две, synack и synack_split, висят заглушками Unimplemented: вброс SYN-ACK идёт другим путём, через перехватчик на входе TUN. Реестр пробует стратегии по порядку регистрации и берёт первую, у которой plan собрал шаги. Если применить не вышло, политика OnFail решает: откатиться к следующей, перейти на обычный трафик или сбросить соединение. Обычный трафик — последний вариант, если не сработало ничего.

Свой сценарий тоже можно написать: Lua-стратегия (под feature-флагом) запускает скрипт в изолированной песочнице (урезанная stdlib, скомпилированный байткод не принимается, лимит памяти 16 МиБ, watchdog по числу инструкций, без выхода за пределы своего каталога).

В какой точке соединения сработает действие — тоже не зафиксировано. Tuner для каждого соединения, AdaptivePlannerResolver из крейта ripdpi-runtime-adaptive, хранит состояние по кортежу (сеть, группа, тип соединения, цель) и при неудаче поочерёдно перебирает пять параметров (сдвиг split, сдвиг TLS-записи и три протокол-специфичных профиля). Порядок перебора перемешивается на основе сида, полученного из ключа соединения, так что два соединения идут разными путями. Победа фиксирует текущего кандидата, а поражение откладывает его на пятнадцать секунд, прежде чем снова пустить в дело.

Этажом выше работает обучающийся слой. StrategyEvolver гоняет многорукого бандита UCB1, классический алгоритм «исследуй или используй», который балансирует между «бери то, что уже работало» и «попробуй то, что пробовал реже всего». Он оценивает каждую комбинацию стратегий по доле успехов, задержке, стабильности и штрафу за детектируемость, а сам штраф вычисляется по тем классам сбоев, что означают «путь активно отверг соединение» (TlsAlert, HttpBlockpage, Redirect, ConnectionFreeze). Победы затухают с периодом полураспада в два часа, поражения — в один час, так что сработавшая стратегия держит своё преимущество примерно вдвое дольше провалившейся. В том же крейте лежит альтернатива на Thompson sampling, помеченная как мёртвый код; по умолчанию работает UCB1, и так и написано в комментарии.

Операция над пакетами — один из двух путей при плохом вердикте. Второй — OWNED_STACK_ONLY: направить соединение через собственный TLS-клиент приложения, а не системный. Этот клиент (OwnedTlsClientFactory поверх Rust-крейта ripdpi-tls-profiles) хранит выверенные шаблоны ClientHello для Chrome, Firefox, Safari и Edge — вплоть до порядка шифронаборов и поведения session-ticket. Один шаблон на соединение он выбирает по хешу SHA-256(authority | seed сессии | набор профилей): для одного хоста выбор стабилен, между хостами — различается. Он умеет ECH, когда его предлагает целевой сервер, и согласует пост-квантовую гибридную группу X25519MLKEM768, когда её поддерживают обе стороны. Зафиксированный снимок фингерпринта (owned_stack_tls_fingerprint_snapshot.json) роняет CI, если рукопожатие меняется.

Что телефон запоминает

Результаты этого обучения хранятся отдельно для каждой сети, а не только для адреса назначения. RememberedNetworkPolicyStore (Kotlin поверх базы Room) помечает каждую запись SHA-256-хешем области сети. В хеш входят тип транспорта, состояние валидации DNS, статус captive-portal, режим private DNS, отсортированный список DNS-серверов и кортеж идентичности, зависящий от транспорта: SSID, BSSID и шлюз для Wi-Fi; коды оператора и SIM, carrier ID и состояние роуминга — для сотовой. Перед хешированием всё приводят к нижнему регистру и убирают пробелы, а сырые значения живут только до создания хеша: CapturedWifiIdentity.toString() печатает redacted, обобщённый NetworkSnapshot для классификации не несёт ни SSID, ни IP, а правило репозитория не пускает сырые SSID и BSSID в логи и краш-репорты. Сырой SSID с телефона не уходит.

Запомненная политика проходит через три состояния: observed, validated, suppressed. Два провала валидированной политики подряд переводят её в suppressed и запирают на 24 часа; любой успех обнуляет счётчик провалов и снимает блокировку. Всего таблица хранит не больше 64 строк и забывает всё старше 90 дней. Стоит вернуться в знакомую сеть — и хранилище сразу применяет валидированную политику, а потом тихо перепроверяет в фоне. На каждом переходе между Wi-Fi и сотовой сетью отпечаток пересчитывается, и хранилище опрашивается заново. За недели у телефона складывается собственная карта того, какие сети ломают какие соединения и как именно.

Машина состояний политики, запомненной по сетям. Состояния observed, validated, suppressed. Новая область входит в observed, валидируется в validated, успех оставляет её там же. Два отказа подряд у валидированной политики переводят её в suppressed с блокировкой на 24 часа; любой успех или истечение блокировки возвращают в validated. Ключ области -- SHA-256-хеш; хранилище держит не больше 64 строк и забывает записи старше 90 дней.

Два режима работы

Прокси-режим — тот, что полегче. RipDpiProxyService поднимает SOCKS5-прокси на localhost-порту; приложения, которые умеют SOCKS5 или HTTP CONNECT, указывают на него явно, а остальной трафик идёт напрямую. Второй режим запускает тот же прокси на эфемерном порту, а сверху накладывает TUN-устройство через Android VpnService. Туннель читает IP-пакеты с TUN-устройства (10.10.10.10/32, MTU 1500) и устанавливает с прокси аутентифицированные SOCKS5-сессии.

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

Когда в туннельном режиме включён шифрованный DNS, внутренний FakeIP-слой под названием MapDNS отвечает на запросы адресами из диапазона 198.18.0.0/15, резолвит настоящее имя через шифрованный резолвер и отдаёт приложению синтетический адрес, который закрепляет на время соединения. Пользователю это тумблером не показывают: из-за возни с IPv6-режимом и fail-closed-отбрасыванием выносить такое в отдельный переключатель не стали.

Этот шифрованный резолвер — отдельная часть: ripdpi-dns-resolver умеет DoH, Oblivious DoH (RFC 9230) и DNSCrypt, так что инструменту не нужно откатываться к системному резолверу, и ответы местного DNS не портят измерение. Oblivious DoH делит знание надвое: запрос идёт через relay, который видит адрес, но не имя, а целевой резолвер — имя, но не адрес, так что ни один узел не видит обе половины сразу. Ответы ложатся в route-aware-кэш с ключом (домен, qtype, решение о маршруте). При смене маршрута ключ уже другой: вместо ответа, полученного для другого пути, уходит новый запрос.

Маршрут через собственный сервер — опция со своими ограничениями. В нативном ядре libripdpi-relay.so — с десяток транспортов, от Shadowsocks и Trojan до VLESS Reality и многоузловых цепочек; WARP и AmneziaWG стоят отдельно, это туннели, а не relay. Важнее списка строчка под ним в статус-документе: каждый протокол проверен только на loopback, живого удалённого эндпоинта нет ни у одного. Mieru — показательный случай. Нативный крейт и loopback-тест на месте, а активатора в переключателе профилей нет, так что из сохранённого профиля Mieru на этой ревизии не включить.

Если всё же ходить через свой сервер, передача конфигурации — это контракт, а не копипаст. Серверная часть деплоя (emit-bundle.sh) отдаёт стандартный sing-box JSON с одним дополнительным объектом верхнего уровня ripdpi: schema_version плюс то, чему в sing-box нет места, — массив профилей AmneziaWG и обфускацию Hysteria2. Парсер приложения SingBoxSubscriptionParser читает стандартные outbounds, затем блок ripdpi; чужой schema_version он отвергает, а обычный sing-box-клиент ключ просто не замечает. За контракт отвечают тесты с обеих сторон — SingBoxRipdpiExtensionParserTest на клиенте, валидатор секретов на сервере, — так что незаметно рассинхронизироваться сторонам не дадут. Один секрет сознательно не передаётся по этому пути: приватный ключ WireGuard остаётся заглушкой (private_key_placeholder: true) и доставляется по отдельному каналу.

Почему граница проведена именно здесь

Границу между Kotlin и Rust намеренно сводят к минимуму. Воркспейс — 115 крейтов (в документах по архитектуре всё ещё 114), разложенных на девять слоёв, от L0 до L8, и слоистость контролируется автоматически: CI-скрипт разрешает трогать крейт jni или прослойку android-support только тринадцати крейтам верхнего слоя; крейтам ниже это запрещено. Пять из этих верхних крейтов компилируются в разделяемые библиотеки, которые грузит Android: libripdpi.so, libripdpi-tunnel.so, libripdpi-relay.so, libripdpi-warp.so, libripdpi-amneziawg.so.

Карта слоёв нативного Rust-воркспейса из 115 крейтов, сгруппированных в девять слоёв от L0 до L8; зависимости идут только вниз. Снизу вверх: L0 support; L1, L2, L5 -- протокол, контракты, платформа; L3 доменная логика; L4, L6, L7 -- рантайм, диагностика, relay-транспорты; L8 Android- и JNI-адаптеры, единственные корни .so, за пунктирной границей JNI. Трогать jni могут только 13 крейтов L8, сегодня это делают 11.

Вся работа с данными остаётся в Rust и в Java не попадает: SOCKS5-сессии, перекачка пакетов TUN, desync-мутации, relay-транспорт, проброс DNS. Границу JNI пересекают только чтобы запустить и остановить сессию, опросить телеметрию примерно раз в секунду, отдать снимок состояния сети и вызвать VpnService.protect() на сокете. И каждое из этих пересечений обёрнуто: функция ffi_boundary из крейта android-support запускает каждый экспорт внутри catch_unwind, так что Rust-паника возвращается как sentinel-значение, а не разматывает стек через границу extern "system", что привело бы к неопределённому поведению. Профиль сборки JNI даже специально ставит panic = "unwind", потому что release-профиль проекта выставлен на abort, и унаследовать это поведение значило бы уронить весь процесс вместо того, чтобы панику перехватил catch_unwind на границе.

Зачем именно Rust для кода, который разбирает недоверенные байты прямо из сети, — тема следующего текста.

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

Nikita PochaevAI Engineer · Senior Mobile Developer
interactive shell -- try: help, ls posts/, cat meridian, open blog, neofetch

© Nikita Pochaev

Privacy