addDnsServer Bad Address: что Android на самом деле проверяет
Ошибка Bad address от addDnsServer это локальная проверка формата, а не сбой DNS. Разберите литерал, уберите loopback и wildcard, затем проверьте доступность.
Значение пришло в VpnService.Builder.addDnsServer как строка, и builder отверг его там, до того как что-либо было установлено. Этот отказ локален: ни один resolver не вызывался, туннеля нет, и провалилась проверка формата, а не DNS.
Это важно, потому что у типичного VPN-приложения три похожих входа DNS: значение в профиле, значение от сервера и Private DNS на уровне системы. До этого вызова доходят только первые два.
Объект подготовки проверяет аргумент сразу
VpnService.Builder это не туннель. Это объект подготовки, собирающий адреса, маршруты и серверы, и только establish() превращает эти списки в живой ParcelFileDescriptor и интерфейс. Запись DNS проверяется при вызове addDnsServer.
Два следствия: стек короткий и намеренно неинформативный, а небрежный catch оставляет туннель без resolver.
Builder сохраняет любой список; добавление сервера, прошедшего проверку, доказывает лишь то, что строка разобрана как литерал адреса при сборке.
Чем может быть аргумент
Параметр это адрес, а не endpoint. Справка Builder принимает литерал IPv4 или IPv6, а разбор только числовой: android.net.InetAddresses.parseNumericAddress это публичное лицо этого парсера с API 29.
Практическое соответствие выглядит так:
| Input | Result | Reason |
|---|---|---|
10.8.0.1 | accepted | IPv4 literal |
2606:4700:4700::1111 | accepted | IPv6 literal |
dns.example.com | rejected | hostname; numeric parsing does not resolve it |
10.8.0.1:53 | rejected | port belongs to your own config model |
https://dns.example.com/dns-query | rejected | DoH URL, a different configuration path |
127.0.0.1 | rejected | loopback is not a remote resolver |
0.0.0.0 or :: | accepted by the builder | parses fine, but names no destination; only your own validator rejects it |
Версия влияет на то, как вы проверяете, а не на то, что принимает builder. InetAddresses доступен с API 29, поэтому на Android 10 разбор это один вызов; addDnsServer(InetAddress) исходная форма, String более новое дополнение. Ниже API 29 InetAddress.getByName разрешит имя хоста блокирующим сетевым запросом.
Loopback здесь отвергается, wildcard нет
Клиенты VPN на десктопе часто направляют свой stub resolver на 127.0.0.1:53; копирование этого в профиль Android вносит значение, описывающее локальную машину, а локальной машины здесь нет. Builder отвергает только этот случай: адрес null или loopback бросает IllegalArgumentException.
Wildcard это обратный случай, и именно здесь большинство прочтений ошибаются. 0.0.0.0 и :: не loopback, поэтому builder их принимает; при сборке ничего не бросается. Интерфейс затем поднимается с записью, не называющей пункт назначения. Поймать это задача вашего валидатора: считайте isAnyLocalAddress недопустимым вводом.
Разбирайте один раз на границе конфигурации
Дешёвое решение проверять там, где строка попадает в вашу модель:
import android.net.InetAddresses
import java.net.InetAddress
private fun dnsLiteralOrNull(raw: String?): InetAddress? {
val text = raw?.trim().orEmpty()
if (text.isEmpty()) return null
val address = try {
InetAddresses.parseNumericAddress(text)
} catch (e: IllegalArgumentException) {
return null
}
return address.takeUnless { it.isAnyLocalAddress || it.isLoopbackAddress }
}
Результат null становится ошибкой уровня поля с виновным текстом, и это единственная форма, с которой может работать поддержка. На успешном пути объект идёт прямо в builder.addDnsServer(address), а IllegalArgumentException ловите точно, не Exception.
Не заменяйте публичным resolver, когда нужное значение отвергнуто; профиль, тихо подставляющий публичный DNS, подключается, но внутренние имена перестают разрешаться.
Подтвердите доступность после создания интерфейса
Принятие builder это первая контрольная точка, а не последняя. После подъёма интерфейса LinkProperties.getDnsServers() показывает, что сеть получила на самом деле, а adb shell dumpsys connectivity печатает те же свойства снаружи.
Private DNS это отдельное управление, и оно не идёт через этот аргумент; имя хоста DoT-провайдера относится к системной настройке, а не к полю IP.
Прежде чем снова менять парсер профиля, запишите сырую строку и её источник, подтвердите, что это литерал, а не имя хоста, что это не loopback и не wildcard, и передайте разобранный объект.