Android VPN: дескриптор VpnService против root-устройства tun
Приложения Android не могут сами создать tun0: что возвращает establish(), где останавливается Binder и начинается JNI и когда выигрывает устройство, созданное root.
Два пути могут поместить IP-интерфейс перед Android-приложением, и оба заканчиваются одним и тем же типом файлового дескриптора с очень разными владельцами. Один идёт через VpnService и его builder, другой через root-оболочку и /dev/net/tun. Каждое чтение из этого дескриптора — исходящий пакет, каждая запись — входящий. Пути разделяет то, кто держит политику, что переживает обновление ОС и насколько можно трогать таблицу маршрутизации.
Выбирай путь платформы, если только не можешь назвать конкретную вещь, от которой она отказывается. Этот отказ обычно TAP, захват за пределами собственного набора приложений или правило маршрутизации, которое фреймворк никогда не раскрывает.
На пути платформы ты объявляешь намерение вместо команд. Ты указываешь хотя бы один адрес, нужные маршруты, DNS-серверы и необязательный список приложений, затем системная служба Android выполняет привилегированную настройку под собственной идентичностью. Ты никогда не узнаёшь имя интерфейса заранее и никогда не выдаёшь ioctl, который его создаёт, и в этом весь смысл.
Два способа получить IP-интерфейс
Путь root — это рецепт постарше. Ты открываешь /dev/net/tun, вызываешь TUNSETIFF с IFF_TUN, поднимаешь интерфейс через ip link и вручную добавляешь адреса и маршруты. Каждый шаг нужно сделать правильно самому, включая те, что фреймворк сделал бы молча. Один пропущенный маршрут даёт пользователю такой же отчёт, как мёртвый сервер.
Отладка отличается ровно настолько, насколько и настройка. На пути root интерфейс либо есть, либо твой ioctl вернул ошибку, которую можно прочитать напрямую. На пути платформы интерфейс существует только после успеха establish(), поэтому сбой приходит как исключение или null-дескриптор, а не как узел устройства, в котором можно покопаться.
Что на самом деле возвращает establish()
У Builder.establish() три исхода, и им нужны три разных реакции. Дескриптор означает, что интерфейс настроен и твой для использования, null означает, что запрос не прошёл, а выброшенное исключение означает, что системная служба его отклонила. Схлопывание null и успеха в один boolean — самый частый способ, которым приложение сообщает о работающем туннеле, который никогда не несёт пакет. Справочник Builder указывает требование к адресу, и это стоит прочитать до того, как винить сервер.
Направления задаёт дескриптор, а не твой протокол. Чтения дают исходящие пакеты, уже отобранные для VPN, а записи впрыскивают входящие пакеты обратно в интерфейс. Твой собственный транспорт шифрует, аутентифицирует и обрамляет эти пакеты, потому что платформа останавливается на границе пакета.
Binder — один шаг, JNI — другая ось
Запрос builder пересекает границу системы через Binder, и где-то ниже платформа вызывает нативный код, чтобы настроить устройство. Это два разных механизма, уложенных в один вызов. Binder — межпроцессное взаимодействие между приложением и системной службой, а JNI — мост из Java в нативный код внутри процесса. Чтение дерева AOSP Android 9 делает стек буквальным, и стоит помнить, что внутренние имена классов и пути к файлам меняются между выпусками.
Что не меняется — это контракт, на который можно опереться: builder, дескриптор, который он возвращает, и жизненный цикл службы вокруг него. Ни одно приложение не должно вызывать внутреннюю транзакцию службы или пересоздавать узел устройства, чтобы получить интерфейс. Если твой код зависит от имени класса из AOSP, он сломается на выпуске, который ты не тестировал.
TUN несёт IP, TAP несёт Ethernet
TUN — это IP-интерфейс. TAP — это Ethernet-интерфейс, и эта разница решает, возможно ли мостовое объединение вообще. У TUN-устройства нет MAC-адреса и нет заголовка Ethernet для пересылки, поэтому TUN-клиент не может войти в мост уровня 2, как бы ни были написаны маршруты.
VpnService даёт тебе вариант IP, который нужен VPN уровня приложения и который он может поддерживать без сырого стека Ethernet. Если твой дизайн требует моста, путь платформы туда не приведёт, а путь root — только если ядро и разрешения устройства будут сотрудничать.
Где путь root всё ещё выигрывает
Три задачи уводят людей с пути платформы. Мосту уровня 2 нужен TAP. Захват трафика приложений за пределами твоей зоны VPN требует видимости, которую фреймворк не даёт. Маршрутизация по политике, которую builder никогда не раскрывает, например отдельная таблица для одного назначения, требует правил, которые ты устанавливаешь сам.
Затраты приходят позже: разблокированный bootloader или образ с root, политика SELinux, которая съезжает под тобой, и устройство, которое перестаёт вести себя правильно после OTA. Рецепт с root ещё и нельзя распространять через обычную дистрибуцию приложений, поэтому большинство команд держат его как внутренний диагностический путь, а не как продукт.
Выбор между ними
Начинай с интерфейса, который нужен, а не с кода, который есть. Если ответ — устройство TUN с маршрутами, ограниченными приложением, путь платформы короче и переживает обновления. Если ответ включает кадры Ethernet, захват других приложений или правила маршрутизации, которые builder отклоняет, ты на территории root и должен заложить это в бюджет как операционные расходы.
Проверка, которая решает большинство споров: сначала построй путь платформы, запиши исход establish() отдельно от состояния удалённой сессии и подтверди по одному пакету в каждую сторону. Только потом решай, оправдывает ли недостающая возможность уход с поддерживаемого пути.