Сброс конкретных соединений при исправной сети: точечная диагностика
Через мобильный интернет 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 не установился

Схема показывает логику решения; а вот она в реальном прогоне — три запуска пробы на локальной сетевой фикстуре репозитория, которая на 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: переписывать пакеты бессмысленно, если по адресу никого нет.

Самое лёгкое средство
Когда вердикт требует операции над пакетами, включается вторая система. Каждое средство реализует трейт 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 и сотовой сетью отпечаток пересчитывается, и хранилище опрашивается заново. За недели у телефона складывается собственная карта того, какие сети ломают какие соединения и как именно.

Два режима работы
Прокси-режим — тот, что полегче. 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 и в 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 для кода, который разбирает недоверенные байты прямо из сети, — тема следующего текста.
Та самая коробка на пути никуда не делась и ведёт себя как прежде: буферизует трафик и рвёт соединение при превышении порога, независимо от того, какое приложение его открыло. Меняется поведение телефона. Он больше не считает все сбои одинаковыми: выносит вердикт, потом пробует минимальное вмешательство, которое снимает затык, и запоминает, помогло ли. В следующий раз, когда это соединение зависнет в той же сети, в хранилище уже записана сработавшая политика.