值以字符串形式到达 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 起该解析器的公开入口。主机名在这里不是慢地址,而是无法解析的字符串。

实际对应关系如下:

InputResultReason
10.8.0.1acceptedIPv4 literal
2606:4700:4700::1111acceptedIPv6 literal
dns.example.comrejectedhostname; numeric parsing does not resolve it
10.8.0.1:53rejectedport belongs to your own config model
https://dns.example.com/dns-queryrejectedDoH URL, a different configuration path
127.0.0.1rejectedloopback is not a remote resolver
0.0.0.0 or ::accepted by the builderparses 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,传递解析后的对象而不是重新解析文本,然后再把已建立的接口与预期值对比。

相关文章