Недавно я дебажил проблему «Tinkoff Investments не открывается через VPN» — стандартная, split-routing проблема. Симптом: app запускается, картинки грузятся, но авторизационная сессия не поднимается, login висит. Через 30 минут возни со снапшотами /state, /connections, /logs и ручного matching'а conn_id'ов между ними картина прояснилась.

Часть доменов вида *.t-bank-app.ru корректно матчится в мой ru-domains rule_set и идёт напрямую. Но другая часть резолвится через CNAME на *.trbcdn.net (TLD .net!) — этот target в ru-domains уже не попадает, и sing-box честно отправляет трафик через bypass-VPN в Польшу. Получается split: часть запросов уходит с моего домашнего IP, часть — с польского. Bank-backend, который привязывает session к source-IP / fingerprint'у, видит непоследовательного клиента и просто отказывается поднимать auth state.

Симптом — «login завис», корень — domain-level split routing внутри одного приложения.

Стало ясно: такая диагностика не должна занимать 30 минут. Поэтому в L×Box (мой Android-клиент на sing-box, open source) появилась фича — Per-app traffic profiler.

Pick app → ▶ Record → 30 секунд работы приложения → читаешь Domains tab. Видно по строкам, что часть *.t-bank-app.ru уходит через direct-out, а другая — через vpn-1. Cовершенно очевидно где провал в правилах. Никакого packet capture, root'а, внешних tools.

Зачем это вообще

Сейчас индивидуальная и корпоративная безопасность более востребована чем ранее, пользователи хотят знать куда смотрит какое их приложение — это простая точка диагностики.

Три класса задач, которые регулярно возникают у любого пользователя split-routing VPN'а:

Дебаг роутинга. «X не работает через VPN» — flagship-кейс. Корень обычно один: домен выглядит RU, но CNAME уводит на CDN с иностранным TLD, и rule_set не отрабатывает. Без видимости CNAME-chain'а — это всегда детектив.

Privacy audit. «Куда стучит это приложение?» — список доменов отсортированный по объёму трафика. Замечаешь незнакомое — копаешься в outbound'е, при необходимости блокируешь.

Дебаг производительности. «Почему этот сервис тормозит?» — смотришь в каком outbound'е лежит трафик, есть ли DNS retry'и, не висит ли всё на одном медленном IP.

На рынке Android-VPN-клиентов никто не показывает routing chain per-app в реальном времени. PCAPDroid делает packet capture (но не видит роутинг). NetGuard блокирует (но не диагностирует). AdGuard видит DNS (но не outbound). Wireshark — это десктоп.

Преимущество в том, что у меня под капотом sing-box — единственный VPN-engine, который пишет в логи структурированные DNS-резолвы с CNAME chain'ом, package_name по conn_id и outbound chain. Эту информацию надо просто вытащить и разложить по полкам.

Как это устроено

Singleton-сервис TrafficProfiler (Dart, ChangeNotifier) держит одну active session + ring-buffer на 5 завершённых. Всё in-memory — на kill приложения сессии стираются. Persist принципиально не делается: фича diagnostic-only, не аналитическая, цена schema/migration'ов превышает value.

Два независимых источника данных:

              ┌────────────────────────────────────────┐
              │  TrafficProfiler (singleton)            │
              │   _active: Session?  _completed: Q[5]   │
              └────────────────────────────────────────┘
                     ▲                    ▲
        ┌────────────┴────────┐  ┌────────┴──────────────────┐
        │ A: log stream       │  │ B: /connections poll       │
        │  AppLog (core src)  │  │  Clash API every 2s        │
        │  ts-diff drain      │  │  diff vs prev snapshot     │
        └─────────────────────┘  └────────────────────────────┘

Источник A — sing-box log stream через native EventChannel lxbox/coreLogAppLog (мой per-source ring-buffer для логов) → TrafficProfiler listener. Парсятся три типа строк:

