ZapretKVN-android/docs/DNS_ARCHITECTURE.md
loop-uh d1311dabdb
All checks were successful
Android CI / build (push) Successful in 8m57s
fix: resolve tunnel names on the node instead of managed DoH
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>
2026-09-06 17:27:43 +03:00

92 KiB
Raw Permalink Blame History

ADR-002: DNS и изоляция приложений в Zapret KVN

Поле Значение
Статус Принято для реализации; выпуск только после test matrix
Дата решения 21 июля 2026 года
Последний повторный аудит 22 июля 2026 года
Платформа Android 8.0+ (API 26+)
Проверенный исходник tag v1.13.14-extended-2.5.2, commit ff11f007ec798136a5de258f947a4f34011a37ea
Android reference gitlink SagerNet/sing-box-for-android@eb87216961321de1802e1355c470242f2ed5faa8 (только поведенческий reference, не ABI-контракт)
Область DNS, Android VpnService, per-app VPN, отказоустойчивость

Этот документ является обязательной архитектурной спецификацией MVP. Слова «должен», «нельзя» и «только» обозначают требования, а не рекомендации. При обновлении базовой версии sing-box документ и эталонный JSON должны пройти повторную проверку.

Граница между глобальным списком приложений и domain/IP/rule-set политикой зафиксирована отдельно в ROUTING_ARCHITECTURE.md. DNS следует выбранному сетевому outbound, но не решает, какие приложения входят в TUN.

Границы достоверности

Документ разделяет три вида утверждений:

  • контракт ядра — подтверждён зафиксированным исходным commit sing-box-extended;
  • контракт Android — подтверждён публичным Android API и AOSP;
  • политика Zapret KVN — обязательное решение нашего приложения поверх этих контрактов.

Исходный код подтверждает структуру и ожидаемое поведение, но не заменяет instrumented-тесты на устройствах. Per-app изоляция, Private DNS, captive portal, NAT64 и смена сети считаются готовыми к выпуску только после прохождения матрицы в конце документа.

Результат аудита исходника

Проверено Подтверждённое поведение Следствие для Zapret KVN
experimental/libbox/tun.go DNS-адрес — следующий IPv4 после первого TUN-адреса; /32 отклоняется брать только GetDNSServerAddress(), не хардкодить 172.19.0.2
experimental/libbox/dns.go platform transport принимает raw DNS, а при Raw=false — только A/AAAA API 29+ использовать DnsResolver.rawQuery, API 2628 — Network.getAllByName()
common/dialer/default.go, route/network.go при route.auto_detect_interface=true platform control вызывается для исходящих сокетов и возвращает ошибку в dialer требовать этот флаг; не игнорировать protect(fd) == false, а выбрасывать ошибку в libbox
dns/transport/udp.go, dns/transport/tcp.go literal server задаёт адрес соединения, detour использует указанный outbound обычный DNS на 53 действительно уходит внутрь туннеля и попадает под перенаправление узла
dns/transport/fallback/strategy.go sequential переходит дальше только при transport/Go-ошибке и использует тот же context; зависший первый сервер может исчерпать весь deadline. parallel запускает все перечисленные транспорты в общем bounded context разные источники DNS нельзя надёжно объединить этим transport: Auto выполняет конечные чистые app-level попытки, а sequential внутри managed-этапа перебирает только два транспорта до одного и того же resolver'а узла; системный DNS разрешён только последним кандидатом Auto с предупреждением
dns/client.go общий DNS timeout — 10 секунд; кэш при independent_cache=false не включает tag транспорта DNS-выбор одного домена должен быть детерминированным, а при смене режима/сети кэш сбрасывается
dns/router.go ResetNetwork() очищает DNS response cache и сбрасывает транспорты, но не очищает dnsReverseMapping для полного сброса DNS-состояния в MVP нужен перезапуск core
dns/transport/hosts/hosts.go и dialer hosts.predefined работает как resolver для outbound.domain_resolver, не меняя hostname назначения last-known-good overlay сохраняет TLS/Reality SNI и HTTP Host
route/route.go, migration docs protocol: dns требует предварительного sniff; port: 53 совпадает без sniff в быстром MVP использовать port: 53 → hijack-dns
daemon/instance.go OverrideOptions дописывает package-list из JSON и изменяет только первый TUN до запуска очищать оба package-list в runtime-копии и разрешать ровно один TUN
route/router.go, route/route.go в Android-сборке с platform interface поиск owner/process включается безусловно и вызывается до сопоставления route-правил удаление package_name-правил убирает дублирующую политику, но не отключает сам lookup в этом commit
common/dialer/default.go protect(fd) не добавляется, если задан bind_interface либо inet4/inet6_bind_address; route.default_interface также выбирает другую ветку platform bind/mark/netns-поля в Android MVP отклонять до establish()
protocol/tun/inbound.go, experimental/libbox/service.go Android OpenTun() вызывается на стадии старта inbound, а route ranges строятся из TUN options до запуска проверять полный IPv4/IPv6 capture и достижимость внутреннего DNS; после любого позднего сбоя закрывать platform PFD
Android reference VPNService.kt per-app список применяется в VpnService.Builder, пакет VPN-приложения добавляется в include-режиме глобальная allowlist является platform policy, а не доверием импортированному JSON
Android reference DefaultNetworkMonitor.kt текущий retry-цикл может отправить одно успешное обновление интерфейса до десяти раз не копировать цикл буквально; дедуплицировать и сериализовать network events

Pinned Android gitlink фиксирует полезный пример поведения, но не доказывает совместимость с ABI нашего libbox. В проверенном core API менялся после commit Android reference, а upstream Android workflow для stable/testing дополнительно переключает submodule на плавающие main/dev. Поэтому Zapret KVN компилирует собственный platform adapter против AAR, собранного в том же CI из pinned core commit; reference-код не копируется как готовый бинарно-совместимый слой.

В ходе аудита также обнаружено, что upstream release asset с именем 1.13.14-extended-2.5.2 содержит revision ea48501b1d078cbf55437a8d37987b2feb842796, а не commit тега ff11f00…. На дату аудита между ними в DNS/libbox/route-коде различий нет — изменён только README.md — поэтому проверенные JSON совместимы с обоими. Однако upstream workflow принудительно создаёт локальный tag и умеет заменять assets существующего release. Следовательно, имя release не является доказательством происхождения бинарника: Zapret KVN собирает libbox и проверочный CLI самостоятельно только из полного pinned commit.

Решение

  • FakeIP по умолчанию выключен.
  • До запуска VPN адрес сервера разрешает системный resolver Android на физической сети.
  • После запуска DNS по TCP/UDP 53 выбранных приложений принимает DNS-модуль sing-box внутри TUN.
  • В режиме «Автоматически» сначала без изменений пробуется выбранный DNS профиля, если он существует; после подтверждённой DNS-ошибки выполняется чистая попытка с DNS узла через выбранный proxy outbound, затем последний резерв — DNS Android с предупреждением.
  • Строгий Private DNS Android не отключается и не перехватывается: для управляемых режимов «Автоматически»/«Защищённый» это блокирующая несовместимость, а не повод тайно ослабить DNS.
  • Невыбранные приложения вообще не попадают в TUN и продолжают использовать обычную сеть и системный DNS.
  • Явные режимы «Из JSON», «DNS Android» и «Защищённый через VPN» не получают скрытого fallback. Отказ всех DNS-кандидатов Auto закрывает VPN.
  • Управляемый Zapret/sing-box DNS-кэш существует только в памяти сессии. Android resolver может иметь собственный системный кэш вне контроля приложения. Отдельно сохраняется маленький last-known-good кэш адресов VPN-серверов.

