addDnsServer Bad Address: qué valida realmente Android
El error Bad address de addDnsServer es una comprobación local de formato, no una caída de DNS. Analiza el literal, descarta loopback y wildcards y luego prueba la accesibilidad.
El valor llegó a VpnService.Builder.addDnsServer como cadena y el builder lo rechazó ahí antes de que nada se estableciera. El rechazo es local: no se contactó ningún resolver, no hay túnel y lo que falló es una comprobación de formato, no una caída de DNS.
Importa porque una app VPN típica tiene tres entradas DNS parecidas: un valor escrito o importado en el perfil, un valor que el servidor envía durante la configuración y Private DNS a nivel de sistema. Solo las dos primeras llegan a esta llamada.
Un objeto de preparación comprueba el argumento al instante
VpnService.Builder no es el túnel. Es un objeto de preparación que reúne direcciones, rutas y servidores, y solo establish() convierte esas listas en un ParcelFileDescriptor vivo y una interfaz. La entrada DNS se comprueba al llamar a addDnsServer, no al establecer.
Se siguen dos consecuencias. La traza es corta e inútil a propósito: el valor llegó como cadena simple. Y un catch descuidado alrededor de toda la construcción deja un túnel con direcciones y rutas pero sin resolver.
El builder conserva cualquier lista que le des. Añadir un servidor que pasa la comprobación solo demuestra que la cadena se analizó como literal de dirección al construir.
Qué puede ser el argumento
El parámetro es una dirección, no un endpoint. La referencia de Builder acepta un literal IPv4 o IPv6, y el análisis es solo numérico: android.net.InetAddresses.parseNumericAddress es la cara pública de ese analizador desde API 29. Un nombre de host aquí no es una dirección lenta sino una cadena que no se analiza.
El mapeo práctico es así:
| 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 |
La versión afecta a cómo validas, no a lo que acepta el builder. InetAddresses está disponible desde API 29, así que en Android 10 y posteriores el análisis es una llamada. addDnsServer(InetAddress) es la forma original y String la adición más nueva. Por debajo de API 29, InetAddress.getByName resolverá un nombre de host con una consulta de red bloqueante y convertirá una errata en un arranque lento.
Loopback se rechaza aquí, un wildcard no
Los clientes VPN de escritorio suelen apuntar su stub resolver a 127.0.0.1:53. Copiar eso en un perfil Android importa un valor que describe la máquina local, y aquí no hay máquina local. El builder rechaza solo este caso: una dirección null o loopback lanza IllegalArgumentException.
Un wildcard es el caso opuesto, y aquí fallan la mayoría de las lecturas. 0.0.0.0 y :: no son loopback, así que el builder los acepta; no se lanza nada al construir. La interfaz sube con una entrada que no nombra ningún destino. Detectar eso es trabajo de tu validador: trata isAnyLocalAddress como entrada no válida.
Analiza una vez en el límite de configuración
La solución barata es validar donde la cadena entra en tu modelo:
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 }
}
Un resultado null se convierte en un error de campo con el texto culpable, y es la única forma sobre la que puede actuar el soporte. En la ruta de éxito el objeto va directo a builder.addDnsServer(address). Captura IllegalArgumentException concretamente, no Exception: una captura amplia también se traga errores de puntero nulo.
No sustituyas por un resolver público cuando se rechaza el valor deseado. Un perfil que cambia en silencio a un DNS público se conecta, pero los nombres internos dejan de resolverse.
Confirma la accesibilidad cuando exista la interfaz
La aceptación por el builder es el primer punto de control, no el último. Con la interfaz arriba, LinkProperties.getDnsServers() informa de lo que la red recibió realmente. adb shell dumpsys connectivity imprime las mismas propiedades desde fuera.
Private DNS es un control aparte y no viaja por este argumento. El nombre de host de un proveedor DoT pertenece al ajuste del sistema, no a un campo IP.
Antes de cambiar otra vez el analizador del perfil, trabaja en este orden: registra la cadena cruda y su origen, confirma que es un literal y no un nombre de host, que no es loopback ni wildcard, y pasa el objeto analizado.