INFO[N] [<conn_id> <Nms>] router: found package name: <pkg>
INFO[N] [<conn_id> <Nms>] dns: exchanged|cached <type> <name>. <ttl> IN <type> <data>
INFO[N] [<conn_id> <Nms>] dns: exchange failed for <host>: <reason>

Per-conn-id accumulator (_DnsAccumulator) собирает CNAME chain и эмитит финальное dnsResolve event'ы с исходным запрошенным domain'ом, списком CNAME hops и финальным IP.

Источник B — Clash API polling /connections каждые 2 секунды (только пока session active — battery-friendly). Diff с предыдущим snapshot'ом даёт tcpOpen / tcpClose события с bytes, chains, host, IP, port. Фильтрация по metadata.process или metadata.processPath после strip'а UID-суффикса (там приходит "ru.tinkoff.investing (10364)", picker отдаёт чистый package).

Aggregates byDomain / byIp рассчитываются on-demand при чтении (sub-tab views). Connection-issue классификатор отмечает ⚠ на event'ах — два locale-агностичных типа:

Issue

Условие

DNS timeout

sing-box лог dns: exchange failed ... — прямой engine-сигнал, не heuristic

TCP RST early

conn closed в течение 1с, ↑0 ↓0 байт — heuristic для блокировки/RST/unreachable

Memory bounds — 50000 events на session или 3-часовое sliding window (что раньше). _completed ring-buffer FIFO-evict'ится при добавлении нового. Conn-id maps GC'атся по 30-секундному TTL когда map > 256 entries.

UI — 4 sub-tab'а:

  • Live — newest-first стрим events с цветными kind-метками (DNS/TCP/UDP) и inline-показом CNAME chain + outbound chain под каждым event'ом

  • Domains — aggregated по domain'у, sorted by bytes, с search-фильтром по domain || ip || cname-target. Раскрытие row'а показывает CNAME chain, все IPs, outbound, issues (⚠)

  • IPs — аналогично по IP. Полезен для hostless конн'ов (без SNI sniffing) и подозрительных IP из threat-feed'а

  • Connections — per-conn timeline с inline-expand: CNAME, all IPs, issues (⚠), кнопка [View in Domains →] с автопереходом + autofill search'а

Recording продолжается даже когда я вышел из этого экрана (singleton). На HomeScreen в traffic bar появляется красный ⚡-chip с короткой версией package name'а — индикатор; tap → возврат в Per-app tab. Останавливается только manual STOP, force-stop приложения или start новой сессии.

Грабли, на которые наступил

Из всего, что было запланировано в spec'е, больше всего времени ушло на штуки, которые не были предсказуемы из дизайна. Перечислю короткой сводкой — кто захочет деталей, найдёт в [lib/services/traffic_profiler.dart](https://github.com/Leadaxe/LxBox/blob/main/app/lib/services/traffic_profiler.dart) и §044 spec:

  • UID-суффикс в metadata.process. Sing-box возвращает не "ru.tinkoff.investing", а "ru.tinkoff.investing (10364)" — pid в скобках. Без strip'а ни одна conn'ция не атрибутируется к target session'у. Заодно сюрприз — metadata.process чаще null, актуальный package живёт в metadata.processPath.

  • Ring-buffer overflow ломает length-diff scan. Изначальный drain логов сравнивал entries.length с прошлым snapshot'ом. Мой AppLog имеет cap=500 для core source — когда busy traffic заполняет буфер, новые entries вытесняют старые, длина стабилизируется на 500, length-diff навсегда нулевой. DNS-events переставали приходить через 1-2 минуты после старта recording'а. Фикс — timestamp-diff (хранить ts последнего обработанного entry, а не его индекс).

  • dns: cached нужно ловить наравне с dns: exchanged. Sing-box логирует обе формы; у занятых apps большинство резолвов — cache-hit'ы. Изначальная regex-маска ловила только exchanged — DNS-уровень был разорванный (TCP-conn'ы есть, DNS-events нет).

  • Domain attribution на исходный domain, не на CNAME-target. Sing-box логирует CNAME chain последовательно: CNAME a → b, потом A b → ip. Если эмитить event с domain = именем из A-записи — в Domains tab появится b (CDN-хост), а изначально-запрошенный a исчезнет. Фикс — per-conn-id accumulator хранит первое имя, CNAME-targets копятся отдельно как chain.

  • Connections click target — только header. Inline-expand на conn row делал весь tile (включая раскрытую секцию) кликабельным. Тапаешь на кнопку «View in Domains» или хочешь скопировать CNAME — row схлопывается. Завернул InkWell только в header'ную часть, expanded section живёт отдельно.

