Managed DoH через proxy возвращал настоящий origin: узел перенаправляет на свой resolver только DNS на порт 53, а зашифрованный запрос проходит мимо этого перенаправления. Клиент получал реальный адрес управляемого имени и шёл к нему через тот же узел, получая региональный отказ. Managed-этап (явный Secure и соответствующий кандидат Auto) теперь использует обычный DNS на 53 через выбранный proxy outbound: udp, затем tcp для сетей, где UDP через прокси не проходит. Bootstrap вне туннеля не тронут — он остаётся единственным местом, где провайдер может подменить DNS, и его защищает LKG. Прежние теги zapret-doh-* остаются в списке сгенерированных, чтобы рантайм-копия профиля от старой версии теряла их при следующем запуске. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
98 KiB
Zapret KVN Android — главный план архитектуры
Актуальная карта пакетов обеих платформ: структура исходников.
Читать первым. Это главный документ для человека или ИИ, продолжающего проект. Он фиксирует границы MVP, владельцев данных, порядок реализации и запрещённые усложнения. Детали сетевой политики находятся в ROUTING_ARCHITECTURE.md, детали DNS и Android VPN — в DNS_ARCHITECTURE.md, rootless hardening — в VPN_HIDING_ARCHITECTURE.md, границы входных форматов — в IMPORT_FORMATS.md, рабочие TODO и gates — в IMPLEMENTATION_PLAN.md.
| Поле | Значение |
|---|---|
| Статус | Этапы 0–7 и automated Gate 8 реализованы; внешний signing/release и физические release-gates открыты |
| Последний аудит | 25 июля 2026 года |
| Минимальная ОС | Android 8.0, API 26 |
| Устройства MVP | Только телефоны |
| Ядро | sing-box-extended tag v1.13.14-extended-2.5.2 |
| Точный commit | ff11f007ec798136a5de258f947a4f34011a37ea |
| Текущий workspace | Android app + изолированные app-updater/network-bootstrap/import libraries, один VPN service, pinned libbox и packaged .srs |
Порядок доверия
Если документы кажутся противоречивыми:
- Этот файл определяет продукт, объём MVP и порядок работ.
- Routing ADR определяет per-app, domain/IP/rule-set и
direct/proxy/reject. - DNS ADR определяет
VpnService, DNS, Private DNS, bootstrap и сетевой lifecycle. - VPN Hiding ADR определяет допустимый rootless hardening effective runtime.
- Политика форматов определяет допустимые преобразования URI/subscription в JSON.
- Поведение ядра определяется зафиксированным исходным commit и проверяемым patchset, SHA-256 которого входит в build/release/diagnostic metadata.
Нельзя молча придумывать второй исполняемый формат конфигурации, скрытые правила или исправлять профиль без отражения в effective JSON. Ограниченные глобальные runtime-intent допустимы только после явной фиксации в ADR, с видимым состоянием в UI, тестом и отражением результата в effective diagnostic JSON.
Цель MVP
Zapret KVN — независимый нативный Android-клиент sing-box-extended:
- импортирует JSON, ссылки, подписки, QR, буфер и файл;
- позволяет подключиться без ручной работы с JSON;
- сохраняет настоящий sing-box JSON и неизвестные extended-поля;
- даёт простой GUI для наиболее частых полей и raw-редактор для остальных;
- для новых managed-профилей по умолчанию направляет RU и LAN напрямую, а остальное через VPN только у выбранных приложений, экономя VPN-сервер;
- распространяется готовым APK только через Forgejo Releases.
Основной приоритет: корректность → простота → скорость → дополнительные функции.
Канонические решения
| Область | Решение MVP |
|---|---|
| UI | Kotlin, Jetpack Compose, Material 3 |
| Навигация | 4 нижние вкладки: Главная, Профили, Маршрутизация, Настройки |
| Архитектура проекта | app владеет продуктом; app-updater изолирует release/download/verification; network-bootstrap изолирует Android network/DNS; libraries не зависят от app |
| Состояние UI | ViewModel + StateFlow |
| DI | Один ручной AppContainer, без Hilt/Koin |
| Процесс | Один Android process; VpnService, UI и libbox без отдельного android:process |
| VPN | Один foreground VpnService, один Android TUN, один libbox instance |
| Конфигурация | Настоящий sing-box JSON — база транспорта профиля; общие destination-rules хранятся как ограниченный UI intent и компилируются в единственный effective sing-box JSON |
| Импорт | Вход сразу преобразуется в JSON; Clash YAML и неподтверждённые URI не входят в MVP |
| Настройки | DataStore только для глобальных/UI-настроек |
| Профили | JSON-файлы через AtomicFile, одна резервная копия |
| Метаданные | Маленький app-private index без копии route/DNS-конфигурации |
| Приложения | Android allowlist применяется один раз в VpnService.Builder |
| Маршруты | sing-box route.rules: direct, selected proxy или reject |
| GEO/списки | Локальные binary .srs rule-set внутри APK |
| DNS | FakeIP выключен; Android bootstrap; Auto: DNS профиля → DNS узла → Android |
| Rootless hardening | Один compile-time/runtime overlay без process/thread/polling; localhost endpoints закрыты по умолчанию |
| Go runtime | Libbox.SetMemoryLimit(false): стандартный GC, без Android GC=10 |
| Телеметрия | Только session totals; 1 Гц лишь пока главная видима |
| Ядро | Встроено в APK и обновляется только вместе с приложением |
| Обновления | Forgejo Releases, Stable/Beta, APK + SHA-256 |
| Фоновая работа | Только включённый пользователем VPN service, включая видимую сетевую паузу без TUN; нет фоновой синхронизации |
Для targetSdk 34+ единственный VPN service объявляет foreground type systemExempted: официальная таблица Android прямо включает в допустимые случаи VPN-приложения, настроенные через системный VPN consent. specialUse не нужен и не создаёт лишнюю Play Console review-категорию. Lint пока требует SCHEDULE_EXACT_ALARM без учёта VPN-исключения, поэтому только на service стоит точечный tools:ignore="ForegroundServicePermission"; alarm-разрешение намеренно не добавляется. См. Android foreground service types.
Неподвижные инварианты
- Физически существует ровно один Android TUN.
- Невыбранный UID не попадает в TUN и не виден libbox.
- Выбранный UID полностью попадает в TUN по IPv4 и IPv6.
- Android решает только область приложений; sing-box решает только назначения и outbound.
- Быстрые пресеты не создают дублирующие
package_nameroute-правила. - В управляемом Android TUN нет GEO/LAN route exclusions.
directпроходит через локальный core, но не использует VPN-сервер.- Новая блокировка использует
action: reject, а не legacy block outbound. - Блокировка действует только на приложения, уже включённые в область VPN.
- FakeIP, глобальный sniff, root и второй VPN/TUN по умолчанию запрещены.
- Любая ошибка после открытия TUN полностью закрывает PFD и core.
- Статус «Подключено» появляется только после DNS и HTTPS health-check.
- Приложение не держит
WakeLock, не использует alarm/job/WorkManager и не делает периодический health-check. - «Автоматически» сначала пробует DNS профиля, затем DNS узла через туннель и только после подтверждённой DNS-ошибки — DNS Android; каждый переход создаёт чистую bounded-сессию без фонового retry.
- Скорость не показывается в уведомлении; status/log streams существуют только пока нужен соответствующий экран.
- Управляемый профиль не создаёт NTP, remote rule-set, периодический URL-test или явный persistent keepalive сверх defaults выбранного протокола.
VpnService.Builder.allowBypass()не вызывается; rootless hardening не обещает скрыть системный TUN/TRANSPORT_VPN.- Updater не создаёт постоянного Forgejo route: VPN overlay существует только во время одной повторной операции, совпадает одновременно по package приложения и Forgejo hostname и всегда снимается через восстановление сессии.
Реальный сетевой путь
невыбранное приложение
└─ Android system network
TUN, libbox, DNS и VPN-сервер его не видят
выбранное приложение
└─ Android VpnService allowlist
└─ один TUN
└─ libbox / sing-box
├─ reject → локальный отказ
├─ direct → underlying Android network
└─ proxy → выбранный VPN outbound
Это максимально раннее безопасное отсечение на обычном Android без root. Приложение можно исключить до TUN, но нельзя запретить ему сеть публичным VpnService API: исключение означает direct. Домен появляется только после DNS/sniff/reverse mapping, поэтому доменное решение до sing-box невозможно.
Последовательность подключения
Один VpnController и один service-lock выполняют запуск строго последовательно:
- Проверить выбранный профиль и непустую allowlist.
- Получить системное VPN-разрешение при необходимости.
- Зафиксировать generation token и текущую underlying сеть через event-driven Android callback с
NET_CAPABILITY_NOT_VPN; собственный TUN не может стать underlying network. - Прочитать профиль и создать runtime-копию JSON.
- Проверить один TUN, полные IPv4/IPv6 routes, rule-set assets и запрещённые socket bind-поля.
- Очистить импортированные
include_package/exclude_packageтолько в runtime-копии и применить одну глобальную allowlist. - Добавить только разрешённые runtime overlays: health-check, runtime-only hosts bootstrap из свежего pre-VPN resolve или допустимого LKG строго для выбранного member, минимальный Android DNS для профиля без DNS и внутренний WireGuard MTU, если он отсутствует.
- Выполнить
libbox CheckConfig()доestablish(). - Проверить captive portal/Private DNS и разрешить адрес активного proxy-сервера через underlying Android network.
- Создать platform adapter и локальный libbox command server в том же Android process.
- Вызвать
startOrReloadService(): libbox синхронно вызываетOpenTun(TunOptions), а adapter создаёт одинVpnService.Builder, применяет приложения, адреса, полные routes и внутренний DNS. Builder.establish()возвращает Android PFD; libbox дублирует его FD и запускает единственный core поверх этого же TUN. Второго адаптера или второго TUN здесь нет.- Проверить DNS и HTTPS через Android TUN. Для HTTPS runtime-копия включает TLS sniff только для package Zapret KVN и порта 443, после чего точные probe-hostnames направляются в выбранный outbound. Это доказывает настоящий путь через proxy без глобального sniff и без отдельного WireGuard-only gate. В Auto подтверждённая именно DNS-ошибка полностью закрывает текущие core/PFD/callbacks и запускает следующий кандидат
профиль → DNS узла → Android; максимум три попытки внутри общего deadline 45 секунд. Ошибка data-plane, JSON или HTTPS-пути не считается DNS-ошибкой и не запускает переключение. - Только после успеха показать «Подключено»; сбор 1 Hz session-only статистики начать лишь при видимой главной.
Ожидание физической сети выполняется до шага 4 и не входит в общий deadline 45 секунд: сначала ждём зрелую сеть 10 секунд, затем любую пригодную ещё 15. Бюджет подключения не должен тратиться на то, что Android ещё поднимает Wi-Fi.
Любая ошибка или revoke выполняет один идемпотентный stop без ожидания общего lifecycle mutex: generation немедленно инвалидируется, текущий lifecycle job отменяется, Android TUN/PFD закрывается первым, затем последовательно закрываются callbacks, command clients, libbox service и command server. Монитор сети принадлежит сервису и переживает сессию: сессия снимает только свою подписку. Терминальный Error/Stopped публикуется только после этого cleanup, чтобы немедленный повторный запуск не пересёкся со старым foreground startId на Android 8–9. Невыбранные приложения всё это время продолжают работать напрямую.
Автоматическое восстановление
Транзиентный отказ не терминален. NET-101 переводит сервис в состояние «Переподключение» и ждёт события ConnectivityManager о пригодной сети; NET-102, DNS-101 и DNS-105 повторяются через ограниченный backoff 1 → 2 → 4 секунды с потолком 15. Отказ без стабильного кода тоже считается транзиентным, если физическая сеть сменилась под уже работающей попыткой: её результат ничего не говорит о профиле.
Ожидание физической сети не ограничено по времени: выключенная на ночь сеть не должна превращаться в ошибку, которую пользователь потом чинит руками. Это ожидание события ConnectivityManager, а не серия попыток — ни таймера, ни опроса, ни wakelock оно не создаёт, а состояние и кнопка «Остановить» всё это время видны.
Границы применяются к числу попыток подключения: три подряд идущих на одной сети и восемь на всю серию. Счётчик подряд идущих обнуляется при появлении другой физической сети, общий — только при успешном подключении или действии пользователя; он же и есть страховка от ping-pong Wi-Fi ↔ cellular. По исчерпании публикуется исходный код отказа и сервис останавливается.
Всё остальное остаётся терминальным немедленно: профиль, allowlist, политика Android, strict Private DNS, deadline подключения, отказ data-plane и captive portal (NET-110). Captive portal исключён сознательно — без авторизации в браузере сеть не станет пригодной, и держать foreground-сервис в ожидании действия пользователя дороже честной ошибки.
На время восстановления TUN закрыт, поэтому выбранные приложения временно идут напрямую — ровно как и при терминальной ошибке. Callback сети закрывается до публикации терминального состояния, чтобы «остановлено» означало полностью освобождённые ресурсы.
Включённая по умолчанию настройка «Только IPv4 через VPN» влияет только на сгенерированные DNS-правила доменов, которые effective route отправит в proxy. Она действует в явных Secure/DNS Android и после перехода Auto к этим managed-этапам, но не меняет первую попытку с DNS профиля, direct/LAN, IPv6-маршрут TUN, сохранённый профиль или режим «Из JSON». Это предотвращает выбор AAAA у dual-stack сайтов для WireGuard-профиля только с внутренним IPv4. IPv6-only трафик требует настоящего IPv6-адреса внутри WireGuard-профиля; отключение фильтра не может добавить его автоматически.
Baseline underlying-сети остаётся неизменяемым на всю сессию. Callback-flap
A → B → A отменяет pending restart; после debounce итоговый policy key
проверяется ещё раз. Контролируемый restart выполняется только если сеть или
DNS/captive policy действительно остались отличными от baseline.
Debounce зависит от зрелости новой сети: 750 мс, если Android уже отдал резолверы
и вердикт о доступе в интернет, иначе 3 секунды с потолком ожидания 12 секунд.
Перезапуск на ещё не настроенном кандидате заканчивался NET-101/NET-102, а сети,
которые Android никогда не пометит validated, не должны навсегда удерживать сессию
на мёртвом линке.
Автоматизация по типу сети
По умолчанию автоматизация выключена и lifecycle полностью совпадает с прежним: пользователь запускает VPN вручную, а активная сессия переживает смену underlying сети контролируемым restart. После явного включения пользователь независимо выбирает, должен ли KVN работать в Wi-Fi, cellular, Ethernet и неизвестных Android transport. Дополнительный bounded-список содержит не более 32 доверенных SSID; его правило можно временно отключить без удаления списка.
Автоматика вооружается только явным запуском VPN. Если текущая сеть запрещена правилом,
TUN/PFD, core и command clients полностью закрываются, а тот же foreground VpnService
остаётся в видимом состоянии Paused с одним event-driven NetworkCallback. При появлении
разрешённой сети сервис запускает последний профиль; ручная остановка закрывает callback,
notification и сам сервис. Кнопка «Подключить сейчас» временно игнорирует правило только
для текущей identity underlying-сети; следующая смена сети снова применяет сохранённую
политику. Polling, alarm, job, WorkManager и WakeLock не создаются.
Правило «весь Wi-Fi» использует только transport и не требует runtime-разрешения. Точное
правило доверенного Wi-Fi использует SSID из WifiInfo; Android может открыть его только
после явного разрешения точного местоположения и при включённой системной геолокации.
Если SSID недоступен или отозван, политика fail-open относительно сети: VPN остаётся
включённым. SSID хранится только локально; diagnostic JSON содержит лишь число сетей и
флаги политики. Временный updater route считается явным пользовательским действием,
может кратко обойти сетевую паузу и затем восстанавливает прежнее paused/connected состояние.
Экраны
Главная
├─ Состояние и большая кнопка подключения
├─ Профиль и выбранный сервер
├─ Внешний IP, Relay HTTPS, ICMP и время сессии
├─ Download / Upload
└─ Лёгкий график последних 60 секунд
Профили
├─ Подписки
├─ Импортированные
├─ Файлы
└─ Добавить: QR / буфер / URL / файл
Маршрутизация
├─ Область VPN: выбранные приложения
├─ Правило трафика: preset
├─ Читаемый итог двух решений
├─ Правила: VPN / напрямую / блокировать
└─ Расширенные: rule-set / raw JSON
Настройки
├─ Оформление и DNS
├─ Автоматизация VPN и доверенные Wi-Fi
├─ Обновления
├─ Скрытие VPN
├─ Диагностика
├─ Сообщество
└─ О приложении
Главная
Компактная карточка показывает максимум полезного без тяжёлого dashboard:
- состояние;
- профиль/сервер;
- IP, Relay HTTPS и ICMP как независимые показатели;
- время;
- получено/отправлено;
- две тонкие линии скорости за 60 секунд.
Статистика хранится только в памяти текущей сессии. Суммарные счётчики читаются раз в секунду только пока главная находится в lifecycle STARTED; при уходе с экрана stream и ticker закрываются. IP запрашивается один раз после подключения; Relay HTTPS и ICMP запускаются только явной командой «Проверить оба». Постоянное уведомление показывает только состояние, а не живую скорость.
В production runtime всегда создаётся внутренний clash_api traffic manager, но
без external_controller или сетевого listener. Постоянный command client слушает
только event-driven группы. Отдельный CommandStatus client с интервалом 1 секунду
существует лишь при одновременно STARTED Activity и выбранной вкладке «Главная»;
его закрытие прекращает ticker в самом core. CommandConnections не создаётся.
CommandLog кратко захватывает bounded core-лог во время connect health-check,
затем существует только при открытом экране диагностики.
Relay HTTPS и ICMP имеют разные state machine и никогда не подменяют друг друга.
Relay выполняет HEAD https://www.gstatic.com/generate_204 через конкретный leaf-outbound:
тестовое TCP/TLS/HTTP-соединение создаётся, но selector/urltest selection, history и
активные соединения не изменяются. ICMP — RTT одного Echo до hostname endpoint;
сокет привязывается к underlying Network через Android Network.bindSocket() и
не проходит через TUN. Все DNS-адреса endpoint пробуются до первого ответа.
Интерфейс различает «Не проверено», running, success, timeout/error, unsupported и
stale; успешное значение устаревает через пять минут или сразу после смены сети.
Один session-owned coordinator допускает только один отменяемый запуск: Relay идёт
очередью по 10, ICMP — по 4, вложенная группа открывается отдельно. Периодического
ping-loop нет. Внешний IP
дополнительно запрашивается один раз после успешного подключения или ручной смены
сервера через dual-stack ipify endpoint; ошибка этого
неблокирующего запроса не отключает VPN и не запускает retry. Значения, 60 точек
графика и URL-test history живут только до остановки текущей сессии.
Состояния: нет профиля, нет выбранных приложений, выключено, проверка, подключение, подключено, переподключение, отключение, ошибка. Переподключение показывает причину и номер попытки, кнопка остаётся «Остановить»: сервис жив и поднимет VPN сам. Ошибка ведёт в «Диагностику».
Маршрутизация
Пользователь видит не две конкурирующие «маршрутизации», а две карточки:
Область VPN
Только выбранные приложения · YouTube, Discord · 7 >
Блокировать приложения · 0 >
Правило трафика
Россия напрямую · остальное через VPN >
Общая политика · для всех профилей
Итог
выбранные: RU → напрямую, остальное → VPN
остальные приложения: напрямую, вне VPN
destination-блокировка: только для выбранных приложений
полная блокировка приложений: отдельный пустой по умолчанию список
Список приложений открывается отдельным полноэкранным picker с поиском. Общие
destination-rules остаются на том же экране; raw-редактор меняет только базовый JSON
активного профиля. При запуске общая политика накладывается в памяти до
Libbox.CheckConfig(), а сохранённый профиль не переписывается. Кнопка редактора
называется «Исходный JSON профиля» и рядом явно объясняет эту границу. Выбор другого
профиля при подключённом VPN заменяет текущую сессию новой сессией выбранного
профиля; повторный выбор уже подключённого профиля ничего не перезапускает.
Редактор правила имеет ровно три обычных действия:
- «Через VPN»;
- «Напрямую»;
- «Блокировать».
Глобальные presets:
- Всё через VPN;
- Обход LAN;
- Только выбранные сайты;
- Россия напрямую, остальное через VPN;
- Россия через VPN, остальное напрямую;
- Пользовательский.
Whole-app block не входит в основной MVP: он потребовал бы третьего состояния каждого приложения и package_name → reject. Advanced JSON может содержать такие правила под ответственность пользователя.
Хранение и источник истины
DataStore
├─ тема / канал обновлений / bounded DNS runtime-настройки
├─ сетевая автоматика / не более 32 локальных доверенных SSID
├─ активный профиль
├─ глобальная per-app allowlist и include/exclude mode
└─ routing_policy: preset + bounded destination-rules без credentials
files/profiles/
├─ index.json — только id, название, source и created/updated timestamps
├─ <id>.json — настоящий sing-box JSON
└─ <id>.json.bak — одна предыдущая версия
files/rule-sets/
├─ manifest.json
└─ *.srs — проверенные встроенные binary rule-set
cache/
└─ временный APK и диагностический экспорт
index.json содержит только UI/import metadata и не дублирует DNS, outbound или route
rules. routing_policy содержит явный GUI-intent, но не копию профиля или исполняемый
JSON; единственной конфигурацией ядра остаётся собранный effective sing-box JSON.
Профиль и index записываются атомарно. Для профиля AtomicFile сначала формирует отдельный <id>.json.atomic, затем два rename оставляют либо прежний читаемый <id>.json, либо новый <id>.json и ровно один <id>.json.bak. Отдельный staging нужен потому, что служебный suffix .bak самого platform AtomicFile конфликтовал бы с постоянным пользовательским backup. При запуске незавершённый staging удаляется, а отсутствующий основной файл восстанавливается из backup.
До create/update/restore вызывается native Libbox.checkConfig(). Файл и буфер ограничены 4 МБ; файл читается только после ответа системного OpenDocument, явного Android ACTION_VIEW либо ACTION_SEND с одним потоковым URI, буфер — только в обработчике явного нажатия и на main thread для совместимости с API 26. Внешний intent принимает только выданный другим приложением URI и ведёт в тот же preview, без автосохранения и автоподключения. Credentials никогда не копируются в index или DataStore.
Глобальный include allowlist хранится отдельно в DataStore vpn_scope. Флаг initialized отличает первый запуск от уже созданного списка: установленные popular suggestions, Android-браузеры, обработчики схемы tg:// и ссылок YouTube выбираются при первой инициализации. Версия suggestions разрешает ровно одну миграцию новой группы известных package: установленные ChatGPT/Claude/Gemini и связанные AI/media/productivity-приложения один раз добавляются к существующему include-списку после обновления. В exclude-режиме они не добавляются к прямому обходу, потому что неотмеченные приложения уже входят в VPN. После миграции ручное снятие не отменяется и приложение не возвращается автоматически. Каталог пакетов читается один раз при создании AppsViewModel и вручную при открытии picker; фонового polling и таймера нет. Собственный package приложения отбрасывается при чтении/записи DataStore и скрыт из каталога. Полный список нужен основной per-app функции, обрабатывается только локально и никогда не попадает в сеть, аналитику или диагностику.
Перед будущим Builder.establish() единственный VpnAppScopePreflight нормализует пользовательский список и отбрасывает отсутствующие/отключённые package до вызова Builder. Они попадают только в bounded diagnostic log и не мешают запуску, если осталось хотя бы одно доступное приложение. Нулевой итоговый список блокирует запуск. Затем preflight добавляет доступные пользовательские package и внутренний package Zapret KVN через addAllowedApplication(). Любое исключение Builder для существующего package возвращает блокирующий результат; частично заполненный Builder после ошибки не используется. Внутренний package существует только в effective platform allowlist для health-check и никогда не сохраняется как пользовательский выбор.
JSON редактируется через kotlinx.serialization.json как дерево. Неизвестные поля сохраняются. После GUI-изменения могут исчезнуть комментарии и исходные отступы; raw editor показывает это заранее.
Managed presets и основные destination-rules атомарно сохраняются как общая bounded policy и компилируются в runtime-копию активного JSON. Raw/неизвестные route-поля остаются только в профиле. Итоговый overlay всегда доступен в redacted-диагностике.
Подписки обновляются только вручную. Нет WorkManager, таймеров или скрытого сетевого refresh.
Профили, серверы и переключение
Один профиль — один настоящий sing-box base JSON. Proxy-серверы находятся в его
массиве outbounds; WireGuard/AmneziaWG 2.0 использует нативный массив sing-box
endpoints. Отдельной таблицы серверов, INI runtime-слоя и связанного набора
полноразмерных JSON-шаблонов нет.
Профиль, созданный из одиночной ссылки, всё равно получает managed selector с одним сервером. Следующую одиночную ссылку можно сохранить новым профилем либо явно добавить сервером в существующий managed-профиль. Одна подписка по умолчанию создаёт один профиль-группу: несколько server outbounds и один основной selector. В предпросмотре импорта её можно вместо этого разложить по одному профилю на сервер (не более 200): профили создаются только после того, как ядро приняло каждый JSON, имя берётся из имени сервера, а URL и настройки источника сохраняются как одна split-группа вне profiles/index.json.
Ручное обновление любого участника split-группы загружает подписку один раз и атомарно согласует все связанные профили. Стабильный credential-free ключ строится из протокола, endpoint и транспорта: сменившиеся UUID, password и obfs обновляют существующий JSON с сохранением его индивидуальных маршрутов; новые endpoint создают профили с RussiaDirect, исчезнувшие удаляются после preview. Удалённый пользователем профиль остаётся исключённым и не появляется снова при следующем refresh. Обновление настроек клиента/HWID через любой профиль применяется ко всей группе. У создаваемого приложением selector стабильный tag zapret-proxy; managed route и managed DNS ссылаются на него. Tag сервера строится из его имени в ссылке и сохраняет буквы любого алфавита (похожие на секреты строки вырезает SecretRedactor), потому что именно он виден пользователю в списке серверов.
Упрощённый фрагмент:
{
"outbounds": [
{
"type": "selector",
"tag": "zapret-proxy",
"outbounds": ["server-a", "server-b"],
"default": "server-a",
"interrupt_exist_connections": true
},
{
"type": "vless",
"tag": "server-a",
"server": "vpn-a.example",
"server_port": 443,
"uuid": "<UUID>"
},
{
"type": "vless",
"tag": "server-b",
"server": "vpn-b.example",
"server_port": 443,
"uuid": "<UUID>"
}
],
"route": {
"final": "zapret-proxy"
}
}
GUI получает список групп и текущий выбор из libbox. При работающем VPN переключение вызывает CommandClient.SelectOutbound("zapret-proxy", serverTag) без пересоздания TUN/core. interrupt_exist_connections: true закрывает только соединения из внутренней interruptGroup этого selector. Отдельный route в outbound direct в эту группу не входит, а невыбранные UID вообще отсутствуют в TUN; поэтому оба пути не затрагиваются. Это подтверждено реализацией exact selector и Android integration-тестом switch без смены TUN.
Без запущенного ядра список серверов берётся из сохранённого JSON: ProfileServerCatalog
собирает selector-группы и описания outbounds, поэтому карточка профиля и главный экран
показывают «Серверов: N · выбран: tag» и открывают тот же выбор сервера, что и при
подключённом VPN. Выключенный VPN меняет только selector.default в профиле через
ProfileStore.update() (с CheckConfig() внутри) и ничего не запускает; подключённый VPN
идёт прежним путём через libbox. Отдельного хранилища выбора нет ни в одном из режимов.
Выбор сохраняется атомарным изменением selector.default в самом JSON после
CheckConfig(); отдельного DataStore для выбранного сервера нет. Ядро при этом ведёт
собственный cache.db, и его запись приходится нейтрализовать явно: needCacheFile
включается уже от одного PlatformLogWriter, то есть на Android всегда, а
Selector.outboundSelect
читает cache.db раньше default и полностью его игнорирует при попадании. Кэш общий
для всего приложения и ключуется тегом группы, а zapret-proxy одинаков у всех
управляемых профилей, поэтому застрявшая запись переживала и выбор сервера при
остановленном ядре, и смену профиля: пользователь видел один сервер, а трафик шёл через
другой. Поэтому сразу после старта ядра, до проверки DNS/HTTPS,
SelectorCacheReconciliation навязывает каждой группе сервер из JSON —
default, а при его отсутствии первый член группы, то есть ровно то, что ядро выбрало бы
без кэша. Вызов идемпотентен: совпадающий выбор ядро не переписывает и соединения не рвёт.
Если runtime-переключение не удалось, выполняется один контролируемый
restart уже с проверенным JSON. Переключение целого профиля всегда делает restart,
потому что у профиля могут отличаться transport, DNS, расширенные raw-routes и
TUN-настройки; общая GUI-policy при этом сохраняется.
Raw JSON не нормализуется скрыто. В режиме «Из JSON» GUI показывает существующие selector-группы как есть. Общая routing-policy и rootless overlay из VPN Hiding ADR являются явно показанными runtime-исключениями: policy компилирует destination-rules, а hardening удаляет локальные control endpoints и блокирует non-TUN inbound. Сохранённый JSON не меняется, effective-результат отражается в диагностике.
Полные JSON-шаблоны не являются долгоживущими данными. ManagedProfileFactory только
один раз собирает начальный JSON из маленького base builder, protocol outbound builder
и selector builder; после сохранения он становится источником истины для transport,
outbounds и расширенных raw-полей, а общая destination-policy остаётся отдельным
bounded intent. При ручном обновлении подписки выбранный server tag сохраняется, если
он всё ещё существует; иначе выбирается первый доступный сервер и показывается
уведомление.
Профили с credentials находятся только в app-private storage; Android Auto Backup для них выключен. Экспорт диагностики всегда redacted.
Приложения
Режим по умолчанию — include:
- пользователь выбирает приложения, которые должны войти в VPN;
- Zapret KVN добавляет собственный package внутренне для health-check и служебных операций через VPN;
- пустой итоговый список запрещает запуск;
- исчезнувший/отключённый package пропускается с записью в bounded diagnostic log; запуск блокируется, только если доступных выбранных приложений не осталось;
- Builder-ошибка существующего package отменяет запуск целиком;
- остальные приложения идут напрямую.
Advanced exclude-mode поддерживается, потому что он был выбран в требованиях, но не является default. Пустой exclude-list всегда блокирует запуск: иначе весь телефон неожиданно войдёт в VPN.
В APK хранится небольшой список подсказок: Instagram, YouTube и YouTube Music (официальные, ReVanced, ReVanced Extended, Vanced и Morphe), Telegram Stable/Beta/Direct, Telegram X, ZaStoGram, распространённые Telegram-форки, WhatsApp, Discord, Signal, ChatGPT, Claude, Gemini, Perplexity, Copilot, DeepSeek, Grok, Suno, Spotify, Notion и известные браузеры, включая Chromium/Ultimatum; TikTok исключён. Дополнительно при первой инициализации Android сообщает все установленные обработчики CATEGORY_APP_BROWSER, браузерных HTTPS-ссылок, схемы tg:// и ссылок youtube.com/youtu.be/vnd.youtube, поэтому неизвестный заранее браузер, Telegram- или YouTube-клиент тоже выбирается. Произвольные неизвестные приложения автоматически не добавляются. Системные скрыты до нажатия «Показать системные».
После импорта профиль сохраняется без автоматического подключения. Диалог «Выбрать приложения» показывается только при инициализированной allowlist с нулём выбранных package. Снятие выбора с одного приложения при наличии остальных окно не вызывает. Исчезнувший или отключённый package пропускается с bounded diagnostic warning; запуск блокируется только тогда, когда доступных выбранных приложений не осталось. Если хотя бы одно приложение выбрано, импорт завершается обычным snackbar без лишнего окна. После очистки defaults скрыто не восстанавливаются.
Routing и rule-set
- Legacy
geoip/geositeне генерируются: в sing-box 1.13 они удалены. - Используются domain/IP
route.rule_set. - Встроенные presets зависят только от локальных
.srs, входящих в APK. - Нет managed remote rule-set, отдельного updater или
experimental.cache_file. .srsобновляются только вместе с APK и проверяются SHA-256.- Rule-set содержит совпадения, но не выбирает outbound: действие и порядок компилируются в effective JSON из общей политики.
- Пользовательские inline/local/remote rule-set разрешены только как явный JSON-сценарий.
Domain block создаёт DNS reject и route reject. IP block создаёт только route reject. Глобальный sniff ради блокировки не включается. Встроенный DoH приложения может скрыть domain-only блокировку; полноценный firewall/ad blocker не обещается.
Полная блокировка приложения — отдельная политика DataStore, а не правило профиля.
В include-режиме её пакеты добавляются к Android TUN boundary, в exclude-режиме не
могут одновременно быть direct-исключениями. Runtime-копия ставит для них первые
DNS/route package_name → reject; сохранённый JSON не изменяется.
DNS
Режимы GUI:
- Автоматически — выбранный DNS профиля; при подтверждённой DNS-ошибке чистая попытка с DNS узла через proxy, затем последний резерв — DNS Android с предупреждением;
- DNS Android — системная DNS/Private DNS политика;
- DNS узла через VPN — стандартный DNS выбранных приложений уходит на 53 через proxy, где его забирает resolver узла;
- Из JSON — существующая DNS-секция не переписывается; если секции/серверов нет, runtime-копия получает один local DNS Android,
hijack-dnsиdefault_domain_resolver, а сохранённый JSON остаётся прежним.
FakeIP выключен. Системные настройки Android приложение не меняет и Private DNS не обходит.
При strict Private DNS Auto не падает, а сужает цепочку кандидатов до «DNS Android» с предупреждением в логе — системный DoT уважается, пользователь ничего не настраивает. Secure при strict не запускается (DNS-110): он явно обещает собственный резолвер, а системный DoT обошёл бы port-53 hijack и reverse mapping. Явные режимы не получают скрытого fallback. Полная политика, bootstrap cache и fail-close описаны в DNS ADR.
Проверка простоты и скорости
| Возможная «оптимизация» | Результат аудита |
|---|---|
| Android allowlist | Используем: это единственное бесплатное отсечение до TUN по приложению |
| GEO через Android routes | Отклонено: 10 842 exclusions на API 33+ и около 35 041 complement routes на API 26–32 |
route_exclude_address_set |
Exact libbox не разворачивает set в Android platform TUN |
| Отдельные TUN для direct/proxy/block | Нельзя и архитектурно лишнее |
| Global sniff | Отклонён: читает первые пакеты и замедляет весь трафик |
| FakeIP | Отклонён по умолчанию: кэш и per-app edge cases |
| Root/TProxy/iptables | Вне неинвазивного Android MVP |
| Proxy-only mode | Быстрее только для приложений с ручным SOCKS/HTTP; ломает «скачал и работай» |
| Dynamic core/rule updates | Отклонены: больше кода, мусора и точек отказа |
| Remote managed rule-set | Отклонены: сеть может заблокировать запуск VPN |
| Fork ради owner lookup | Пока отклонён: exact core вызывает lookup безусловно, но сначала нужен device benchmark |
| DoH через proxy для трафика туннеля | Отклонён: узел перенаправляет на свой resolver только DNS на 53, а зашифрованный запрос проходит мимо и возвращает настоящий origin — управляемые имена переставали работать. Managed-этап использует UDP/TCP 53 через proxy; DoH остаётся только вне туннеля, в bootstrap |
| Отдельный VPN-процесс | Отклонён до профилирования: добавляет IPC/process overhead и усложняет lifecycle |
Pinned Android AAR собирается с with_gvisor. Для managed TUN поле stack не переопределяем: exact core выбирает upstream default mixed. Exact pinned core при пустом tun.mtu выбирает на Android 9000, а userspace WireGuard — собственный меньший MTU. Поэтому стандартный runtime задаёт внешний TUN явно: 1500 для остальных профилей и min(1500, effective userspace WireGuard endpoint MTU) для конфигураций с userspace WireGuard. Endpoint без MTU получает Android fallback 1280; это совпадает с default официального AmneziaWG Android. Режим «По профилю» сохраняет явно заданный TUN MTU, но при отсутствии поля также использует MTU userspace WireGuard endpoint и не возвращается к несовместимому 9000. Stored JSON не меняется. Исправление основано на системном logcat stable 0.2.3, где фактический Android TUN был 9000 при endpoint 1280; окончательный физический A/B 1280 ↔ 9000 остаётся обязательным.
Runtime-копия каждого профиля, включая raw JSON и endpoint-only WireGuard/AWG, всегда получает log.level=warn; явно более строгие error, fatal и panic сохраняются. trace, debug, info, notice и отсутствие уровня ограничиваются до warn, а log.output удаляется. Это обязательно, потому что exact core при отсутствующем log.level выбирает trace, что создаёт ненужную работу на data-plane. Сохранённый JSON не переписывается.
Итог аудита: безопасного более раннего фильтра для домена/IP на stock Android не найдено. Текущая схема использует самый ранний доступный UID-фильтр, binary IP/domain lookup и не гонит direct/reject на VPN-сервер. Дальнейшее ускорение возможно только ценой root, proxy-only, огромных system routes или собственного patch ядра.
Политика CPU и батареи
Экономичный default является частью MVP, а не будущей оптимизацией:
- приложение,
VpnService, локальный libbox command server и UI работают в одном Android process; управляемый профиль не создаёт второй service/process или внешний Clash controller; Libbox.setup()выполняется лениво при первом запуске VPN, а не вApplication.onCreate; обычное открытие UI не поднимает core, command socket или сетевые callbacks;- внутренний Clash traffic manager создаётся самим libbox из-за
PlatformLogWriterи даёт session totals без сетевого listener; в managed JSONexperimental.clash_api.external_controllerи external UI отсутствуют; Libbox.SetMemoryLimit(false)вызывается явно. В pinned core значениеtrueна Android меняет GoGCPercentсо100на10, не устанавливая Android memory limit;- runtime-копия managed JSON использует
log.level = "warn"; raw JSON сохраняет явно выбранный уровень. Libbox хранит не более 256 строк в памяти, аlog.outputудаляется только из runtime-копии, поэтому запись runtime-лога на диск отсутствует; CommandStatusс интервалом 1 секунда включается только для видимой главной;CommandConnectionsне включается никогда; boundedCommandLogоткрывается на время connect health-check и затем только при видимой диагностике;- 60 значений графика — обычный кольцевой массив в памяти. Уведомление, фон и закрытый UI не обновляют график;
- health-check выполняется при подключении и значимой смене сети, IP — один раз за соединение, Relay/ICMP — только явной командой. Периодических проверок «на всякий случай» нет;
- автоматическое восстановление после транзиентного отказа ждёт сеть по событию
ConnectivityManager, а не опросом; единственный таймер — ограниченный backoff повтора. Простой без сети не стоит ничего и потому не ограничен, а число попыток подключения ограничено жёстко; wakelock и alarm не используются; - DNS cache остаётся включённым с capacity 4096. Явный Secure или managed-этап Auto использует
fallback/sequentialнад двумя транспортами к одному resolver'у узла: UDP, затем TCP для сетей, где UDP через прокси не проходит; успешный DNS профиля managed-трафика не создаёт; - updater, подписки и импорт работают только после действия пользователя;
- rootless hardening выполняется один раз при сборке runtime JSON и не создаёт scanner, timer, listener или отдельный process;
- приложение не запрашивает
WAKE_LOCKи исключение из battery optimization; - managed presets используют selector и явную read-only проверку Relay/ICMP, а не policy
urltest; не создают NTP, remote rule-set и явные persistent keepalive.
Импортированный JSON остаётся источником истины и может сам содержать urltest, NTP, remote rule-set, внешний Clash controller, verbose log или keepalive. Перед запуском GUI показывает единое предупреждение «Профиль содержит фоновую или внешнюю активность». Включённая по умолчанию и видимая в настройках localhost-защита удаляет control listener только из effective runtime; остальные поля не меняются.
Обязательный release-gate энергии
Измеряется release/profileable APK, без debugger, минимум пять повторов каждого сценария при одинаковых яркости, температуре, сети и объёме данных:
- VPN выключен, экран погашен, трафика нет — baseline устройства.
- VPN подключён, экран погашен, managed-профиль без трафика — idle overhead.
- Невыбранное приложение передаёт фиксированный объём — Zapret KVN не должен получать его пакеты или расти по CPU пропорционально трафику.
- Выбранное приложение с
direct— цена TUN/core без VPN-сервера. - Выбранное приложение с proxy — полный пользовательский путь.
- Видимая главная против закрытой — цена status stream и Compose-графика.
- Серия уникальных DNS-имён — принятый
parallelпротив контрольногоsequentialпо надёжности, числу запросов, CPU и энергии. - Wi-Fi ↔ mobile, reconnect и сломанный DNS — отсутствие бесконечного retry и утечки jobs/threads/fd.
Сохраняются Perfetto/System Trace, process CPU time, RSS/PSS, GC count/pause, wakeups, wakelocks, network bytes/packets и энергия. На поддерживаемом устройстве используется Android Power Profiler/ODPM и Macrobenchmark PowerMetric; на API 26 — system trace и batterystats. APK не выпускается, если managed idle создаёт периодические сетевые запросы/таймеры, обнаружен app-owned wakelock/job/alarm, невыбранный трафик создаёт per-packet работу Zapret KVN или выбранный вариант не проходит стабильность DNS/IPv6/QUIC.
Жёсткий порог mW заранее не выдумываем: первый воспроизводимый прогон фиксирует baseline. Из двух совместимых вариантов принимается более экономичный; разница менее 5% считается шумом и не оправдывает усложнение архитектуры.
Структура модулей
app/ product orchestration, UI, profile/config, libbox и VpnService
app-updater/ Forgejo release API, download, checksum/APK/signing policy и retry callback
network-bootstrap/ Android underlying Network, согласованный snapshot, bootstrap DNS и коды NET/DNS
wireguard-import/ независимый parser WireGuard/AWG без зависимости от app
Новые Gradle-модули создаются только для самостоятельной границы с направленной зависимостью на app, а не для каждого слоя/класса. app-updater не знает о Compose, профилях, libbox и VPN lifecycle: app передаёт ему только installer factory и одноразовый VPN lease callback. network-bootstrap не знает о Compose, профилях, libbox и VPN lifecycle; app не реализует повторно bootstrap resolver или выбор физической сети. Конкретные продуктовые классы передаются через один AppContainer. VpnService остаётся единственным владельцем активного PFD/core lifecycle.
Что сознательно не входит в MVP
- Room/SQLite;
- Hilt, Koin или другой DI framework;
- WebView;
- аналитика и telemetry;
- WorkManager и периодическая синхронизация;
- отдельный Android process для VPN;
WAKE_LOCK, alarm/job и запрос исключения из battery optimization;- live speed в уведомлении и фоновый status/connection polling;
- динамическая загрузка или смена core;
- второй TUN/VPN service;
- root, iptables, eBPF и Device Owner режим;
- Always-on/Lockdown;
- FakeIP по умолчанию;
- глобальный traffic sniff;
- полноценный firewall/ad blocker;
- whole-app block в основном picker;
- планшетный/desktop layout;
- собственный параллельный движок правил;
- автоматическое исправление неизвестных JSON-полей.
Разрешения
INTERNET,ACCESS_NETWORK_STATE;- VPN consent при первом подключении;
- foreground VPN service и соответствующий Android 14+ service type/permission;
POST_NOTIFICATIONSна Android 13+;- камера только при открытии QR scanner;
QUERY_ALL_PACKAGESдля полного per-app picker на Android 11+; основнойgetInstalledApplications()сверяется с явнымиMAIN/LAUNCHERqueries, аgetInstalledPackages()вызывается только при ошибке, почти пустом результате или доказанном launcher-расхождении. Типизированный discovery-report различает полный, восстановленный, частичный и неудачный каталог без сохранения package list. Ошибка источника не заменяется придуманным сообщением: picker показывает имя операции, исходныйThrowable.toString()и фактические counts остальных источников;REQUEST_INSTALL_PACKAGESтолько для явно запущенного Forgejo updater и штатного installer;- системный file picker без broad storage permission;
- буфер только после явного нажатия.
WAKE_LOCK и REQUEST_IGNORE_BATTERY_OPTIMIZATIONS не запрашиваются. Usage Access, Accessibility, root и изменение системного Private DNS не используются.
Оформление, диагностика и обновления
Material 3 следует системной светлой/тёмной теме. Dynamic Color применяется на Android 12+; Android 8–11 получают встроенную согласованную палитру.
Диагностика остаётся обычным пакетом внутри app, а не отдельным Gradle-модулем или сервисом. Каждая попытка подключения/контролируемого restart создаёт в памяти bounded timeline максимум из 20 этапов с монотонной длительностью: профиль и scope, сеть Android, bootstrap DNS/TCP, runtime overlay, CheckConfig, platform adapter, command server, запуск core/TUN, selector/log clients, ожидание VPN network, отдельные UDP/TCP/Android DNS probes, HTTPS probe и финализация. Экран и diagnostic JSON v5 показывают итог, candidate attempt ID, причину отмены, leased VPN Network identity/loss, текущий этап, elapsed/remaining budget, socket path, фактический outer TUN MTU, status/detail каждого этапа и самый долгий этап. Отдельный stop timeline фиксирует отмену запуска, закрытие Android TUN, callback/job, command clients, libbox service, command server и network monitor; незавершённый этап экспортируется со статусом running и текущей длительностью. Замеры происходят только при переходах между этапами: ticker, polling, worker и дополнительный сетевой запрос для них не создаются.
Диагностика и главная показывают стабильный support-код (NET-*, DNS-*, SRV-*, CFG-*, CORE-*, VPN-*, AUTH-*) рядом с понятным сообщением. VPN-200 означает, что DNS-проверка уже пройдена, но полезный HTTPS-трафик через выбранный VPN outbound не прошёл. AUTH-100 назначается только по явному отказу авторизации в ошибке или startup-логе ядра; timeout, EOF и молчаливое закрытие VLESS не считаются доказательством отключённого UUID и остаются VPN-200. Для WireGuard startup core-лог различает два паттерна поверх VPN-200/DNS-200: VPN-210 — «received handshake response» есть, но данные через туннель не возвращаются (блокировка протокола DPI или сломанный форвардинг/NAT на сервере — с клиента неразличимо), VPN-211 — рукопожатие отправляется, но ответ не приходит; сырые строки-доказательства из core-лога (redacted) попадают в техническую деталь ошибки. VPN-210 требует явных доказательств мёртвого data-plane (retry «stopped hearing back» или «dns: exchange failed … deadline exceeded»), а успешный «dns: exchanged» через туннель отменяет вердикт: полевой случай июля 2026 показал, что strict Private DNS (DoT :853 refused) валит DNS-пробу при полностью живом туннеле. Сам паттерн VPN-210 подтверждён полевыми отчётами: тот же WG-профиль работал из другой сети и с AmneziaWG-обфускацией, то есть DPI режет именно чистый WireGuard после рукопожатия. Для bootstrap код задаёт типизированная ошибка network-bootstrap; для остальных старых путей app назначает стабильный код категории. Вся suspend-цепочка запуска имеет один 45-секундный monotonic deadline, а DNS+HTTPS health-gate — вложенный предел 20 секунд: по истечении VPN fail-close останавливается с VPN-120, без retry-loop. Diagnostic JSON хранит redacted техническую деталь (rcode, errno, timeout), но endpoint и credentials туда не попадают. В памяти остаются три последние попытки подключения, до 20 этапов и до 48 startup core-записей на попытку. Одинаковые соседние сообщения схлопываются, а handshake/endpoint/TUN/timeout/warn/error имеют приоритет над обычным packet/DNS-шумом; counters показывают полученные, схлопнутые и отброшенные строки. Вход одного callback ограничен 48 записями, общий журнал — 80, внутренний backlog libbox — 256. CommandLog кратко работает во время connect health-check; после подключения он остаётся только пока экран диагностики видим и Activity находится в STARTED. Runtime/core traffic log не попадает в Logcat и на диск. На диск атомарно записывается только один последний uncaught Kotlin/Java crash: timestamp, тип, redacted message и максимум 16 сокращённых stack frames в noBackupFilesDir; следующий crash заменяет предыдущий. На API 30+ дополнительно читается одна системная запись о прошлом завершении процесса (включая native crash/ANR), но большой system trace намеренно не копируется.
Diagnostic JSON создаётся только явной кнопкой, не содержит raw profile, package list, endpoint, внешний IP или credentials и включает app/core revision+patch SHA-256, Android/API/device ABI, non-VPN network/Private DNS, runtime resource counters, connection timeline, одну прошлую process-exit запись, последний app crash, log counters и структурную сводку effective zapret-* overlay. Временный файл перезаписывает предыдущий, передаётся системным Sharesheet через non-exported FileProvider с read grant и удаляется при следующем запуске.
Updater проверяет только Forgejo Releases Stable/Beta и только после явной кнопки. По Build.SUPPORTED_ABIS он выбирает один APK из release-metadata-v2.json (arm64-v8a, armeabi-v7a или x86_64), требует отдельный SHA-256, ограничивает HTTPS/redirect/размер и доверяет только точному хосту и пути репозитория. Затем он проверяет package, повышение versionCode, minSdk и signing history содержимого APK. Лишь после этого APK из cache/updates передаётся штатному Android installer через bounded non-exported FileProvider. Ошибка, отмена и следующий запуск удаляют временный APK. Core никогда не скачивается отдельно: libbox меняется только вместе с подписанным APK. Legacy release-metadata.json сохраняется для одного переходного arm64-обновления старых клиентов.
Локальный release publisher строит CLI/AAR/APK из одного полного commit и одного tracked patchset, проверяет embedded revision и patch SHA-256,
подписывает постоянным owner-only ключом и публикует три одно-ABI APK, checksums,
metadata и release notes в Forgejo. APK с чужим ABI, отсутствующим libbox или debug symbols отклоняется. Assets существующего tag не заменяются. Порядок создания и
восстановления офлайн-ключа зафиксирован в SIGNING.md.
Ссылки находятся только в «Настройки → Сообщество»:
План реализации
Каждый этап должен оставлять собираемое приложение и не начинать следующий до своего gate.
Этап 0 — воспроизводимая основа
- создать один Compose
appс minSdk 26; - настроить Material 3, fallback palette и четыре нижние вкладки;
- добавить CI-сборку pinned libbox/CLI и проверку revision;
- запускать
sing-box checkдля всех эталонных JSON; - зафиксировать
Libbox.SetMemoryLimit(false), releaseDebug=falseи bounded log на 256 строк.
Gate: debug APK собирается локально и в CI; revision ядра совпадает.
Этап 1 — хранение и минимальный импорт
ProfileStore, metadata index иAtomicFilebackup;- импорт raw JSON из файла/буфера;
- список профилей и raw editor;
CheckConfig()без запуска VPN.
Gate: JSON round-trip сохраняет неизвестные extended-поля; backup восстанавливается.
Этап 2 — вертикальный VPN slice
- global per-app picker/include mode;
- foreground
VpnServiceи один lifecycle owner; - один Android process, без второго VPN/daemon service;
- platform adapter, protect callback, один TUN;
- подключение одного валидного JSON-профиля;
- постоянное уведомление и полный stop/revoke cleanup.
Gate: выбранное приложение проходит через TUN, контрольное невыбранное не появляется в TUN.
Этап 3 — DNS и отказоустойчивость
- underlying network monitor;
- Android bootstrap resolver и LKG;
- четыре DNS-режима;
- port-53 hijack, fallback/sequential над UDP/TCP DNS узла;
- captive portal, strict Private DNS preflight;
- health-check state machine.
Gate: Android device matrix из DNS ADR; мёртвый DNS никогда не оставляет активный TUN.
Этап 4 — маршрутизация
- экран с карточками «Область VPN» и «Правило трафика»;
- presets и читаемый effective summary;
- packaged
.srs+ manifest/hash; - действия proxy/direct/reject;
- DNS + route block;
- advanced include/exclude mode и JSON rules.
Gate: Routing ADR matrix, RU/non-RU IPv4/IPv6, selected/unselected apps и block fixture.
Этап 5 — полный импорт и подписки
- QR scanner, URL и connection URI parsers;
- subscription groups и ручное обновление;
- понятный preview результата без автоподключения;
- популярные package suggestions без TikTok;
- предупреждение о
urltest/NTP/remote rule-set/external Clash/verbose log/keepalive в импортированном JSON.
Реализация хранит refresh URL отдельно в noBackupFilesDir/subscriptions/index.json:
profiles/index.json остаётся UI-only и не получает token/credentials. Parser создаёт
настоящий sing-box JSON сразу; transient import candidate не становится вторым
источником routing. QR использует отдельную неэкспортируемую Activity и запрашивает
CAMERA только после явного действия. Ни updater, ни subscription refresh не имеют
фонового scheduler.
Точный core содержит Clash subscription parser, но текущий libbox AAR его не экспортирует. Kotlin YAML parser намеренно не добавляется: подробная семантика, кандидат Hysteria v1 и критерии возврата описаны в политике форматов импорта.
Gate: malformed input не меняет существующие профили; секреты не попадают в UI/log.
Этап 6 — продуктовый минимум
- главная карточка, IP, ping, session timer и график;
- lifecycle-gated
CommandStatus1 Гц; безCommandConnectionsи live-speed notification; - диагностика/redacted share;
- тема, сообщество и About.
Gate: полный пользовательский путь «установил → импортировал → выбрал приложения → подключился → отправил диагностику».
Этап 7 — updater и Forgejo Release
- ручной Stable/Beta check без фоновой синхронизации;
- checksum/package/version/signing-history validation до installer;
- подписанные exact-core
arm64-v8a,armeabi-v7a,x86_64APK, metadata, SHA-256 и notes; - same-key upgrade с сохранением app-private данных.
Updater находится в отдельном app-updater library-модуле. Прямая retryable ошибка
проверки или загрузки допускает ровно один повтор: app временно перезапускает/поднимает
текущий VPN с runtime-only правилом для package Zapret KVN и хоста Forgejo. После запроса
предыдущее состояние VPN восстанавливается даже при отмене; stored JSON и трафик других
приложений не меняются. Отдельного VPN service, фонового worker или постоянного правила нет.
Gate: битый/прерванный/чужой/downgrade APK не запускает installer и не оставляет cache.
Этап 8 — выпускная матрица
- выполнить весь список «Потом проверить» ниже на реальных устройствах;
- сохранить результаты, версии ОС, модели устройств и сырые замеры как release artifact;
- перенести в канонические решения только выводы, прошедшие критерий соответствующего пункта.
Gate: APK не выпускается при failed fixture, instrumented test, ABI/revision mismatch или провале обязательного release-gate энергии.
Текущая проверка
Локально реализованы Этапы 0–7:
- 7/7 JSON приняты CLI, собранным из точного commit;
- 7/7 приняты compatibility release CLI;
- Android WireGuard ClientBind fixture,
SHA-256
822dcc9c6a138418b8adc51be44260ac3ff0dceb10187506e1506a5c710974c7, фиксирует Android WireGuard endpoint, health-route и раздельные TUN/endpoint MTU; go test ./dns/... ./route/rule ./experimental/libboxпроходит; наш воспроизводимый audit test дополнительно проверяет exact pinned fallback success/error/hang/RCODE внутри исходного Go package;- Gradle-проект с направленными library-модулями собирает одно-ABI debug и R8 release-матрицу; каждый APK содержит ровно один ABI, один process и один
VpnService; - 165/165 JVM unit-тестов проходят, включая DNS/routing/import/updater/signing, Always-on policy и полную блокировку приложений;
- полный текущий набор 67/67 Android instrumented-тестов проходит на AVD API 36; API 26 прошёл предыдущую матрицу 66/66 и security delta 3/3, API 29 — 66/66; API 29/36 дополнительно прошли 100 connect/stop и 50 Wi-Fi/cellular transitions;
- API 36 AVD прошёл 16 performance-сценариев по пять повторов: невыбранные 8 MiB дали median TUN=0, System Trace/batterystats/raw metrics сохранены, а
mixed, MTU 9000 и GCPercent 100 оставлены без изменений по порогу 5%; физическая энергия не считается доказанной; - same-key upgrade probe на API 26/36 сохраняет настоящий профиль, active id, DataStore и allowlist между versionCode 701001→701002; Android отклоняет downgrade и APK с другим ключом без потери данных;
- подписанный R8 release bundle с временным тестовым ключом локально прошёл
apksigner, package/version/core metadata и SHA-256 consistency; постоянный production key и внешний Forgejo Release остаются действиями владельца; - отдельный debug-only adb probe на API 26/36 подтверждает hard process contract: после смерти процесса Android снимает service/TUN/core и новый UI показывает
Stopped, а следующий connect создаёт ровно один экземпляр; receiver отсутствует в release manifest; - exact CLI проверяет packaged RU domain/IP
.srsна RU/non-RU domain, IPv4 и IPv6; manifest закрепляет источник, commit/license и SHA-256, а installer атомарно восстанавливает повреждённый asset до запуска VPN; - exact core benchmark: загрузка
.srs1 114 мкс/758 624 allocation bytes, lookup 329 нс/op, 1 104 B/op и 2 allocs/op; полный Android debug-прогон дал cold connect 41/63 мс и 2,5/7,5 мс CPU на flow на API 36/26 без продолжающегося роста PSS; - в каждом lifecycle-цикле внутренние счётчики PFD/TUN, adapter, callback и libbox instance возвращаются в ноль; process FD/thread trend не показывает продолжающегося роста;
- Markdown, локальные ссылки, fixture hashes, lint и полный локальный аналог CI проверены;
- P12–P14, физическая/энергетическая часть P15 и обязательный release-gate энергии на устройстве ещё не выполнены.
Эти результаты закрывают локальные автоматизированные Gate 2/4/6/7, но не закрывают физический Gate 3 и выпускную OEM/energy matrix: ещё нужны настоящий captive portal, IPv6-only/NAT64, blocked-DNS/LKG/DNS узла и повтор per-app/routing на физических сетях/устройствах. Они также не заменяют внешний Forgejo Actions run, постоянный signing key и фактически опубликованный Release. Приложение нельзя считать готовым к выпуску до этапа 8.
Потом проверить — открытые вопросы
Это единственный список недоказанного. Пункты ниже не являются принятыми решениями и не разрешают ИИ заранее добавлять переключатели, новые слои, зависимости или fork ядра. До получения измерений сохраняются канонические defaults выше. После теста результат переносится в соответствующую ADR, а пункт отмечается закрытым с датой, устройствами и ссылкой на лог.
| ID | Что пока не доказано | Как проверить | Что делать до результата |
|---|---|---|---|
| P1 | Реальная per-app изоляция на всех целевых API/OEM | API 26, 28, 29 и актуальный Android: выбранное приложение видно в TUN, невыбранное не видно; отдельно shared UID, удаление package во время запуска, Always-on/Lockdown | Использовать include allowlist; при пустом/ошибочном списке не запускаться |
| P2 | Какой userspace stack быстрее именно на наших устройствах | Одинаковые TCP/UDP/QUIC сценарии для mixed и system: throughput, CPU, RAM, connect time, потери и стабильность на Wi‑Fi/mobile |
Оставить поле stack пустым, то есть upstream mixed; не показывать выбор в обычном GUI |
| P3 | Отсутствие OEM/операторских проблем у default 1500, а для userspace WireGuard — min(1500, endpoint MTU) |
Проверить IPv4, IPv6, NAT64, QUIC, крупные загрузки, PMTU/fragmentation и смену Wi‑Fi/mobile; отдельно A/B WireGuard 1280 ↔ core 9000 | Оставить вычисляемый runtime MTU с явным откатом «По профилю»; менять только по воспроизводимой регрессии |
| P4 | Цена безусловного Android owner/process lookup в pinned core | Профилирование connect latency, CPU и throughput с текущим core; отдельная экспериментальная сборка без lookup допустима только для сравнения | Не форкать ядро. Patch рассматривается лишь при повторяемом существенном выигрыше и полном повторе матрицы |
| P5 | Реальное поведение DNS на Android/OEM | Private DNS off/automatic/strict working/broken, captive portal, заблокированный system DNS, DNS узла через proxy, port-53 hijack, DNS/HTTPS health-check | Следовать fail-close DNS ADR; системные настройки не менять и strict Private DNS скрыто не обходить |
| P6 | Сетевой lifecycle без гонок | Wi‑Fi ↔ mobile, потеря underlying network, быстрые callback bursts, reconnect 8–10 секунд, LKG fresh/stale, revoke, kill process и повторный старт | Один generation token, один service-lock и идемпотентный stop |
| P7 | Production RU/domain/block rule-set | Выбрать источники и лицензии, закрепить commit/hash, собрать настоящие .srs, проверить RU/non-RU IPv4/IPv6, память, cold start и повреждённый asset |
Эталонные fixtures считать только schema/graph тестами, не production-базой |
| P8 | Практическая граница domain block | Стандартный DNS, кэшированный ответ, direct IP и приложения со встроенным DoH; TCP/UDP/ICMP reject | Не обещать firewall. Для гарантии IP-блокировки требовать IP/CIDR set; глобальный sniff не включать |
| P9 | Android/libbox lifecycle и ABI release build | Собрать AAR/APK из exact commit для выбранных ABI; проверить PFD ownership, protect(fd), cancel/revoke, process death, отсутствие утечек fd/threads и совпадение embedded revision |
Не выпускать APK только на основании CLI/unit-тестов |
| P10 | Достаточность диагностики и redaction | Экспорт конфигов всех поддержанных типов; проверить удаление UUID/password/token/URL credentials и возможность воспроизвести ошибку по очищенному файлу | Не логировать исходные секреты; подробный экспорт только по действию пользователя |
| P12 | Idle CPU и энергия ещё не измерены | Сценарии 1–8 из release-gate на слабом API 26 и современном устройстве; пять повторов, одинаковые условия | Никаких polling/wakelock/jobs; один process; managed background полностью event-driven |
| P13 | Цена status/traffic tracking и bounded logs | Сравнить главную видимую/закрытую, status 1 Гц/выключен, diagnostics stream закрыт/открыт; измерить CPU, allocations и GC | Только totals на главной; никогда не подписываться на connections; log stream только в диагностике |
| P14 | GC=100 против GC=10 и риск памяти | Сравнить CPU, GC count/pause и PSS/RSS под длительной TCP/UDP/QUIC нагрузкой | SetMemoryLimit(false); менять только при доказанном OOM и выигрыше без роста энергии |
| P15 | Цена надёжного DNS fallback на физических устройствах | Cache burst и уникальные имена; сравнить принятый parallel с sequential по энергии и запросам, не возвращая заведомо сломанный hang-path |
Managed default parallel; кэш 4096; без периодических проверок и plaintext fallback |
| P16 | Стабильность нового Android WireGuard/AWG data-plane | Test 23 доказал старую неисправность после handshake; проверить новый split engine во всех DNS-режимах, затем длительную сессию, mobile/network switch и IPv4/IPv6-capable profiles | Один Android TUN и pinned patch; до физического теста исправление не объявлять подтверждённым |
Критерий для P2–P4 и P12–P15: одного удачного speed test недостаточно. Нужны минимум пять повторов на слабом API 26 устройстве и одном современном устройстве без регрессии DNS, IPv6, QUIC и стабильности. Разница менее 5% не оправдывает усложнение архитектуры.
Источники мастер-плана
- Android: per-app VPN contract
- Android: Power Profiler/ODPM
- AndroidX Macrobenchmark: PowerMetric
- Android PackageManager: APK/signing contract
- Android SigningInfo: rotation and multiple signers
- Android installer intent contract
- Forgejo API
- Pinned core: Android memory/GC policy
- Pinned core: status/log command server
- Pinned core: internal Clash API creation from PlatformLogWriter
- Pinned core: traffic manager