Android VpnService 的分流隧道:实际能路由什么
VpnService 只转发 builder 交给其描述符的报文。先核对前缀、代理认证、DNS 与按应用列表,再归咎于分流隧道。
VpnService 应用里的分流隧道会在少数几个具体位置断掉,每处的修法都不同。客户端只显示一个失败的地址,所以第一件事是弄清撞到的是哪条边界:builder 收到的路由、无法完成认证的代理、只做了通告的解析器,还是平台按用户生效的应用列表。正因如此,我保留一套四步核查。它按下文顺序执行,并在第一个失败处停下。
确认 builder 交给了平台什么
VpnService.Builder 是一张声明清单。establish() 返回虚拟接口的 ParcelFileDescriptor,平台只把与 addAddress、addRoute 前缀匹配的流量送进该描述符;addDnsServer 则告诉客户端该向哪个解析器查询。落在你添加的所有前缀之外的地址永远到不了你的应用,界面上怎么写都不算数。仅凭这一条,就能解释大部分分流隧道故障报告,而无需先改代码。
把 builder 组装的地址、路由和 DNS 清单打进日志,再与失败的地址对照。管理主机少了一条 /32,远比隧道损坏常见。清单正确就继续,错误则后面的检查全是白费力气。
excludeRoute 从 API 33 才有
按目的地址排除是最容易演变成支持工单的诉求。builder.excludeRoute(IpPrefix) 在 API 33 才出现;在此之前唯一受支持的近似做法是为除被排除前缀之外的一切添加路由,也就是自行计算补集前缀。两个调用并不互为镜像:一个从接口路由集合中减去,另一个从零构建集合;旧设备遇到不认识的调用会抛异常或静默忽略。按 Build.VERSION.SDK_INT 分支,并在真机上验证两条路径。
计算补集时要注意前缀对齐。/16 路由内排除 /24,需要真正描述其余部分的多条覆盖前缀,而不是一个形状错误的 /8。记录最终清单,并确认被排除主机仍能经隧道外的路径解析。
代理凭据无法表达
setHttpProxy(ProxyInfo) 把接口指向 HTTP 或 SOCKS 代理,PAC 地址还能按目的地址在两者间选择。ProxyInfo 与 PAC 格式都不携带用户名和密码,因此需要认证的代理落在平台 API 之外。有这类需求的应用通常内嵌用户态协议栈、tun2socks 或类似引擎,自行应答 407 挑战。
这个选择有代价。用户态协议栈只能看到你路由给它的流量,所以它必须活在同一个描述符边界内,并且自身向上游的套接字同样要调用 protect()。漏掉 protect(),隧道就会抓到自己的传输,表现为第一个报文之后握手停滞。请用要求凭据的代理来测试,因为开放代理在平台路径上照样通过,什么也证明不了。
按应用列表和 DNS 同样是声明
Builder 同时提供 addAllowedApplication 与 addDisallowedApplication,而两者互斥:同时调用会抛异常。无论哪种,列表都在建立接口时按用户生效,所以工作资料里的应用归工作资料的隧道管,而不是你的。没有任何 API 能让你控制另一个应用的决定:你无法从外部把某个包塞进自己的隧道,也无法关闭或重新配置已生效的 VPN。
DNS 遵循同样的模式。addDnsServer 只是通告解析器地址,并不能让你的应用应答查询。要自己解析域名,就跑一个解析器并把它的地址路由进隧道。使用 DoT 或 DoH 直连固定主机名的应用可以绕过你通告的地址,所以除非也拦截查询路径,否则只能把域名解析当作尽力而为。
清单、常开 VPN 与四步测试
资格要在清单里声明:BIND_VPN_SERVICE、android.net.VpnService 的 intent 过滤器,以及隧道需要在后台存活时的前台服务。常开 VPN 还要求干净的 onRevoke,因为用户改设置或别的应用接管时系统会调用它。
按顺序执行核查,遇到第一个失败就停。第一,输出交给 builder 的地址、路由和 DNS 清单。第二,确认 establish() 返回了描述符,且传输套接字已受保护。第三,分别请求一个包含前缀内的地址和一个被排除前缀内的地址。第四,查询一个只有你的解析器能解析的主机名,看究竟是哪个解析器应答的。
握手完成并不能证明后面任何一步。每项核查只隔离一层,所以第二步通过,对第四步毫无说明。
VpnService.Builder 参考文档列出了 builder 的全部接口。超出它的需求,要么是另一个 API、另一个应用,要么是你自己的用户态协议栈;那是设计决定,不是分流隧道的缺陷。