Android always-on VPN: как имя конечной точки разрешается первым
Android блокирует трафик ещё до появления туннеля, но клиенту всё равно нужно разрешить имя своей конечной точки. Вот порядок и место, где возникает тупик.
Телефон перезагружается, always-on включён, «блокировать подключения без VPN» включено, а туннель так и не поднимается. Ничего не настроено неправильно: блокировка делает своё дело, и попытка самого клиента достучаться до своей конечной точки тоже ей подчинена. Имя конечной точки должно быть разрешено до того, как туннель существует, клиентом, которому разрешено говорить с сетью, пока всем остальным приложениям нет. Понимание того, какой компонент это делает и в каком порядке, превращает эту загадку в вопрос конфигурации.
Что на самом деле запрашивает конструктор VPN
Приложение Android VPN не передаёт платформе адрес сервера. Оно строит локальный интерфейс: адреса и маршруты для туннеля, резолверы, которые приложения должны использовать внутри него, а затем establish() возвращает файловый дескриптор, который приложение читает и пишет как необработанные IP-пакеты. Удалённая конечная точка в этом конструкторе вообще не появляется.
Это ключевой структурный факт. Платформа создаёт интерфейс tun и применяет к нему политику; приложение реализует транспорт в пользовательском пространстве, а значит, само разрешает имя своей конечной точки и открывает шифрованное соединение. Поэтому конечная точка в виде имени хоста возможна — не потому, что платформа поддерживает имена, а потому, что платформа никогда не видит конечную точку.
Одно поле конструктора всё же принимает адреса: DNS-серверы. Они должны быть числовыми адресами резолверов, обычно теми, которые оператор хочет опрашивать через туннель. Это поле описывает, куда приложения отправляют запросы после поднятия туннеля. Оно ничего не говорит о том, как клиент находит конечную точку, и именно смешение этих двух настроек порождает путаницу.
Два вопроса DNS, а не один
Первый вопрос: куда приложения отправляют запросы, когда туннель уже поднят? Ответ — адрес резолвера внутри туннеля, достижимый через интерфейс tun и по построению числовой.
Второй вопрос: как клиент находит свою собственную конечную точку до того, как этот туннель существует? Этот поиск происходит вне туннеля, в нижележащей сети, в процессе клиента. Он может использовать имя и обязан использовать резолвер, доступный устройству без VPN.
| Stage | Who resolves | Path | Failure looks like |
|---|---|---|---|
| Endpoint lookup | The VPN app | Underlying network, protected socket | Never connects; no tunnel interface traffic |
| Peer authentication | Client and server | Tunnel transport | Connects then drops; certificate or key error |
| Private name lookup | Apps via the tunnel | Resolver inside the tun | Tunnel up, internal sites do not resolve |
Три этапа, три разных симптома. Одно сообщение об ошибке «не удаётся подключиться» сливает их воедино, и поэтому люди начинают править поле DNS-сервера, чтобы исправить проблему, которая возникает ещё до того, как это поле вообще будет прочитано.
Почему bootstrap может зайти в тупик
Always-on с включённой блокировкой означает, что трафик, не находящийся внутри VPN, отбрасывается, включая трафик приложения, которому принадлежит VPN. Выход задан явно: приложение вызывает protect() на сокете, что привязывает его к нижележащей сети, и тот обходит туннель и получает разрешение на выход. Каждый VPN-клиент обязан делать это для своего транспорта, иначе он никогда не достигнет сервера.
Теперь рассмотрим развёртывание, в котором единственный резолвер, знающий имя конечной точки, сам находится внутри туннеля. Клиенту нужен туннель, чтобы разрешить имя, и имя, чтобы построить туннель. Это настоящая циклическая зависимость, и никакая конфигурация клиента её не решает. Это ошибка проектирования, а не баг.
Время вскрывает ту же зависимость в более мягких формах. При загрузке до первой разблокировки у устройства может ещё не быть пригодной сети; на captive portal сеть есть, но блокирует всё, кроме портала; при переходе с Wi-Fi на мобильную сеть нижележащая сеть защищённого сокета меняется, и клиент должен это заметить и разрешить имя заново. Каждое из этого — событие bootstrap, и каждое стоит проверять намеренно, а не обнаруживать в 3 часа ночи.
Почему фиксация ответа не является решением
Соблазнительный обходной путь — разрешить имя один раз и вставить полученный адрес в числовое поле или жёстко зашить его в собственную сборку. Это выглядит работающим и превращает имя в снимок. Когда оператор переносит конечную точку — миграция, изменение anycast, новый региональный адрес — клиент продолжает набирать старый, и ничто в интерфейсе не объясняет почему.
У записей DNS есть время жизни именно для того, чтобы ответы могли меняться; кэширование определяется этим сроком, а не навсегда. Фиксация ответа выбрасывает механизм, который делает имя лучше адреса, сохраняя при этом эксплуатационные издержки использования имени.
Фиксация обычно идёт рука об руку со второй дурной привычкой: ослаблением проверки подлинности, потому что соединение теперь падает иначе. Проверка подлинности сервера не входит в проблему bootstrap, и её ослабление меняет неудобство DNS на риск перехвата.
Решения, переживающие перезагрузку
Долговечное решение неприглядно. Используйте клиент, который корректно реализует защищённые сокеты и разрешает свою конечную точку в нижележащей сети, и дайте ему имя конечной точки, разрешаемое публично или через резолвер, доступный до туннеля. Оставьте блокировку включённой; полное её отключение ради работы DNS убирает свойство, ради которого существует развёртывание.
Там, где конечная точка должна оставаться приватной, обеспечьте явный путь bootstrap, которым может воспользоваться платформа — резолвер, доступный без туннеля, или управляющее соединение, поддерживаемое оператором, — и держите это исключение узким и принадлежащим политике, а не пользовательскому переключателю. Документация Android VpnService — это справка о том, что платформа гарантирует в отношении защиты и жизненного цикла, а справка по VpnService.Builder подтверждает, что поле DNS принимает числовые адреса.
Затем проверьте три этапа по порядку на новом соединении после перезагрузки: имя конечной точки разрешается, туннель устанавливается и партнёр аутентифицируется, а приватное имя разрешается через резолвер туннеля. Если первый этап не проходит, ничего последующего работать не может, и исправление относится к пути bootstrap — никогда к полю резолвера.
- Разрешение конечной точки происходит в приложении, вне туннеля, на защищённом сокете.
- Поле DNS в конструкторе предназначено для приложений внутри туннеля, а не для собственной конечной точки клиента.
- Резолвер, доступный только через туннель, — это тупик, а не медленный старт.
- Проверяйте конечную точку, аутентификацию и приватный поиск отдельно после перезагрузки.