Это даёт наиболее предсказуемое поведение для per-app VPN и не создаёт постоянной второй системы маршрутизации.

Два уровня, один TUN

Физически создаётся ровно один Android TUN. libbox получает его file descriptor и поднимает поверх него userspace stack sing-box; это не второй TUN и не второй VPN-адаптер.

Уровень Отвечает за Не должен решать
Android VpnService.Builder allowlist UID, адреса TUN, полные IPv4/IPv6 routes, внутренний DNS, PFD домены, direct/proxy, правила России/LAN
sing-box за TUN DNS hijack, доменные/IP-правила, direct, proxy и выбранный outbound какие Android-приложения система допускает в TUN

Каждый критерий имеет ровно одного владельца. Переданный через TunOptions package-list применяется platform-кодом к VpnService.Builder; сгенерированные режимы sing-box не повторяют это решение через package_name. Получив пакет из PFD, ядро считает приложение уже допущенным и выполняет только сетевую политику. Пользовательские process/package-правила допустимы только как явная advanced-функция JSON, а не как скрытая часть preset.

Отсюда следуют два разных значения «напрямую»:

  • невыбранное приложение не входит в TUN вообще и работает через Android как без VPN;
  • выбранное приложение всегда входит в TUN, но правило sing-box может отправить его соединение через защищённый direct outbound, не нагружая VPN-сервер.

Границу нельзя размывать: package-list не превращаем в route-правила sing-box, а «Обход LAN»/GEO-режимы не реализуем исключением адресов из Android TUN routes. Поля TUN в runtime JSON служат входом для VpnService.Builder; прикладная маршрутизация остаётся только в route.rules sing-box.

Где отсекаем трафик ради скорости

Порядок fast path для MVP:

  1. До TUN — только по приложению. Android allowlist отсекает основной объём: невыбранные UID не проходят через PFD, userspace stack, DNS и route engine вообще.
  2. В sing-box — один короткий route-pass. Сначала DNS/health, затем локальные и точные IP-правила, после них доменные/rule-set правила и final. Package/process rules в сгенерированных режимах не используются.
  3. До VPN-сервера — по outbound. direct всё ещё проходит через локальный TUN/core, но сразу выходит в underlying network через защищённый socket и не создаёт нагрузку на VPN-сервер.

Android routes умеют отсекать только IP-префиксы, а не домены. Теоретический fast-LAN через excludeRoute() сэкономил бы немного локального CPU, но потребовал бы вырезать из исключений внутренние TUN/DNS-подсети, учитывать API до 33, IPv6 и пересоздавать TUN при смене LAN. Для MVP это хуже по сложности и риску, чем один короткий проход через sing-box. Такую оптимизацию допускаем только позже, отдельно от DNS ADR и только после профилирования на слабом API 26 устройстве.

Важное ограничение exact core: pinned commit при наличии Android platform interface безусловно вызывает owner/process lookup для каждого локального TCP/UDP flow ещё до route rules. Штатного libbox-переключателя нет. Это не вторая allowlist-политика, но лишняя цена для наших preset-ов. В MVP не форкаем ядро ради этой микрооптимизации: инвариант точного commit и простое обновление важнее. Сначала измеряем lookup на API 26 и современном Android. Если он материален, будущий patch делает lookup условным; тогда бинарник уже маркируется как base commit + patch hash и проходит всю матрицу заново.

Главный инвариант: прямые приложения не затрагиваются

По умолчанию используется Android allowlist:

  1. Глобальный пользовательский список берём из DataStore. Поля include_package/exclude_package импортированного профиля сохраняем в JSON, но не считаем разрешением изменить platform allowlist.
  2. Перед establish() проверяем выбранные пользователем пакеты через PackageManager.
  3. Если после проверки пользовательский список пуст, VPN не создаём. Отсутствие allowlist у Android означает «все приложения через VPN».
  4. В MVP разрешён ровно один TUN inbound. Ноль TUN, несколько TUN либо auto_route: false отклоняются до запуска с предложением явного исправления профиля.
  5. В runtime-копии единственного TUN очищаем и include_package, и exclude_package. Затем передаём ровно один итоговый список через libbox OverrideOptions; это важно, потому что проверенное ядро дописывает override к JSON, а не заменяет его.
  6. После проверки пользовательского списка внутренне добавляем пакет Zapret KVN. Это позволяет выполнять DNS/HTTP health-check через VPN; остальные служебные запросы явно привязываются к VPN либо underlying network согласно операции.
  7. OpenTun(TunOptions) применяет итоговый include-list только через addAllowedApplication(). Если пакет исчез между preflight и вызовом Builder, запуск отменяется, а не продолжается с неполным списком.
  8. Маршруты IPv4/IPv6 и внутренний DNS применяются только к UID итогового allowlist.
  9. Все остальные приложения используют сеть так, будто VPN не запущен.

Каноническая точка применения в Android-адаптере выглядит так:

check(userPackages.isNotEmpty())
val allowedPackages = (userPackages + packageName).distinct()
allowedPackages.forEach(builder::addAllowedApplication)
// Затем на тот же Builder добавляются TUN addresses, full routes и internal DNS.
val tunPfd = checkNotNull(builder.establish())

NameNotFoundException, любая Builder-ошибка или null из establish() отменяют весь запуск; нельзя продолжать с урезанным списком. Это единственное место, где исполняется глобальная package allowlist.

Runtime-подмена списка пакетов — узкое исключение из правила «JSON является источником истины»: она не создаёт второй движок маршрутизации, не сохраняется в профиль и существует только для применения глобальной Android-настройки. Эффективный redacted runtime JSON доступен в диагностике.

Режим исключений может использовать addDisallowedApplication(), но два вида списков никогда не смешиваются. Это явный advanced-режим «все, кроме перечисленных, через VPN»: он предупреждает о большей нагрузке, не запускается с пустым списком исключений и не обещает, что остальные приложения идут напрямую. Режим по умолчанию и главный инвариант этой ADR — включения.

Дополнительные ограничения:

  • пакет Zapret KVN добавляется внутренне и не отображается как пользовательский выбор; системные приложения автоматически не добавляются;
  • изменение списка приложений требует пересоздания TUN;
  • allowBypass() не вызывается: невыбранным приложениям он не нужен, а выбранным позволил бы самостоятельно обходить VPN;
  • в управляемых режимах IPv4 и IPv6 выбранных приложений полностью направляются в TUN (0.0.0.0/0 и ::/0), чтобы частичные routes и IPv6 не стали обходным каналом;
  • route_address, route_exclude_address и их rule-set/deprecated-варианты не используются в управляемом Android TUN. В частности, «Обход LAN» реализуется route-правилами sing-box с outbound direct, а не исключением RFC1918 из Android routes: иначе диапазон 172.16.0.0/12 вырежет и внутренний DNS 172.19.0.2;
  • перед establish() отдельно проверяем, что адрес из GetDNSServerAddress() входит в фактические Builder routes и не исключён;
  • в любом TUN-профиле route.auto_detect_interface обязан быть true: именно этот флаг подключает platform protect callback к default dialer. В управляемых режимах GUI записывает его в JSON; режим «Из JSON» с выключенным флагом не запускается и показывает исправление;
  • каждый исходящий сокет libbox должен успешно пройти через VpnService.protect(fd); если Android вернул false, platform callback выбрасывает ошибку в libbox. Игнорировать boolean, как это делает текущий reference-клиент SFA, нельзя: возможна петля обратно в TUN;
  • для гарантии protect(fd) Android MVP отклоняет route.default_interface, route.default_mark и platform-specific dialer-поля bind_interface, inet4_bind_address, inet6_bind_address, bind_address_no_port, routing_mark, netns, protect_path. Они остаются видимыми в исходном JSON, но требуют будущего отдельного аудита и не исправляются скрыто;
  • гарантия распространяется только на outbounds/endpoints, которые используют проверенный common dialer либо имеют собственную проверенную platform-защиту. Конфигурация с неаудированным raw socket backend в MVP не запускается в TUN-режиме и получает диагностическую ошибку;
  • physical/underlying Network отслеживается отдельно от VPN-сети;
  • если исходящие сокеты явно привязываются к конкретному Network, тот же список передаётся через Builder.setUnderlyingNetworks()/VpnService.setUnderlyingNetworks() и обновляется при смене сети. Без явной привязки оставляем системное значение null.

