addDnsServer Bad Address : ce qu’Android vérifie réellement
Une erreur Bad address d’addDnsServer est un contrôle local de format, pas une panne DNS. Analysez le littéral, écartez loopback et wildcards, puis testez l’accessibilité.
La valeur est arrivée dans VpnService.Builder.addDnsServer sous forme de chaîne et le builder l’a refusée là, avant que quoi que ce soit soit établi. Ce refus est local : aucun resolver n’a été contacté, il n’y a pas de tunnel et ce qui a échoué est un contrôle de format, pas une panne DNS.
Cela compte car une application VPN typique a trois entrées DNS similaires : une valeur saisie ou importée dans le profil, une valeur poussée par le serveur pendant la configuration et Private DNS au niveau système. Seules les deux premières atteignent cet appel.
Un objet de préparation vérifie l’argument sur place
VpnService.Builder n’est pas le tunnel. C’est un objet de préparation qui collecte adresses, routes et serveurs, et seul establish() transforme ces listes en ParcelFileDescriptor vivant et en interface. L’entrée DNS est vérifiée lors de l’appel à addDnsServer, pas à l’établissement.
Deux conséquences suivent. La trace est courte et volontairement muette : la valeur est arrivée comme une simple chaîne. Et un catch négligent autour de toute la construction laisse un tunnel avec adresses et routes mais sans resolver.
Le builder conserve toute liste qu’on lui donne. Ajouter un serveur qui passe le contrôle prouve seulement que la chaîne a été analysée comme littéral d’adresse au moment de la construction.
Ce que l’argument peut être
Le paramètre est une adresse, pas un endpoint. La référence Builder accepte un littéral IPv4 ou IPv6, et l’analyse est purement numérique : android.net.InetAddresses.parseNumericAddress en est la face publique depuis API 29. Un nom d’hôte n’est pas ici une adresse lente mais une chaîne non analysable.
Le mappage pratique ressemble à ceci :
| 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 version compte pour votre validation, pas pour ce que le builder accepte. InetAddresses existe depuis API 29, donc sur Android 10 et plus l’analyse est un seul appel. addDnsServer(InetAddress) est la forme d’origine, String l’ajout plus récent. Sous API 29, InetAddress.getByName résoudra un nom d’hôte par une requête réseau bloquante et transformera une faute de frappe en démarrage lent.
Loopback est refusé ici, un wildcard non
Les clients VPN de bureau pointent souvent leur stub resolver vers 127.0.0.1:53. Copier cela dans un profil Android importe une valeur qui décrit la machine locale, et il n’y a pas de machine locale ici. Le builder ne refuse que ce cas : une adresse null ou loopback lève IllegalArgumentException.
Un wildcard est le cas inverse, et c’est là que la plupart des lectures se trompent. 0.0.0.0 et :: ne sont pas loopback, donc le builder les accepte ; rien n’est levé à la construction. L’interface monte alors avec une entrée qui ne nomme aucune destination. Détecter cela revient à votre validateur : traitez isAnyLocalAddress comme une entrée invalide.
Analysez une fois à la frontière de configuration
Le correctif bon marché est de valider là où la chaîne entre dans votre modèle :
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 résultat null devient une erreur de champ portant le texte fautif, et c’est la seule forme exploitable par le support. Sur le chemin du succès, l’objet va directement dans builder.addDnsServer(address). Capturez IllegalArgumentException précisément, pas Exception : une capture large avale aussi les erreurs de pointeur nul.
Ne remplacez pas par un resolver public quand la valeur voulue est refusée. Un profil qui bascule silencieusement vers un DNS public se connecte, mais les noms internes cessent d’être résolus.
Confirmez l’accessibilité une fois l’interface créée
L’acceptation par le builder est le premier point de contrôle, pas le dernier. Une fois l’interface montée, LinkProperties.getDnsServers() indique ce que le réseau a réellement reçu. adb shell dumpsys connectivity imprime les mêmes propriétés depuis l’extérieur.
Private DNS est un contrôle séparé et ne passe pas par cet argument. Le nom d’hôte d’un fournisseur DoT relève du réglage système, pas d’un champ IP.
Avant de changer à nouveau l’analyseur de profil, procédez dans cet ordre : journalisez la chaîne brute et sa source, confirmez qu’il s’agit d’un littéral et non d’un nom d’hôte, qu’elle n’est ni loopback ni wildcard, puis transmettez l’objet analysé.