Недавно я дебажил проблему «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/coreLog → AppLog (мой 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 лог |
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 для агента.
Сценарий который реально работает у меня сейчас:
Втыкаю телефон в кабель, поднимаю
adb forward tcp:9270 tcp:9269(один раз — потом скриптscripts/ensure-wifi-adb.shвосстанавливает соединение через wifi автоматически)Открываю Cursor / Claude Code / любой coding agent в проекте L×Box
Пишу: «Tinkoff Investments login виснет, разберись» — и кидаю токен Debug API из app'а
Иду пить чай
Дальше агент сам:
Дёргает
/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уходит на.netTLD, вru-domainsrule_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: exchanged (в Nms префиксе), 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-снапшотами.