Always-on/Lockdown не поддерживаем в MVP и объявляем в manifest:

<meta-data
    android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
    android:value="false" />

На API 29+ сервис читает VpnService.isAlwaysOn() и isLockdownEnabled(). Если OEM/старая настройка всё же активировала этот режим, запуск блокируется до TUN, а UI и diagnostic JSON объясняют, где его отключить. На API 2628 Android не даёт приложению публичный status API, поэтому manifest opt-out остаётся главной гарантией.

Lockdown Android способен блокировать трафик приложений вне allowlist, поэтому он несовместим с обещанием «остальные приложения всегда напрямую». На Android 8.0, где системный opt-out ограничен, это отдельно проверяем и объясняем пользователю.

Граница Android API: per-app VPN применяется к текущему Android user/work profile. Пакеты с общим UID и ограничения, навязанные Device Owner/Lockdown, нельзя разъединить на уровне приложения; диагностика не должна обещать для них изоляцию сильнее системного контракта.

Физическая сеть Android

Приложение хранит ссылку именно на текущую не-VPN сеть: WiFi, cellular или Ethernet.

  • До TUN можно получить текущий activeNetwork.
  • Во время работы нужен NetworkCallback, не принимающий VPN-интерфейс за underlying network. API 31+ использует best-matching callback. На API 2630 обычный пассивный callback наблюдает все подходящие NOT_VPN сети, а приложение выбирает лучшую из актуальных: validated, не captive, затем Ethernet/Wi-Fi/cellular. requestNetwork здесь не используется: проверка на API 26/28 показала, что он может удерживать первую подходящую сеть и не сообщать нужный Wi-Fi ↔ cellular switch.
  • Там, где используется NetworkRequest, он явно требует NET_CAPABILITY_NOT_VPN; callback живёт в scope VpnService, всегда снимается при stop и не использует GlobalScope.
  • События сериализуются и дедуплицируются по компактному policy key: identity Network/интерфейса, captive flag, strict mode, strict hostname и strict readiness (active && validated). Поэтому смена или поломка strict DNS уже во время сессии вызывает один контролируемый restart/fail-close даже при прежнем интерфейсе. Короткие повторы callback объединяются; устаревший bootstrap/restart отменяется generation token.
  • Для наблюдения объявляется обычное, не runtime-разрешение ACCESS_NETWORK_STATE; для legacy-чтения имени текущей сети на API 2628 — ACCESS_WIFI_STATE; CHANGE_NETWORK_STATE приложению не требуется.
  • Опциональная автоматика по типу transport не требует дополнительных разрешений. Только точное сравнение доверенного SSID запрашивает ACCESS_FINE_LOCATION по действию пользователя и callback с FLAG_INCLUDE_LOCATION_INFO на API 31+. При redaction, отказе разрешения или выключенной геолокации SSID считается неизвестным и VPN не отключается; имя сети не экспортируется в diagnostic JSON.
  • При смене policy key обновляем resolver и, если используется явная socket binding, setUnderlyingNetworks(), после чего выполняем контролируемый перезапуск core.
  • Наличие NET_CAPABILITY_CAPTIVE_PORTAL блокирует автоматическое подключение и открывает системный вход в WiFi.
  • Отсутствие NET_CAPABILITY_VALIDATED показываем в диагностике и обычно само по себе не считаем отказом: системная проверочная точка может быть заблокирована при рабочем интернете. Исключение — режим «DNS Android» при настроенном strict Private DNS: сочетание strict с неактивным Private DNS или невалидированной сетью блокирует запуск, чтобы Android 9 не ушёл на обычный DNS.

Системный captive portal и остальные невыбранные системные приложения остаются вне TUN.

Этап 1: bootstrap до VPN

Адрес активного VPN-сервера разрешается через resolver Android, привязанный к underlying Network:

  • Android 10+ (API 29+): DnsResolver с обычным объектом Network, CancellationSignal и FLAG_EMPTY;
  • Android 89: Network.getAllByName().

Этой границей владеет отдельный модуль network-bootstrap. Он публикует сеть только после получения и NetworkCapabilities, и LinkProperties, ждёт короткое стабильное окно перед bootstrap и один раз повторяет операцию только при доказанной смене identity либо bootstrap-значимых полей той же сети: validation/captive, Private DNS или списка DNS-серверов. Обычный DNS timeout/error при неизменной сети скрыто не повторяется. Coroutine cancellation всегда пробрасывается наружу.

Ошибки bootstrap типизированы стабильными кодами: NET-101/102 для отсутствующей/меняющейся сети и DNS-101…106 для timeout, NXDOMAIN, REFUSED, пустого ответа, системной ошибки и прочего RCODE. На главной показываются код и понятное действие, а diagnostic JSON получает redacted rcode/errno/timeout без hostname сервера.

NET-101, NET-102, DNS-101 и DNS-105 не останавливают сервис: диагностика фиксирует провалившуюся попытку, а на главной вместо ошибки появляется «Переподключение» с кодом причины и номером попытки. Остальные коды, включая captive portal NET-110, остаются терминальными — они требуют пользователя, а не повтора.

Так приложение использует настройки сети Android и Private DNS на Android 9+. Нельзя получать hidden getPrivateDnsBypassingCopy() или отправлять собственный plaintext bootstrap-запрос: настройки Private DNS никогда не изменяются и не обходятся.

В platform LocalDNSTransport применяем ту же underlying network. На API 29+ Raw() возвращает true, а Exchange() передаёт полный пакет через DnsResolver.rawQuery(). На API 2628 Raw() возвращает false, и libbox вызывает Lookup() только для A/AAAA через Network.getAllByName().

В LinkProperties диагностируем:

  • Private DNS выключен;
  • opportunistic/automatic;
  • strict с указанным hostname;
  • underlying network отсутствует или не валидирована.

Если strict Private DNS не отвечает, запрещён скрытый запрос к публичному plaintext DNS. Managed Auto/Secure и DNS Android завершаются понятной ошибкой до TUN. В режиме «Из JSON» LKG ещё может помочь только bootstrap поддержанного аутентифицируемого outbound, но ответственность за пользовательскую DNS-схему остаётся у JSON.

route.default_domain_resolver указывает на локальный platform resolver:

{
  "route": {
    "default_domain_resolver": "zapret-android-dns"
  }
}

