Обнаружение VPN на Android: почему tun0 и ping вводят в заблуждение
Имя интерфейса — это соглашение; ping доказывает, что один хост был доступен. Спросите NetworkCapabilities, что заявляет сеть, а затем реагируйте на обратные вызовы.
Проверка, которую большинство приложений Android используют для обнаружения VPN, — это имя интерфейса, и она никогда не была проверкой VPN. Она сообщает, что существует некий интерфейс со строкой, которую ожидал разработчик, что на одном устройстве означает OpenVPN, а на другом — нечто совершенно не связанное. ping — такое же доказательство: он доказывает, что один адресат ответил, а не то, что туннель перенёс пакет. То, что платформа действительно предоставляет, — это набор сетей и транспорты, которые каждая из них заявляет, и это другой вопрос с другим ответом.
Миф: если существует tun0, VPN включён
VPN-приложения для Android — это реализации VpnService, которые передают системе дескриптор файла tun. Имя интерфейса происходит от того, кто его создал: OpenVPN обычно создаёт tun0, PPTP и L2TP создают ppp0, WireGuard называет интерфейс по туннелю, а туннель IPsec может вообще не иметь обычного имени. Поэтому чтение /proc/net/dev или перечисление объектов NetworkInterface ищет соглашение, а не факт.
Оба направления ошибки живы. На устройстве может быть tun0 для локального туннеля, не имеющего отношения к VPN пользователя, а совершенно здоровый туннель WireGuard может быть невидим для проверки, которая знает только строку tun0. Ложные срабатывания и пропуски в одной функции делают её непригодной как диагностику, потому что обращению в поддержку с текстом «VPN не обнаружен» нельзя доверять ни в одну сторону.
Доступ тоже сузился. Новые версии Android ограничивают, что обычное приложение может читать из /proc, поэтому проверка по имени, работавшая на Android 8, может вернуть пустой список на более позднем устройстве без каких-либо изменений в коде. Когда проверка тихо отказывает в открытую сторону, приложение делает вывод, что на оборудовании, где туннель в порядке, VPN нет.
Миф: успешный ping означает, что туннель передаёт трафик
ping до внутреннего хоста успешен всякий раз, когда существует маршрут до этого хоста. При раздельном туннеле хост может быть доступен по Wi-Fi, пока туннель включён и занят совсем другим. При VPN для отдельных приложений приложение, выполняющее ping, может быть исключено из туннеля, поэтому эхо ICMP уходит за его пределы и возвращается, что доказывает обратное тому, что предполагает проверка.
ICMP и TCP также фильтруются отдельно. Туннель, который разрешает эхо-запросы, но отбрасывает TCP-порт, нужный службе, пройдёт тест ping и потерпит неудачу в приложении, и это правило брандмауэра на другой стороне, а не свойство устройства. Единственное честное прочтение успешного ping — что этот адресат ответил на этот протокол в этот момент.
Что платформа действительно сообщает
Начиная с API 21, ConnectivityManager.getAllNetworks() возвращает каждую сеть, известную системе, а getNetworkCapabilities(network) сообщает, что заявляет эта сеть. VPN-сеть несёт TRANSPORT_VPN. Перебор всех сетей отвечает на один вопрос: есть ли сейчас на этом устройстве какой-либо активный VPN?
Второй, более узкий вопрос требует другого вызова. getActiveNetwork() возвращает сеть, которую трафик приложения использовал бы прямо сейчас, и проверка той же возможности на ней отвечает: пойдёт ли мой трафик через VPN? Оба могут расходиться, и это расхождение информативно. VPN, который включён, но не является текущим, указывает на маршрутизацию для отдельных приложений или конфигурацию раздельного туннеля, а не на отсутствующий туннель.
Network active = cm.getActiveNetwork();
NetworkCapabilities caps = cm.getNetworkCapabilities(active);
boolean viaVpn = caps != null
&& caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN);
Защищайте оба вызова. Возможности могут быть null для сети, исчезнувшей между перечислением и запросом, а активная сеть может быть null во время передачи. NET_CAPABILITY_NOT_VPN полезна в обратную сторону — чтобы подтвердить, что сеть точно не является туннелем, а не просто у неё отсутствует флаг транспорта VPN.
NetworkInfo был тем же мифом с API
ConnectivityManager.TYPE_VPN с NetworkInfo — это второе поколение этой проверки, и оно устарело не зря. В современных версиях объект, возвращаемый для VPN, может быть null, пока туннель подключён, поэтому код, который трактует null как «нет VPN», тихо сообщает ложный отрицательный результат на устройствах, где ответ важнее всего.
Сопровождающая его трансляция тоже не лучше. CONNECTIVITY_CHANGE имеет документированную историю пропусков переходов VPN, включая сборки, где она срабатывала только при подключении или отключении VPN, а не при каждом изменении сети. Слушатель, построенный на ней, создаёт переменную состояния, которая уходит от видения платформы до следующего полного переподключения.
Реагируйте на обратные вызовы вместо опроса
Зарегистрируйте NetworkRequest, который требует TRANSPORT_VPN и удаляет NET_CAPABILITY_NOT_VPN, затем вызовите registerNetworkCallback с этим запросом. Тогда onAvailable и onLost сообщат, когда объявление меняется, и это и есть событие, которое вас действительно интересует. Отмените регистрацию обратного вызова, когда компонент останавливается, иначе приложение держит ссылку на мёртвый экран, а обратный вызов продолжает срабатывать.
NetworkRequest req = new NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_VPN)
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build();
cm.registerNetworkCallback(req, callback);
Это требует ACCESS_NETWORK_STATE — обычного разрешения: без root, без диалога согласия пользователя, без разрешения VPN. Опрос по таймеру — альтернатива, к которой прибегают люди, и он расходует батарею, при этом всё равно пропуская переход короче интервала, а именно такой переход пользователь и описывает.
Чего ответ всё ещё не может вам сказать
Ни один из этих API не сообщает, здоров ли туннель, на каком сервере он завершается и находится ли трафик определённого приложения внутри него. VPN-сеть может существовать без пригодных маршрутов, а туннель для отдельных приложений может исключить приложение, задающее вопрос.
Поэтому записывайте то, что увидела проверка, а не только логическое значение. Записывайте идентификатор сети, заявленные транспорты и то, была ли эта сеть активной. Отчёт, в котором сказано «VPN не обнаружен», непригоден, а отчёт, в котором сказано, что сеть заявила TRANSPORT_VPN, но getActiveNetwork() вернул Wi-Fi, указывает прямо на маршрутизацию или на список исключений, и это исправление, над которым кто-то может работать.