Раздельное туннелирование в Android VpnService: что реально можно маршрутизировать
VpnService передаёт только те пакеты, которые builder помещает в свой дескриптор. Проверьте префиксы, аутентификацию прокси, DNS и списки приложений, прежде чем винить раздельное туннелирование.
Раздельное туннелирование в приложении VpnService ломается в нескольких конкретных местах, и исправление для каждого своё. Клиент показывает только один сбойный адрес назначения, поэтому первая задача — понять, какую границу вы задеваете: маршруты, которые получил builder, прокси, который не удаётся аутентифицировать, resolver, который вы только объявили, или список приложений, который платформа применяет для каждого пользователя. Именно поэтому я держу проверку из четырёх шагов. Она идёт в указанном ниже порядке и останавливается на первой ошибке.
Подтвердите, что builder передал платформе
VpnService.Builder — это список объявлений. establish() возвращает ParcelFileDescriptor для виртуального интерфейса, и платформа направляет в этот дескриптор только трафик, соответствующий префиксам из addAddress и addRoute; addDnsServer сообщает клиентам, какой resolver опрашивать. Адрес назначения вне всех добавленных вами префиксов никогда не доходит до приложения, что бы ни обещал интерфейс. Этот единственный факт объясняет большинство сообщений о сломанном раздельном туннелировании ещё до изменения кода.
Занесите в журнал списки адресов, маршрутов и DNS, собранные builder, и сравните их с адресами, которые не работают. Отсутствующий /32 для хоста управления встречается гораздо чаще, чем сломанный туннель. Если список верен, переходите дальше; если нет, остаток чек-листа — пустая трата сил.
excludeRoute существует только с API 33
Исключение по адресу назначения — это запрос, который чаще всего превращается в тему поддержки. builder.excludeRoute(IpPrefix) появился в API 33; до этого единственное поддерживаемое приближение — добавить маршруты для всего, кроме исключаемого префикса, то есть вычислить дополнительные префиксы самостоятельно. Эти два вызова не зеркальны: один вычитает из набора маршрутов интерфейса, другой строит набор с нуля, а старое устройство выбросит исключение или молча проигнорирует незнакомый вызов. Ветвитесь по Build.VERSION.SDK_INT и проверяйте оба пути на реальном оборудовании.
Вычисляя дополнения, следите за выравниванием префиксов. Исключение /24 внутри маршрута /16 требует покрывающих префиксов, которые действительно описывают остаток, а не один искажённый /8. Запишите итоговый список и убедитесь, что исключённый хост по-прежнему разрешается путём вне туннеля.
Учётные данные прокси невозможно выразить
setHttpProxy(ProxyInfo) направляет интерфейс на прокси HTTP или SOCKS, а PAC-URL может выбирать между ними для каждого адреса. Ни ProxyInfo, ни формат PAC не передают имя пользователя и пароль, поэтому прокси с аутентификацией остаётся вне API платформы. Приложения, которым он нужен, обычно встраивают стек пользовательского пространства, tun2socks или похожий движок и сами отвечают на вызов 407.
У этого выбора есть последствия. Стек пользовательского пространства видит только тот трафик, который вы направляете в него, поэтому он должен жить в тех же границах дескриптора и наследует то же требование protect() для собственного сокета восходящего канала. Пропустите protect() — и туннель захватит собственный транспорт, что выглядит как рукопожатие, застревающее после первого пакета. Проверяйте на прокси, который требует учётные данные: открытый прокси проходит по пути платформы и ничего не доказывает.
Списки приложений и DNS — тоже объявления
У Builder есть и addAllowedApplication, и addDisallowedApplication, а они взаимоисключающие: вызов обоих приводит к исключению. В любом случае список применяется для каждого пользователя при настройке интерфейса, поэтому приложением рабочего профиля управляет туннель рабочего профиля, а не ваш. Чего не даёт ни один API — контроля над решением другого приложения: вы не можете извне протолкнуть пакет в свой туннель и не можете закрыть или перенастроить уже активный VPN.
DNS следует той же схеме. addDnsServer объявляет адрес resolver; он не позволяет приложению отвечать на запросы. Если хотите разрешать имена сами, запустите resolver и направьте его адрес в туннель. Приложения, использующие DoT или DoH к фиксированному имени хоста, могут обойти объявленный адрес, поэтому считайте разрешение имён лучшей попыткой, если вы не перехватываете и путь запроса.
Манифест, always-on и тест из четырёх шагов
Пригодность объявляется в манифесте: BIND_VPN_SERVICE, intent-фильтр android.net.VpnService и foreground-служба, если туннель должен выживать в фоне. Always-on VPN дополнительно требует чистого onRevoke, потому что система вызывает его, когда пользователь меняет настройки или другое приложение перехватывает управление.
Выполняйте проверки по порядку и останавливайтесь на первой ошибке. Первое: выведите списки адресов, маршрутов и DNS, переданные builder. Второе: подтвердите, что establish() вернул дескриптор и транспортный сокет защищён. Третье: запросите один адрес внутри включённого префикса и один внутри исключённого. Четвёртое: выясните, какой resolver действительно ответил, запросив имя хоста, которое может разрешить только ваш resolver.
Завершённое рукопожатие не доказывает ни одного из последующих шагов. Каждая проверка изолирует один слой, поэтому успех на втором шаге ничего не говорит о четвёртом.
Справочник VpnService.Builder перечисляет всю поверхность builder. Всё, что нужно сверх неё, — это другой API, другое приложение или ваш собственный стек пользовательского пространства, и это решение проектирования, а не ошибка раздельного туннелирования.