Это разрешает hostname proxy outbound до того, как DNS узла через этот proxy станет доступен.

Private DNS Android внутри VPN

Private DNS нельзя считать только свойством bootstrap-сети. AOSP поддерживает strict Private DNS и на VPN network: системный resolver может отправлять DoT на порт 853 вместо запроса к адресу из addDnsServer(). Такой трафик не совпадает с port: 53 → hijack-dns, а ответы не попадают в dnsReverseMapping; поэтому доменная маршрутизация sing-box может потерять имя назначения.

Политика MVP:

  • на API 28+ strict-режим определяем по непустому LinkProperties.privateDnsServerName, даже если isPrivateDnsActive временно false из-за неудачной валидации;
  • если выбран «Автоматически», strict Private DNS не блокирует запуск: цепочка кандидатов сужается до «DNS Android» (единственный кандидат, уважающий системный DoT) с явным предупреждением в логе; кандидаты «DNS профиля» и managed DNS узла при strict пропускаются, потому что подменяли бы выбранный пользователем резолвер. Для «DNS узла через VPN» strict остаётся блокирующей preflight-ошибкой (DNS-110) до establish(): этот режим явно обещает собственный резолвер и не может одновременно уважать strict. Приложение системную настройку само не меняет;
  • в режиме «DNS Android» работающий strict Private DNS разрешён и остаётся системным источником истины. До TUN требуются isPrivateDnsActive=true и NET_CAPABILITY_VALIDATED; иначе подключение блокируется без plaintext fallback. На Android 9 при несуществующем strict hostname active может остаться true, но сеть теряет VALIDATED, поэтому проверять только один флаг нельзя. GUI предупреждает, что доменные правила, которым требуется reverse_mapping, могут быть неполными;
  • в режиме «Из JSON» существующую DNS-секцию не переписываем, а при её отсутствии добавляем только в runtime минимальный local DNS Android; показываем обнаруженный strict Private DNS и оставляем ответственность за явно заданную схему пользователю;
  • opportunistic Private DNS разрешён: Android может проверить DoT, а для внутреннего адреса без DoT вернуться к обычному DNS. Это поведение обязательно проверяется на API 28, 29 и современной версии Android;
  • DoT/853 не подменяем, не расшифровываем и не блокируем ради принудительного downgrade: в strict-режиме блокировка просто уничтожила бы DNS.

Так мы действительно уважаем настройку Android: не заявляем, что addDnsServer() способен отменить strict Private DNS, и не выдаём обходящий нашу DNS-схему DoT за успешный managed-режим.

Этап 2: DNS внутри VPN

После разбора TUN-настроек libbox возвращает адрес внутреннего DNS через GetDNSServerAddress(). Именно его передаём в VpnService.Builder.addDnsServer().

Для стандартной сети 172.19.0.1/30 результатом будет 172.19.0.2, но адрес не хардкодим. Текущий libbox выбирает следующий адрес после первого IPv4 TUN-адреса и отклоняет /32, где нет места для DNS.

В управляемых режимах единственный TUN обязан иметь IPv4-префикс минимум /30, IPv6-префикс и auto_route: true. Используются полные IPv4/IPv6 routes без route-exclude; IPv6-адрес и маршруты добавляются отдельно, но внутренний DNS-адрес в проверенном libbox всё равно вычисляется из первого IPv4-префикса. strict_route на Android reference не реализован, поэтому на него не опираемся.

TUN: libbox TunOptions
  ├─ addresses/routes → VpnService.Builder
  └─ dnsServerAddress → addDnsServer(...)

Если не вызвать addDnsServer(), Android использует DNS default network. Для выбранных приложений это нарушит нашу DNS-политику.

Первым route-правилом добавляется:

{
  "port": 53,
  "action": "hijack-dns"
}

Оно направляет стандартный DNS по TCP/UDP 53 выбранных приложений в DNS-модуль sing-box. Вариант protocol: "dns" без стоящего перед ним action: "sniff" не работает: значение protocol появляется только после sniff. Глобальный sniff всех соединений ради DNS нам не нужен.

Режим «Автоматически»

Auto — это ограниченный выбор готовой DNS-политики, а не смешивание нескольких resolver-ов внутри одной сессии:

  1. Если профиль содержит непустой dns.servers, а dns.final отсутствует либо ссылается на существующий tag, первая попытка использует точную DNS-секцию профиля — как «Из JSON».
  2. Если обязательная DNS-проба через TUN завершилась ошибкой или timeout, текущие core, TUN PFD, command clients и network callback полностью закрываются. Новая попытка использует системную DNS/Private DNS политику Android.
  3. Только после второй подтверждённой DNS-ошибки выполняется последняя чистая попытка с DNS узла через выбранный proxy outbound.
  4. После отказа DNS узла VPN закрывается. Цикла, фонового retry и перехода обратно нет.

Если профиль не задаёт DNS, цепочка начинается с Android. Ошибка JSON, bootstrap, proxy/handshake или HTTPS health-check не является DNS-ошибкой и не переключает resolver. Все попытки входят в общий deadline 45 секунд и используют один generation token, поэтому одновременно существуют не более одного TUN и одного core.

App-level переход нужен из-за контракта exact core: встроенный fallback/sequential передаёт всем транспортам один context, поэтому зависший первый resolver способен израсходовать общий deadline и не дать второму начать полезную работу. Смешивать DNS профиля, Android и DNS узла через parallel также нельзя: это отправило бы каждый новый запрос сразу всем источникам и сделало бы результат гонкой разных политик. Чистый restart сохраняет простой fail-close lifecycle и не оставляет старый DNS-кэш или reverse mapping следующему кандидату.

DNS-решение внутри каждого managed Android/Secure этапа повторяет доменную часть маршрутизации: LAN/direct-домены используют Android resolver, а proxy-домены в Secure используют DNS узла. IP-CIDR правила применяются уже после получения IP и не могут сами выбрать DNS до ответа.

Настройка «Только IPv4 через VPN» добавляет strategy: "ipv4_only" к сгенерированным DNS-действиям для доменов, которые по effective route пойдут через proxy. Она применяется в явных Secure/DNS Android и в соответствующих managed-этапах Auto, но не изменяет первую попытку с DNS профиля или явный режим «Из JSON». Pinned sing-box отвечает на AAAA пустым успешным ответом, поэтому приложение выбирает A и не тратит время на IPv6, которого нет у proxy-сервера. LAN/direct-домены, IPv6 TUN route и сохранённый JSON не меняются.

Для IPv6-only сайта пользователь может выключить опцию и вернуть dual-stack. Однако WireGuard outbound без внутреннего IPv6-адреса всё равно не способен передать IPv6: exact core завершает такой flow ошибкой missing IPv6 local address. Для него нужны IPv6-адрес интерфейса и соответствующий peer route в самом профиле. DNS-слой не может безопасно изобрести этот адрес или выдать AAAA только после неудачной IPv4-попытки приложения.

Почему имена туннеля разрешает узел, а не публичный резолвер

Узел перенаправляет любой DNS-запрос туннеля на порт 53 в собственный resolver и подменяет там адреса управляемых имён — на этом стоит наш доступ к ИИ-сервисам. Зашифрованный запрос перенаправления не видит: узел открывает обычное TLS-соединение к DoH-эндпоинту, и клиент получает настоящий origin. Дальше клиент идёт к этому origin через тот же узел и получает региональный отказ. Managed DoH через proxy, существовавший до этого решения, ломал доступ именно так.

