---
title: "Сброс конкретных соединений при исправной сети: точечная диагностика"
description: "Телефон подключается по Wi-Fi, но не по мобильной сети, и отваливаются всегда одни и те же соединения. RIPDPI сначала диагностирует сетевой путь — проба, класс отказа, вердикт — и только потом что-то чинит, если есть чем."
author: "Nikita Pochaev"
slug: "network-isnt-broken-just-here"
lang: "ru"
date: "2026-06-01"
wordCount: 266
category: "Networking"
tags: ["DPI", "Network Diagnostics", "Rust", "Android"]
canonical: "https://po4yka.dev/blog/ru/network-isnt-broken-just-here"
---

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

> Телефон подключается по Wi-Fi, но не по мобильной сети, и отваливаются всегда одни и те же соединения. RIPDPI сначала диагностирует сетевой путь — проба, класс отказа, вердикт — и только потом что-то чинит, если есть чем.

Published: Jun 2026 | Category: Networking | Tags: DPI, Network Diagnostics, Rust, Android

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

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

```text
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 заменяет промежуточную коробку:

```text
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.

Каждый исход пробы попадает в одну из четырёх категорий `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 и ни один запасной IPv6 не ответили на SYN, и чтобы это подтвердило второе независимое соединение. Пока подтверждения нет, раннер сидит в `PendingSecondFlow` и вердикт не выносит. Ложное срабатывание здесь загнало бы соединение на ненужный relay, поэтому раннер ждёт доказательств. Когда вердикт всё же выносится, раннер ставит `arm_gate = OwnedStackOnly`: TLS-приёмы пропускаются, и движок сразу переходит к собственному стеку, то есть к relay. Переписывать пакеты бессмысленно, если по адресу никого нет.

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

Когда вердикт требует операции над пакетами, включается вторая система. Каждое средство реализует трейт `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`. Туда вынесено всё, для чего в формате sing-box полей нет: `schema_version`, массив профилей 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 для кода, который разбирает недоверенные байты прямо из сети, — тема следующего текста.

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