Почему ваш туннель Android VpnService перестаёт маршрутизировать трафик
Четыре убеждения о VpnService, которые ломаются в продакшене: один туннель за раз, охват маршрутов, списки по приложениям и порядок остановки, с проверкой для каждого.
Четыре допущения об Android VpnService однажды стоили мне большей части недели, и ни одно из них не касалось сетевого протокола. Приложение говорило «подключено», значок ключа висел в строке состояния, а трафик всё равно уходил обычным маршрутом. Я продолжал считать, что туннель мой, пока я его не остановлю, что добавленные мной маршруты — это те маршруты, которые я получаю, и что вызов stopSelf уберёт интерфейс вместе с собой. Сервис вёл себя ровно так, как описано в документации. Сломанной была моя мысленная модель.
Миф 1: туннель стоит, пока вы его не остановите
Android держит один VPN-интерфейс одновременно. Поднимите второй, и платформа не поставит его в очередь, а деактивирует ваш и вызовет onRevoke() в вашем сервисе. Если ваш код разбирает всё только по нажатию кнопки, перехват оставляет после себя дескриптор и поток чтения, и следующий запуск наследует оба.
Документация VpnService прямо говорит, что prepare() возвращает null, когда у приложения уже есть согласие, и Intent, когда пользователь ещё должен его подтвердить. Это согласие доходит только если вы передадите намерение через startActivityForResult(). Пропустите передачу, и establish() вернёт null, что читается как тихий сбой, а не как проблема с разрешениями.
Always-on VPN добавляет владельца, которого вы не контролируете. Система может вернуть туннель после загрузки или после падения, и она вытесняет VPN-приложение, запущенное пользователем вручную. Стройте onRevoke() так, чтобы он закрывал те же ресурсы, что и обычная остановка, и относитесь к нему как к основному пути остановки, а не как к редкому случаю. Пользователи меняют VPN-приложения; ваш код должен это пережить.
Миф 2: addRoute захватывает ровно то, что вы перечислили
Маршруты решают, какие пакеты доходят до вашего дескриптора, а ошибка в охвате создаёт тот бесящий случай, когда часть трафика идёт через туннель, а часть нет. addRoute("0.0.0.0", 0) забирает весь IPv4, то есть теперь каждый пакет приходит к вам и его нужно передать дальше или отбросить. /32 для одного хоста почти ничего не забирает, а всё остальное ведёт себя так, будто ваш VPN никогда не устанавливался.
Семейства считаются отдельно. Маршрут только для IPv4 оставляет IPv6 в базовой сети, поэтому узел с двумя стеками всё ещё может разрешать имена и подключаться вне туннеля, выглядя при этом полностью подключённым внутри него.
DNS следует той же логике. Серверы, добавленные через addDnsServer(), достаются приложениям, использующим резолвер туннеля, тогда как приложение с собственным зашифрованным DNS-эндпоинтом или с Private DNS, заданным на имя хоста, полностью их игнорирует. Когда имена не разрешаются для одного приложения и работают для другого, проверьте путь резолвера, прежде чем трогать хоть один маршрут.
Миф 3: правила по приложениям — это переключатель, который можно повернуть в любую сторону
addDisallowedApplication() и addAllowedApplication() — это два режима, а не два фильтра. Конструктор принимает одну модель на туннель, поэтому приложение, которому нужно всё, кроме трёх пакетов, использует запрещённый список и оставляет разрешённый пустым. Они взаимоисключающие, и люди узнают об этом во время выполнения после того, как экран настроек уже предложил оба переключателя.
Видимость пакетов тоже кусается. На Android 11 и новее можно назвать только те пакеты, которые вы видите, поэтому список исключений, собранный из запроса, который вашему приложению выполнять нельзя, тихо потеряет записи. Сравните список с тем, что менеджер пакетов действительно возвращает, прежде чем ему доверять.
Рабочие профили снова делят аудиторию. Туннель, запущенный внутри профиля, покрывает этот профиль и его приложения, а личная сторона сохраняет собственную связность. Это решение об охвате, принятое один раз, и его место в интерфейсе, а не в заявке в поддержку.
Миф 4: stopSelf завершает туннель
Не завершает. Интерфейс живёт в дескрипторе, который возвращает establish(), а объект сервиса может умереть, пока этот дескриптор остаётся открытым. Сначала закройте ParcelFileDescriptor, отпустите ссылку, присоедините или прервите потоки чтения и записи, затем вызовите stopForeground(true) и stopSelf() в этом порядке.
Забытый дескриптор — обычная причина того, что сервис как будто переживает собственный запрос на остановку, и это объясняет устаревший маршрут, который живёт дольше добавившего его приложения.
Обратная ошибка столь же часта: закрыть дескриптор, когда поток чтения находится посреди чтения, а затем оставить этот поток разбираться с исключением, для которого никто не написал обработку. Держите последовательность остановки в одном месте и позвольте и действию пользователя, и onRevoke() вызывать её.
Что проверить, когда туннель выглядит мёртвым
Защитите долгоживущие сокеты через protect() до того, как туннель поднимется. Без этого собственный транспорт туннеля уходит обратно в туннель, и соединение застревает так, что выглядит в точности как сбой сервера. Проверяйте на адресе, который существует только за удалённой сетью, а не на публичном сайте, который отвечает из кеша или по базовому пути.
Записывайте результат establish(), маршруты, исключённые пакеты и причину остановки. Эти четыре строки отделяют проблему согласия от проблемы охвата и от проблемы жизненного цикла без перехвата пакетов.