addDnsServer Bad Address:Android 究竟校验了什么
addDnsServer 抛出的 Bad address 是本地格式校验,而不是 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 配置里,就引入了一个描述本机的值,而这里没有本机可指:隧道的 resolver 必须在接口另一侧可达。这是 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 的配置仍能连上,但内部域名不再解析,每个查询都走用户没有选择的 resolver。可见的错误只花一次支持回复,静默替换花掉的是用户对隧道守住查询的信任。
接口存在后确认可达性
builder 接受只是第一个检查点,不是最后一个。接口起来后,LinkProperties.getDnsServers() 报告网络实际收到了什么,不匹配就在这里显现:空列表、条目比添加的少,或者路由不承载的地址族。从应用外部查看时,adb shell dumpsys connectivity 打印同样的按网络属性。
Private DNS 是独立控制,不经过这个参数。DoT 提供商的主机名属于系统设置,或你自己的加密解析路径;它不是该粘到 IP 字段里的值,修正 Private DNS 设置也不会清除 builder 抛出的异常。
在再次改动配置解析器之前,按这个顺序做:记录原始字符串及其来源,确认它是字面量而不是主机名或 URL,确认它既不是 loopback 也不是 wildcard,传递解析后的对象而不是重新解析文本,然后再把已建立的接口与预期值对比。