Поэтому managed-этап использует обычный DNS на 53 через выбранный proxy outbound. Внутри туннеля это не ослабление: канал до узла уже зашифрован протоколом, а участок «узел → его upstream» принадлежит узлу. Ослаблением был бы plaintext DNS мимо туннеля — его в managed-режимах нет.

Адрес 8.8.8.8 здесь — указатель, а не резолвер, которому доверяют: пакет до него не доходит, его забирает узел. Канонические upstream самого узла (1.1.1.1 и 1.0.0.1) из перенаправления исключены, поэтому ставить их сюда нельзя — запрос ушёл бы к настоящему Cloudflare.

Транспорты перечислены sequential: сначала UDP, затем TCP на случай сетей, где UDP через прокси не проходит. Гонка parallel на одном и том же resolver'е выигрыша не даёт.

Что это не закрывает: встроенный DoH приложения (Chrome, Firefox) уходит на 443 и перехвату не поддаётся; DoT на 853 приложение намеренно не блокирует, потому что тот же порт использует системный Private DNS Android, который приложение обязано уважать.

Эталонный фрагмент защищённого этапа для sing-box 1.13.14

Фрагмент для явного Secure или managed-этапа Auto с proxy по умолчанию и прямыми LAN-исключениями:

{
  "dns": {
    "servers": [
      {
        "type": "local",
        "tag": "zapret-android-dns"
      },
      {
        "type": "udp",
        "tag": "zapret-node-dns-udp",
        "server": "8.8.8.8",
        "server_port": 53,
        "detour": "<SELECTED_PROXY_OUTBOUND_TAG>"
      },
      {
        "type": "tcp",
        "tag": "zapret-node-dns-tcp",
        "server": "8.8.8.8",
        "server_port": 53,
        "detour": "<SELECTED_PROXY_OUTBOUND_TAG>"
      },
      {
        "type": "fallback",
        "tag": "zapret-secure-dns",
        "servers": [
          "zapret-node-dns-udp",
          "zapret-node-dns-tcp"
        ],
        "strategy": "sequential"
      }
    ],
    "rules": [
      {
        "domain_regex": [
          "^[^.]+$"
        ],
        "action": "route",
        "server": "zapret-android-dns"
      },
      {
        "domain": [
          "home.arpa"
        ],
        "domain_suffix": [
          ".local",
          ".lan",
          ".home.arpa"
        ],
        "action": "route",
        "server": "zapret-android-dns"
      }
    ],
    "final": "zapret-secure-dns",
    "strategy": "prefer_ipv4",
    "cache_capacity": 4096,
    "reverse_mapping": true
  },
  "route": {
    "rules": [
      {
        "port": 53,
        "action": "hijack-dns"
      }
    ],
    "default_domain_resolver": "zapret-android-dns",
    "auto_detect_interface": true
  }
}

Это не самостоятельный профиль: полный JSON дополнительно содержит TUN inbound с address: ["172.19.0.1/30", "fdfe:dcba:9876::1/126"], auto_route: true, IPv4/IPv6 routes, direct outbound и реально выбранный proxy/selector outbound. Все placeholder заменяются до CheckConfig().

Адрес указан числом, имён внутри managed DNS нет вообще — цикл «резолвер, который надо резолвить» невозможен по построению. detour всегда заменяется тегом выбранного proxy/selector outbound, а не фиксированной строкой proxy.

Только этот managed-этап использует extended fallback. Оба транспорта ведут к одному resolver'у узла, поэтому стратегия sequential: TCP пробуется, лишь когда UDP через прокси не прошёл. Периодического трафика, отдельного health-loop и plaintext fallback мимо туннеля нет; bootstrap-путь остаётся отдельным и в цепочку zapret-secure-dns не входит.

fallback не оценивает DNS RCODE: NXDOMAIN, SERVFAIL или REFUSED являются корректным DNS-пакетом и завершают перебор. Для одного и того же resolver'а узла это и есть нужное поведение. Пользовательский режим «Из JSON» сохраняет явно заданную стратегию без подмены.

Созданные приложением объекты имеют префикс zapret-. GUI управляет только этими DNS-серверами и правилами, которые ссылаются на них; остальные JSON-поля и правила сохраняются. Полный импортированный JSON с собственным DNS по умолчанию открывается в режиме «Из JSON», без скрытого переписывания.

FakeIP

FakeIP выключен по умолчанию и в MVP не требуется.

Причины:

  • при типичном диапазоне 198.18.0.0/15 приложения и WebView могут сохранить FakeIP после остановки VPN;
  • per-app VPN не гарантирует, что последующее соединение с FakeIP попадёт в тот же TUN;
  • сложнее переключать сети и профили;
  • больше проблем с LAN, mDNS и приложениями с собственным DNS.

Используем реальные IP и reverse_mapping, чтобы sing-box мог сопоставлять DNS-ответы с доменами при маршрутизации.

Проверенное ядро требует явно задать хотя бы один inet4_range/inet6_range; не полагаемся на скрытый default. FakeIP можно добавить позже как явно экспериментальный режим. При включённом persistent store_fakeip его хранилище должно иметь отдельный cache_id для каждого профиля и полностью очищаться при удалении профиля.

Кэш

Обычные домены

Только оперативный кэш sing-box:

  • кэш включён;
  • TTL соблюдается;
  • cache_capacity = 4096;
  • independent_cache не включаем: в проверенном ядре это делает cache key общим между DNS-транспортами и немного экономит память/CPU;
  • persistent DNS cache в MVP не используем.

Общий cache key безопасен для сгенерированных режимов только при инварианте: один и тот же домен в пределах активной конфигурации всегда выбирает один и тот же DNS-транспорт. Zapret KVN не создаёт DNS-выбор одного домена, зависящий от пакета/UID. При смене DNS-режима или правил выполняется перезапуск core. Если пользователь вручную строит контекстно-зависимую схему, это режим «Из JSON», и он сам решает, включать ли independent_cache.

В проверенном commit experimental.cache_file умеет сохранять FakeIP и rejected-response cache, но поля persistent DNS cache нет. Поэтому обычные DNS-ответы на диск не записываются. Возможности будущей версии не считаются частью этой ADR до нового аудита.

Адрес VPN-сервера

Отдельный небольшой bootstrap cache содержит:

  • ID профиля и hostname сервера;
  • последний успешно проверенный набор A/AAAA;
  • время разрешения и последнего успешного подключения.

Политика MVP:

  • системный Android resolver всегда пробуется первым;
  • до 24 часов — резервный кандидат после ошибки системного DNS или неудачи всех новых адресов;
  • от 24 часов до 7 дней — только аварийный кандидат, когда системное разрешение имени не удалось;
  • старше 7 дней, после изменения hostname или удаления профиля — удалить;
  • не сохранять токены, UUID, пароли или полный URL подписки.

Кэш хранится небольшим атомарным файлом в noBackupFilesDir, ограничивается по размеру и очищается кнопкой диагностики.

При аварийном использовании сохранённый JSON не меняется. В runtime-копию добавляется hosts DNS server с точным hostname активного proxy outbound, а domain_resolver только этого outbound временно указывает на него. Само поле server остаётся доменным, поэтому сохраняются TLS SNI, Reality и HTTP Host. Такой overlay существует только во время одного повторного запуска core и никогда не сохраняется в профиль.

