Android VpnService 允许列表:为什么已放行的页面仍然转圈
允许列表本身大概率没问题。页面卡住的原因在 DNS 应答、路由表、解析器 socket 或 MTU。下面按顺序检查。
你把一个域名加进了允许列表,查询确实到达了你的应用,匹配器也返回了允许,可页面就是打不开。到这一步,多数人开始重写匹配器、添加通配符比较。以我的经验,匹配器通常是对的,故障其实晚了一层:在你合成的 DNS 应答里,在你安装的路由里,或者在你用来转发查询的 socket 里。列表是最容易的部分;允许列表真正出问题的地方在数据包的路径上。
匹配器很少是元凶
首先要区分“被拦截”和“被弄坏”。被拦截的域名应该快速、可见地失败,因为浏览器收到“该域名不存在”就会停下。被弄坏的域名则会挂起,改走 IPv6 重试,再换一种传输方式,最后只剩下一个转圈。看到转圈,基本说明你的允许列表回了某种客户端无法处理的东西。
空的 A 记录是典型错误。返回 NOERROR 却零条回答,看起来像合法响应,stub 解析器照单全收,浏览器重试、试 AAAA、换解析器,很久之后才放弃。而返回响应码 3(即 NXDOMAIN),并把问题段回显、事务 ID 抄回,会立刻结束这次对话。凡是你有意拒绝的域名,都用它。
IPv6 也应得到同样的决定,而不是默认放过。如果你对 A 回 NXDOMAIN 却放行 AAAA,客户端就能通过 IPv6 访问被拦截的域名,过滤器会漏。某些实现会对被拦截的 AAAA 查询回一个 loopback 地址,让连接立刻失败,而不是回退到别的路径。选定一种策略,对两种记录都执行,两种都记日志。
捕获全部的路由就拥有全部
只做 DNS 的方案从不会添加 addRoute("0.0.0.0", 0),因此只有查询进入 TUN 接口,其余流量继续走底层网络。这是安全形态,也解释了为什么有些允许列表在 Wi-Fi 下看着能用、在蜂窝网络下就失效。
一旦存在默认路由,每条流就都归你管。到达接口却既不转发也不应答的 TCP 包会一直排队,直到客户端超时,而且没有任何错误可记,因为根本没出错。我见过有人为一份坏掉的允许列表耗掉一下午,真正原因却是 443 端口少了转发器。凡是你决定丢弃的包,都要记录协议和目的地址。
解析器 socket 必须离开隧道
转发是下一个陷阱所在。运营商的解析器常位于隧道无法到达的私有地址上,你转发的查询就石沉大海,应用只报一个读超时。把已批准的查询发给你确认可达的解析器,并先把那个 socket 交给 VpnService.protect()。未受保护的 socket 会把查询送回你自己的隧道,于是它再次到达应用并反复循环,直到超时触发。
这种故障看起来和断网一模一样,所以它在缺陷报告里能存活很久。给已发查询和已收应答各留一个计数器:如果前者持续上涨、后者始终为零,你该看的是 socket,不是列表。
长度和校验和失败时没有提示
静默丢弃也来自长度。回答集很大的 DNS 应答,或带一堆选项的 TCP 握手,可能超过你从未显式设置过的路径 MTU;分片在上游消失,客户端要么用更小尺寸重试,要么干脆不再重试。把接口 MTU 设成 1280 是保守选择,能扛住大多数蜂窝路径;显式设定,总好过继承一个没人测过的默认值。
如果你自己构造数据包,校验和是另一个静默杀手。UDP 校验和若在伪首部里漏掉 UDP 长度或协议字段,算出来的包在你这边看着规整,到了接收端却被无声丢弃。把伪首部逐字段对照规范核对,调试时把某条已知流的计算值打印出来。
VpnService 不会替你做的事
有两处限制值得在设计之前知道。普通应用无法在 Wi-Fi 之外安装系统级代理或 PAC 文件,这部分没有变化。变化的是按应用划分的路径:API 29 之前,VpnService.Builder 根本不能携带代理;从 API 29 起,setHttpProxy(ProxyInfo.buildPacProxy(uri)) 会把一段 PAC 脚本交给平台,由平台为 VPN 网络求值。面向旧 API,或 PAC 模型无法表达规则时,请在自家代码里求值。UDP 也不是白送的:浏览器经 SOCKS 代理只发 TCP,而多数 SOCKS5 客户端从不实现 UDP ASSOCIATE,于是基于代理的方案在 TCP 看似健康时丢掉或泄漏 UDP。
第二条限制正是基于 UDP 的协议能在普通网络上工作、却在 VPN 连上的那一刻失效的原因。若指望隧道承载 UDP,转发器就必须为它实现转发路径,而不是只换一种 socket 类型走 TCP。
按这个顺序检查
照顺序走,遇到第一个失败的环节就停下,因为在前面那一环站住之前,后面的检查都只是噪音。
- 列出已安装的路由,确认非 DNS 的数据包到底有没有进入 TUN 接口。
- 对一个被拦截的域名和一个已批准的域名,记录查询名、判定结果和响应码。
- 确认解析器 socket 已受保护,且所选解析器能从隧道内部应答。
- 确认接口 MTU,以及你手工构造的某个应答的校验和。
- 在 Wi-Fi 和蜂窝网络下重复同一个页面,因为运营商解析器和路径 MTU 并不相同。
五项都站得住、页面仍然卡住,下一个怀疑对象就是转发器本身:把抓到的请求和抓到的应答对比,找缺失的字节计数或半关闭的 socket。VpnService.Builder 参考文档覆盖了路由、DNS 和 MTU 的约定,而 protect() 是独立的 VpnService 调用,不是 builder 的选项;接口已连接只说明第一条约定满足了。