VPN 已经连上,笔记本能通过 IPv6 打开私有 Web 界面,Android 手机却仍然用 IPv4 打开同一个页面,或者干脆加载失败。AAAA 记录是存在的,隧道也报出了 IPv6 地址,客户端看起来没有问题。结果由四个彼此独立的决定造成:哪个解析器作出了回答、VpnService 向 Android 声明了什么、浏览器愿不愿意尝试哪个地址族、以及数据包能否完成往返。按这个顺序逐层排查,而不是先加一条默认路由再指望症状自己挪位置。

第 1 步:证明是哪个解析器回答了 AAAA 查询

Android 的 DNS 路径并不唯一。VPN 可以为自己的网络推送 DNS 服务器,系统可能对符合条件的主机名使用 Private DNS,而 Chrome 还能从 chrome://settings/security 运行自己的加密解析器。在一台手机上,隧道推送了 10.61.0.1,但 Private DNS 指向公共服务商,此时私有区域里的名字并不是由持有该区域的机器解析的。浏览器从一个对该名字没有权威的解析器拿到答案,于是把它当成不存在,而错误提示里根本不会提到 DNS。

在改动任何其它东西之前,直接问隧道的解析器。前两条命令显示 Android 给当前网络挂了哪些 DNS 服务器、Private DNS 处于自动还是严格模式;第三条要在隧道内的任意客户端上执行,而不是在手机上。

adb shell dumpsys connectivity | grep -A3 "DnsAddresses"
adb shell settings get global private_dns_mode
nslookup -type=AAAA nas.lan 10.61.0.1

第 2 步:读一读 VpnService 到底声明了什么

Android 这一侧是显式声明,而不是推断出来的。VpnService.Builder 用 addAddress 添加地址、用 addRoute 添加路由、用 addDnsServer 添加 DNS 服务器,还能用 allowFamily 把整条隧道限制在单一地址族。一个先调用 allowFamily(AF_INET)、之后又添加 IPv6 路由的构建器,等于对系统说了两句互相矛盾的话,而系统保留的是地址族限制。覆盖 fd00:61::/64 的路由配上另一个前缀里的接口地址同样会失败:地址存在,但它对那条路由不是 on-link 的。

要读声明,而不是相信应用里的开关。dumpsys netd 会打印绑在 tun 接口上的地址和路由,dumpsys connectivity 里按网络列出的 LinkProperties 会在网络声称的地址族旁边显示 DNS 列表。

adb shell ip -6 addr show tun0
adb shell ip -6 route show table all | grep tun0
adb shell dumpsys connectivity | grep -E "LinkAddresses|Routes"

如果 tun 接口有 IPv6 地址,但没有任何路由匹配服务前缀,那么浏览器还没登场,IPv6 的投递就已经断了。这和浏览器拒绝尝试是两回事,需要另一种修法。

第 3 步:是否尝试 IPv6 由浏览器决定

Chromium 并不会用上收到的每一条 AAAA 记录。它的主机解析器带有 IPv6 可达性检查和候选筛选:当网络被判为无法承载时,这些逻辑会丢弃或降低 IPv6 的优先级,解析器源码把这项探测和它周围的条件摆在一起。逻辑取决于版本和开关,这也正是同一条隧道上两台手机表现不同的原因。候选出现之后,Happy Eyeballs 会为每个连接让两个地址族赛跑,所以响应慢的 IPv6 路径仍然可能输给响应快的 IPv4。

去观察这个决定,而不是猜。用 chrome://net-export 录一段会话、在 netlog 查看器里回放,能看到每次解析和最终选中的地址族;chrome://net-internals/#dns 显示缓存结果。如果某个名字在你自己的解析器上有 AAAA,解析却只返回 A 记录,那就直接指向这个环节。

第 4 步:确认数据包能完成往返

选地址不等于能送达。Android 为每个网络维护路由表和策略规则,按 fwmark 选择表,所以主表里看得见的路由,未必就是套接字实际使用的那条。读规则、再让内核为某个源地址解析目的地,才能把这两件事分开。

adb shell ip -6 rule show
adb shell ip -6 route get fd00:61::20 from fd00:61::10

真正回答问题的是 route get 的输出。如果它指向 tun0,而数据包依然不通,就该去看隧道的 MTU 和网关的 IPv6 转发,而不是回头折腾手机。

为什么加 ::/0 通常只会更糟

加上 ::/0 等于把隧道的主张从一个前缀扩大到所有 IPv6 目的地。在手机上这确实能让可达性探测通过,因为你前缀之外的测试地址看起来变得可路由了。但它并不会给网关创造 IPv6 上行容量,而很多家用网关根本没有可用的 IPv6 上行。结果是公网 IPv6 的黑洞:本来会回落到 IPv4 的名字现在直接挂住,私有页面看起来变好,只是因为探测测的是别的东西。

还有策略代价。隧道里放了 ::/0,所有 IPv6 流量都会从 VPN 出去,包括网络所有者从未同意承载的流量。这应该当成有明确负责人的部署决定,而不是凌晨 1 点随手做的调试步骤。

真正能收尾的工作顺序

先确认 AAAA 的答案来自持有该区域的解析器,再读 tun 接口的 VpnService 和 netd 状态,抓一份 net-export 轨迹看 Chromium 是否丢掉了候选,最后用 ip -6 route get 测往返。修真正失败的那一层,别去动分流策略。私有名字需要和公网名字不同的答案,那正是 split tunneling 的用途。

记下手机版本、浏览器版本,以及你查询的那个解析器。这三件事能把“IPv6 坏了”变成下一个人可以复现的东西。

相关文章