Адрес попадает в last-known-good только после успешной протокольной проверки через этот сервер. Автоматический stale-fallback запрещён для plaintext SOCKS/HTTP и других outbounds без проверки удалённой стороны: устаревший IP не должен получить учётные данные или трафик.

Смена сети

При переходе WiFi ↔ cellular:

  1. дождаться, пока Android действительно настроит новый линк: есть интерфейс, резолверы и вердикт validated либо captive portal. Пока сеть дозревает, debounce перезапуска увеличен, а потолок ожидания не даёт зависнуть на сети, которую Android не пометит validated никогда;
  2. получить новый underlying Network;
  3. обновить setUnderlyingNetworks(), только если сокеты явно привязаны к underlying network;
  4. отменить незавершённые platform DNS-запросы;
  5. заново разрешить hostname сервера через новый Network;
  6. в MVP контролируемо перезапустить core, чтобы удалить DNS response cache, reverse mapping и старые соединения;
  7. использовать last-known-good только после неудачи нового разрешения/подключения.

Одного libbox ResetNetwork() для строгого сброса недостаточно: оно очищает response cache и сбрасывает DNS-транспорты, но текущий dnsReverseMapping остаётся жить до TTL. Полный перезапуск нужен только при фактической смене Network, DNS-режима/правил и по ручной команде, а не при каждом LinkProperties callback.

Кнопка «Очистить DNS-кэш» очищает bootstrap cache и выполняет контролируемый перезапуск core. Она не может глобально очистить системный/Private DNS cache Android. На API 29+ следующую диагностическую bootstrap-пробу выполняем с FLAG_NO_CACHE_LOOKUP; на API 2628 публичного no-cache API нет. Отдельный сложный cache manager не нужен.

Ошибки и состояние подключения

Последовательность запуска:

Проверка исходного JSON
→ проверка непустого списка приложений
→ ожидание пригодной физической сети вне общего deadline
→ получение underlying Network
→ проверка совместимости с Private DNS
→ bootstrap адреса сервера
→ построение и повторная libbox-проверка effective runtime JSON
→ запуск libbox и TUN
→ проверка, что VPN стал default network для UID Zapret KVN
→ DNS-проба обычным сокетом UID Zapret KVN через TUN
→ только в Auto при DNS-ошибке: полный stop и следующий кандидат профиль → DNS узла → Android
→ HTTPS-пробы Cloudflare, Google и OpenDNS через TUN со ступенчатым стартом
→ первый корректный ответ побеждает и отменяет остальные пробы
→ при провале всех трёх — одна спасательная проба с удвоенными тайм-аутами
→ Подключено

После establish() приложение получает VPN Network через ConnectivityManager и проверяет, что он стал activeNetwork именно для UID Zapret KVN. Сам пакет клиента уже внутренне присутствует в Android allowlist. DNS использует resolver этого VPN Network, а обычная HTTPS-проба — Network.openConnection(), поэтому оба запроса входят в TUN. Явный Network.bindSocket() к собственному VPN Network не используется: instrumented-проверка Android 16 вернула владельцу VpnService EPERM. Underlying Network используется только для bootstrap и защищённых исходящих сокетов core; health-check никогда не привязывается к underlying. Вход в TUN сам по себе ещё не гарантирует proxy-путь, поэтому отдельное раннее health route запрещает выход проверки через внутренний direct.

Во всех четырёх режимах runtime-копия ставит перед пользовательскими destination rules две узкие операции: TLS sniff только для package Zapret KVN, TCP/443, а затем точные HTTPS probe-hostnames → selected outbound для того же package. Поэтому проверка не зависит от пользовательского dns.reverse_mapping, не может ложно выйти через direct и не включает global sniff для выбранных приложений. Test 24 показал, что отдельная WireGuard-only concrete URL-test из трёх публичных сайтов не отличает поломку WireGuard от блокировки самих сайтов и добавляет ровно 12 секунд; этот дублирующий gate удалён. В managed-режимах и при минимальном Android fallback также стоит port: 53 → hijack-dns. Probe-hostnames не сохраняются в профиль и не используются для фонового polling.

Для выбранного userspace WireGuard приложение отдельно читает только внутренние address endpoint. Если у него есть IPv4, но нет IPv6, HTTPS health-check разрешает имя через resolver активной VPN Network, выбирает только IPv4 и открывает числовой адрес через Network.getSocketFactory(). Исходное имя одновременно сохраняется в TLS SNI, HTTPS hostname verification и HTTP Host, поэтому проверка сертификата не ослабляется. Для IPv6-only endpoint применяется симметричный выбор IPv6; dual-stack и остальные outbound сохраняют обычный resolver. Если требуемого семейства в DNS-ответе нет, приложение возвращает точный VPN-201, а не общий VPN-200. Адреса, allowed_ips, DNS и route сохранённого JSON при этом не меняются.

Несколько DNS server objects сами по себе не образуют резервирование. В режиме «Из JSON» приложение анализирует ссылки из dns.final, dns.rules, route.default_domain_resolver, outbound/endpoint domain_resolver и дочерних fallback.servers. Недостижимые серверы показываются предупреждением при импорте и в bounded diagnostic log, но приложение не создаёт fallback скрыто и не переписывает пользовательскую семантику.

Для защищённого кандидата DNS-проба отправляет собственный минимальный UDP-запрос, а при необходимости TCP-повтор, на адрес из GetDNSServerAddress() обычным сокетом health UID; так проверяются per-app admission, TUN route и hijack-dns, а не OS cache. Для DNS профиля и DNS Android используется DnsResolver с VPN Network: запрос всё равно входит в TUN и проверяет фактически объявленный Android DNS этой сессии. Только полученная здесь DNS-ошибка имеет право перевести Auto к следующему кандидату.

Один внешний HTTPS URL не является единственным критерием подключения: перед ним обязательны bootstrap/proxy и DNS-пробы. Три небольших HTTPS probe (Cloudflare, Google, OpenDNS) стартуют через выбранный outbound со ступенчатой задержкой 1 секунда между стартами («happy eyeballs»: медленный туннель не получает залп из шести DNS-запросов и трёх TCP-сессий одновременно); первый корректный ответ выигрывает, а остальные пробы отменяются с немедленным закрытием их сокетов. Любой корректный TLS+HTTP ответ 2xx5xx доказывает полезный путь; точный код 204 не требуется. DNS-резолв внутри туннеля для health-проб использует укороченный тайм-аут 3 секунды (bootstrap на физической сети сохраняет 8), чтобы fallback на следующий DNS-режим начинался быстрее. Если все три пробы провалились, выполняется одна спасательная последовательная проба первого endpoint с удвоенными тайм-аутами (резолв 5 с, сокет 8 с): живой, но медленный туннель проходит проверку со статусом recovered, мёртвый — нет. Только отказ всех трёх endpoints и спасательной пробы закрывает VPN — при этом ошибки в диагностике сохраняют фиксированный порядок endpoints и включают исход спасательной пробы. Внешний IP остаётся дополнительной диагностикой, а не блокирующим условием. Эти запросы существуют только при подключении/контролируемом restart: периодического опроса нет.

Если underlying network работает, но proxy или активный DNS не восстановились в пределах bounded health deadline (20 секунд для DNS+HTTPS части одной попытки; 45 секунд для всего Auto-запуска):

  • отменить health-check и network jobs;
  • идемпотентно остановить core и в finally закрыть сохранённый ParcelFileDescriptor TUN;
  • остановить foreground service;
  • показать причину;
  • вернуть выбранные приложения в обычную сеть Android.