Debug API — для глаз и для агентов

Все controls UI доступны через localhost HTTP API (Bearer-token authenticated, bind на 127.0.0.1, anti-rebinding host-check). Эндпоинты — /profiler/start, /profiler/stop, /profiler/active, /profiler/session/<id>?include=domains,ips,events, /profiler/sessions, плюс SSE-стрим /profiler/stream для real-time event feed'а. Полная reference — в [docs/api/debug-api-reference.md](https://github.com/Leadaxe/LxBox/blob/main/docs/api/debug-api-reference.md).

Это про себя круто, но настоящая ценность — другая. Это API не для меня, это API для агента.

Сценарий который реально работает у меня сейчас:

  1. Втыкаю телефон в кабель, поднимаю adb forward tcp:9270 tcp:9269 (один раз — потом скрипт scripts/ensure-wifi-adb.sh восстанавливает соединение через wifi автоматически)

  2. Открываю Cursor / Claude Code / любой coding agent в проекте L×Box

  3. Пишу: «Tinkoff Investments login виснет, разберись» — и кидаю токен Debug API из app'а

  4. Иду пить чай

Дальше агент сам:

  • Дёргает /profiler/start с package=ru.tinkoff.investing

  • Просит меня сделать действие в app'е (тапнуть Login)

  • Пуллит /profiler/session/<id>?include=domains,events

  • Видит split: 80% доменов через direct-out, 3 — через vpn-1 (несимметричный routing внутри одного app'а — это ровно то, что ломает auth)

  • Раскапывает CNAME chain в самой session JSON'е

  • Пишет: «Корень — *.trbcdn.net уходит на .net TLD, в ru-domains rule_set его нет, добавь suffix»

  • Если попросить — сам делает PR с правкой template'а через Edit-tool, прогоняет тесты

Возвращаюсь — на экране summary проблемы, ссылка на git diff, иногда уже зелёный CI. Времени потрачено — на чай.

Это особенно работает потому что вся диагностика structured: agent читает не grep по логам, а typed JSON с domain / ip / cname_chain / outbound_chain / issues полями. SSE-стрим (fire-and-forget, без Last-Event-ID reconnect — overkill для in-app single-user use case'а) даёт live-feed для агентов которые хотят не пуллить, а реактивно ловить события.

Собственно фичу Per-app traffic profiler я во многом и ради этого сделал — раньше тот же flow был «дай мне 10 файлов logcat / state / connections, я разберу», теперь — «дай адрес API и подожди».

Что не делает (пока)

DNS-уровень с CNAME chain'ом — для каждого резолва записываю не только финальный IP, но и всю цепочку: cdn.t-bank-app.ru → cl-ead2c819.edgecdn.ru → 193.17.93.194. Это главное чего нет ни в одном другом Android роутере клиенте, и без чего сложные инциденты не отлавливаются.

Connections с outbound chain'ом — для каждого TCP/UDP conn'а видно через что роутится: direct-out или vless-server → 🇫🇮Финляндия (vpn-1), с детуром если он есть. Split-routing-диагностика без видимости outbound'а невозможна — это и есть та симметричная половина к domain'у, ради которой я фичу делал.

Connection-issue detection (DNS timeout + TCP RST early) — пассивная ⚠ подсветка на event'ах, чтобы не сканировать самому список из 50 доменов искать «что плохо». DNS timeout — прямой engine-сигнал из лога sing-box, TCP RST early — heuristic (closed <1с с 0 bytes).

Process inference fallback — когда sing-box find_process не атрибутировал conn к package'у (характерно для WebView и system services), пробую attribute по prior DNS resolved IPs в окне 10 секунд, помечается 〽. Без этого для WebView-based apps вроде того же Tinkoff'а часть трассы была бы дырявая.

JSON export через Share — один тап на иконку share — native Android Share intent с полным session JSON'ом (events + aggregates + meta). Закрывает workflow «записал → отправил себе/агенту/в Telegram → продолжил». Дешёвая ниточка, но без неё фича была бы dead-end.

Debug API + SSE — все UI-controls доступны через localhost HTTP API + Server-Sent Events стрим. Это foundation для agent-driven диагностики (про что я выше написал) — без него фича была бы просто-ещё-один-UI-tab. Самое ценное для меня лично.

HTTP-уровневые URL'ы и headers — сейчас видно только domain:port, а не GET /api/v3/auth/login, User-Agent, Authorization. Полезно было бы для full API-аудита (какой именно endpoint app дёргает, какие токены утекают). Но sing-box работает на уровне TUN/SOCKS — к нему трафик приходит уже зашифрованный TLS'ом, расшифровать без MITM-сертификата нельзя. Так что это принципиальная limit'ация архитектуры, не моя.

Per-domain latency / RTT — сейчас собираю только bytes и conn count. Добавить TCP handshake timings и DNS round-trip — было бы понятно, какой CDN-нод тормозит, а не «весь сервис тупит». Sing-box частично эту информацию пишет в dns: exchangedNms префиксе), TCP-handshake придётся probing'ом мерить отдельно, пока как идея чисто.

Inline создание routing rules[Block this domain] / [Add to ru-direct] кнопками прямо в Domains tab. Закрывает feedback loop: увидел проблему в trace → одним тапом сделал rule → проверил → не надо переключаться на Custom Rules screen и копировать имя домена руками. Это самое near-term — UI почти готов, осталось wire'нуть в существующий CustomRule API, но не ясно нужно ли, кстати агенты через API эту работу могут сделать без UI.

TLS fingerprinting (JA3/JA4) — хеш TLS ClientHello для каждой connection. Очень полезно для дебага DPI-проблем соединений: многие провайдеры в РФ блокируют не по домену, а по JA3 (распознают что вас sing-box а не Chrome). Sing-box внутри умеет fake'ить fingerprint через outbound/uTLS (мимикрия под Chrome/Firefox/Safari), но сам fingerprint наружу через libbox API пока не expose'ится. Жду в апстриме.

Где это работает

Релиз L×Box v1.7.0 (тема цикла — «Observability»). Android 8.0+ (minSdk 26), arm64-v8a / x86_64. Sing-box find_process: true нужен в config'е (по дефолту включён в моём wizard-template'е).

Open-source: github.com/Leadaxe/LxBox. Apk'и собираются исключительно через CI/CD на GitHub, телеметрии и сетевых обращений «налево» нет. Spec — [docs/spec/features/044](https://github.com/Leadaxe/LxBox/tree/main/docs/spec/features). User guide на per-app trace — [docs/features/per-app-trace.md](https://github.com/Leadaxe/LxBox/blob/main/docs/features/per-app-trace.md).

Если у вас есть кейсы где per-app трассировка реально пригодилась бы (или наоборот — где её всё ещё не хватает), идеи по issue rule'ам / locale-агностичной geo-проверке через geoip-lookup, или просто желание помочь с UI — буду рад issue'м и pull request'ам.

Sing-box внутри уже знает кто куда ходит и как роутится. L×Box — просто интерфейс, который наконец показывает это людям, не заставляя вручную matching'ать conn_id'ы между log-снапшотами.