Не оставляем активный TUN с мёртвым внутренним DNS и не показываем «Подключено» после проваленной обязательной пробы.

Тот же cleanup обязателен при исключении из OpenTun(), ошибке instance.Start(), establish() == null, onRevoke() и отмене запуска. Проверенный StartedService может перейти в FATAL, сохранив ссылку на уже закрытый instance, а Java/Kotlin PFD ядро закрыть не может. Поэтому PFD принадлежит одному VpnService-координатору, все start/stop/reload события сериализуются, а повторное закрытие безопасно.

Возможные причины:

  • «Нет активной сети»;
  • «Требуется вход в WiFi»;
  • «Системный/Private DNS не отвечает»;
  • «Не удалось разрешить адрес VPN-сервера»;
  • «VPN-сервер не отвечает»;
  • «DNS через VPN не отвечает»;
  • «Конфигурация DNS несовместима с этой версией ядра».

Невыбранные приложения при всех этих переходах остаются вне TUN и не ждут десятисекундное восстановление.

Настройки DNS

  • Автоматически — DNS профиля, если он задан; после подтверждённой DNS-ошибки чистая попытка с DNS узла через proxy, затем последний резерв — DNS Android с предупреждением. При strict Private DNS используется только DNS Android.
  • DNS Android — все стандартные DNS-запросы выбранных приложений через системную DNS-политику Android/Private DNS.
  • DNS узла через VPN — DNS по TCP/UDP 53 выбранных приложений уходит на 53 через proxy outbound, где его забирает resolver самого узла; strict Private DNS блокирует запуск этого режима.
  • Из JSON — существующая DNS-секция используется без замены и fallback-цепочки. Если DNS-секции или серверов в профиле нет, runtime-копия получает минимальный local DNS Android; это не записывается в профиль и не включает managed-резолверы.
  • Только IPv4 через VPN — включённая по умолчанию опция для Secure/DNS Android и managed-этапов Auto: подавляет AAAA только для доменов, направляемых через proxy; первая попытка с DNS профиля, direct/LAN и пользовательский JSON остаются без изменений. Для IPv6-only proxy-сайта опцию можно выключить, но WireGuard-профиль при этом обязан иметь собственный внутренний IPv6-адрес.
  • DNS-переопределение — одна глобальная редактируемая пара точный hostname → IPv4, применяемая только в Secure/DNS Android и соответствующих managed-этапах Auto. Настройка включена по умолчанию как ntc.party → 130.255.77.28; пользователь может изменить пару или выключить её. Runtime добавляет отдельный hosts transport и точное DNS-правило после reject, но до правил выбора resolver. Первая попытка Auto с DNS профиля не меняется. Имя назначения, TLS SNI и HTTP Host не подменяются; правило маршрутизации сайта также не создаётся.

DNS-переопределение является таким же bounded global runtime-параметром, как режим DNS и proxy_ipv4_only: его единственным источником истины служит ui_settings DataStore, а сохранённый JSON профиля не переписывается. Поддерживается ровно одна запись, чтобы не создавать второй hosts-формат или неограниченный список вне профиля. В режиме «Из JSON» эта настройка сохраняется, но не применяется: пользовательская DNS-секция остаётся полностью самостоятельной. Диагностика показывает только активность override, но не копирует hostname или IP.

FakeIP находится только в расширенных экспериментальных настройках и выключен по умолчанию.

Блокировка доменов

Managed-действие «Блокировать» добавляет domain rule-set в dns.rules с action: reject до правил выбора resolver. По умолчанию sing-box отвечает REFUSED; отдельный block DNS server не создаётся. Такое же правило обязательно остаётся в route.rules как защита при наличии domain/reverse mapping. Прямое обращение только к IP требует отдельного IP-set.

IP-only rule-set не вставляется в DNS-правила: адрес ещё неизвестен до DNS-ответа. Встроенный DoH/DoT приложения может скрыть домен от managed DNS, поэтому domain-only блокировка без соответствующего IP-set или reverse mapping не объявляется полноценным firewall. Глобальный sniff для компенсации не включаем.

Честные ограничения

  • Быстрое правило MVP перехватывает DNS только по TCP/UDP 53. Оно не ловит plaintext DNS на нестандартном порту, DoT/853 и встроенный DoH; такой трафик следует обычным route-правилам.
  • DNS-переопределение действует только на перехваченный стандартный DNS. Встроенный DoH/DoT приложения может вернуть другой ответ или отсутствие записи; приложение честно показывает это ограничение и не пытается MITM/блокировать защищённый resolver.
  • Android strict Private DNS применяется и к VPN network на поддерживаемых версиях ОС. Его DoT/853 обходит hijack-dns и reverse_mapping, поэтому при strict Auto автоматически сужается до кандидата «DNS Android», а Secure не запускается, пока strict настроен.
  • mDNS использует multicast/5353 и не обслуживается правилом port: 53 → hijack-dns. Наличие .local в DNS-правилах помогает только тем запросам, которые приложение отправило обычному Android resolver; полноценная LAN/mDNS-совместимость проверяется отдельно.
  • На Android ниже 10 platform DNS-интерфейс sing-box поддерживает только A/AAAA.
  • DNS-выбор можно синхронизировать с доменными/rule-set правилами, но не с IP-CIDR до получения ответа.
  • Доступ к .local и другим LAN-именам зависит от возможностей platform resolver и настроенного пользователем Private DNS; приложение не обходит strict Private DNS plaintext-запросом.
  • reverse_mapping содержит не более 1024 IP→domain записей с TTL DNS-ответа и не очищается текущим ResetNetwork(). Поэтому при значимой смене сети/режима перезапускается core.
  • Кнопка приложения не может очистить глобальный DNS/Private DNS cache Android; на API 29+ она может лишь запросить следующую диагностику без cache lookup.
  • В режиме «Из JSON» ответственность за утечки и совместимость пользовательской DNS-схемы остаётся у конфигурации; приложение только валидирует и диагностирует её.
  • Strict Private DNS уважается для Android resolver; приложение не может обещать одновременно принудительный собственный резолвер и неизменённый system strict resolver, поэтому конфликт разрешается явной блокировкой, а не скрытым fallback.

Версионирование и проверка

Для аудита CLI собран локально из точного commit ff11f00… с Go 1.26.4; sing-box version подтвердил встроенный revision, SHA-256 audit-бинарника — 845b9370443894f163176599aa63a231dbdae8f943e6d9e0e15804cb5fd1843c. Повторный прогон дал 4/4 для хранящихся в репозитории эталонных JSON:

Эталон SHA-256
Android DNS 74ed42ab142a16700710be544c0d3d8c4289924457df3e02259bf03a71b34f36
Auto, proxy-final 7452e4d4f284557300ba79f7764359289cf9eb4668331c02826c9ea308cd5682
Auto, direct-final 531122a14a5665c6ce1be753be64f8c79da6a92de09afd1d78fea8dfd41d15d0
LKG hosts overlay c0df41216c1eba5f11163bdc724c1458feec18392311d685b87c337fb37fa747

Это schema/graph fixtures, а не готовые пользовательские профили: selected-proxy в них заменён на direct, а probe/LKG адреса принадлежат зарезервированным example-диапазонам. CI проверяет именно схему, связи tag-ов и runtime-overlay.

Этим CLI также подтверждены:

  • DNS Android;
  • Auto с proxy-final;
  • Auto с direct-final;
  • port: 53 → hijack-dns;
  • runtime exact probe domains → selected outbound без process/package lookup;
  • fallback/sequential над UDP/TCP-транспортами DNS узла;
  • runtime hosts.predefined + outbound.domain_resolver.

Воспроизводимый audit-test временно копируется в dns/transport/fallback exact pinned checkout и запускается скриптом сборки без изменения сохранённого source tree. Он подтвердил: первый success не вызывает резервный transport; transport error вызывает; зависший первый transport исчерпывает общий context, поэтому второй получает уже истёкший deadline; NXDOMAIN, SERVFAIL и REFUSED без Go-ошибки не запускают следующий transport. Также подтверждены 172.19.0.1/30 → 172.19.0.2, отказ для /32, полный capture обеих IP-family и потеря 172.19.0.2 при исключении RFC1918 172.16.0.0/12. Повторный go test ./dns/... ./route/rule ./experimental/libbox прошёл. В upstream fallback test-файлов нет; наш audit закрывает поведенческую часть P15, но не заменяет сравнение энергии и cache-burst на физических устройствах.

Source-аудит отдельно подтвердил, что libbox package override является append только для первого TUN, Android owner lookup в этом commit включён безусловно, platform protect(fd) пропускается рядом bind-полей, а pinned Android monitor может повторить одно обновление до десяти раз. Это не свойства JSON schema, поэтому sing-box check их обнаружить не способен.

Те же JSON принял скачанный upstream release binary, но его embedded revision — ea48501…, поэтому он служит только второй проверкой совместимости, а не источником истины. sing-box check доказывает корректность схемы и ссылок между объектами, но не исполняет Android OpenTun, platform resolver и per-app VpnService.Builder; их покрывают только Android-тесты.

При обновлении ядра CI должен:

  1. принимать только полный 40-символьный commit SHA, а не плавающую branch или содержимое upstream release asset;
  2. собрать libbox и CLI из одного commit и проверить, что sing-box version содержит ровно этот revision;
  3. выполнить sing-box check для четырёх эталонов: DNS Android, Auto с proxy-final, Auto с direct-final и LKG overlay;
  4. core audit-тестом зафиксировать семантику sequential/parallel, общий timeout и поведение RCODE; JVM-тестом проверить, что managed runtime генерирует fallback/parallel;
  5. проверить TunOptions.GetDNSServerAddress(), полный IPv4/IPv6 capture и недостижимость DNS при конфликтующем route-exclude;
  6. собрать AAR и скомпилировать наш platform adapter против него: Android reference не считается ABI-тестом;
  7. negative-тестами preflight проверить ноль/несколько TUN, оба package-list в импортированном JSON, запрещённые bind-поля и partial routes;
  8. запустить Android instrumented-тесты platform resolver, strict Private DNS, protect(fd), port-53 hijack и allowlist: выбранное приложение через TUN, контрольное приложение вне TUN;
  9. не выпускать APK при несовместимом JSON, изменившемся libbox API или несовпадении revision.

Любое обновление sing-box-extended является отдельной миграцией: сравниваются как минимум dns/, route/, common/dialer/, experimental/libbox/, option/, Android reference и release workflow. Приложение и встроенное ядро обновляются только вместе.

Минимальная проверочная матрица

Это детализация пунктов P1, P4, P5, P6, P8, P9 и P15 из единого раздела «Потом проверить». Она задаёт тесты, но не добавляет новых архитектурных решений.

  • Android 8, 9, 10 и современная версия Android;
  • Private DNS: off, automatic, strict working, strict broken; Auto при strict working сужается до «DNS Android» и подключается, Secure и любой режим при strict broken обязаны остановиться до establish(), Android DNS — сохранить системное поведение;
  • WiFi, cellular, WiFi ↔ cellular, IPv6/NAT64;
  • captive portal;
  • заблокирован системный DNS, есть/нет last-known-good;
  • Auto с DNS профиля: успех без Android/DNS узла, DNS error/timeout с переходом к DNS узла, затем к Android; HTTPS/proxy/JSON-ошибка не должна запускать DNS fallback; после каждой неудачной попытки ноль старых core/PFD/callback;
  • DNS узла на cache miss: UDP отвечает; UDP не проходит и отвечает TCP; оба недоступны; отдельно проверяется, что NXDOMAIN/SERVFAIL/REFUSED завершают перебор и не уводят резолв наружу из туннеля;
  • управляемое ИИ-имя внутри туннеля разрешается в адрес, отданный узлом, а не в настоящий origin;
  • proxy доступен по hostname и по IP;
  • каждый поддерживаемый MVP outbound отдельно проходит socket-protection/loop test; неаудированный backend отклоняется до establish();
  • UDP и TCP DNS на 53; DoT/853, DoH/443, custom DNS port и mDNS/5353;
  • allowlist пуст, пакет удалён до и во время Builder, один/несколько выбранных пакетов, общий UID, импортированный JSON пытается задать другой package list;
  • параллельная проверка: выбранное приложение через VPN и невыбранное напрямую;
  • симулированный protect(fd) == false обязан сорвать подключение;
  • ноль/два TUN, partial IPv4/IPv6 routes, RFC1918 route-exclude и запрещённые bind/mark/netns-поля отклоняются preflight;
  • health-check в режиме route.final = direct всё равно проходит через выбранный outbound;
  • остановка core, ошибка после OpenTun, revoke VPN, смена профиля, смена DNS-режима и проверка закрытия PFD/reverse mapping;
  • повторяющиеся и устаревшие network callbacks не запускают несколько core restart;
  • benchmark owner/process lookup на API 26 и современном Android; отдельно считаются TCP/UDP flow/s, CPU и battery impact;
  • приложение со стандартным DNS и приложение со встроенным DoH (второе обходит managed DNS и это ожидаемо).

Состояние автоматизированной матрицы — 22 июля 2026

  • DNS-матрица входит в прошедшие 66/66 instrumented-тестов на AVD API 26/29 и текущие 67/67 на API 36.
  • JVM-тесты подтверждают конечный порядок Auto профиль → DNS узла → Android, отсутствие fallback у явных режимов и запрет переключения на non-DNS ошибке. Новый instrumented-тест clean restart «DNS узла → Android» с fault injection компилируется; физическое подтверждение входит в Test 21 и пока не считается закрытым gate.
  • Проверены legacy Network.getAllByName() и API 29+ DnsResolver, Private DNS off/automatic/strict working/strict broken, включая поломку strict во время активного TUN, реальные переключения emulator Wi-Fi ↔ cellular, IPv6, fresh/emergency/expired/no LKG, недоступный proxy, мёртвый внутренний DNS и немедленный возврат обычной Android network после stop. Замена managed DoH на DNS узла закреплена JVM-тестом; физическое подтверждение резолва управляемого имени на RU-узле остаётся обязательной проверкой.
  • Captive-portal ветка проверена детерминированной подстановкой platform state до establish(). Это доказывает fail-close кода, но не заменяет настоящий портал Android validation.
  • Эмуляторная сеть dual-stack не является IPv6-only/NAT64. Поэтому настоящий captive portal, NAT64 и повтор blocked-DNS/LKG/DNS узла на физических Wi-Fi/mobile сетях остаются открытой частью Gate 3.
  • Отдельный stress на API 29/36 прошёл 100 connect/stop и 50 Wi-Fi/cellular transitions с ровно одним core restart на переход.

Источники