为什么 Android VpnService 只捕获部分流量,而不是全部
路由、DNS 条目和按应用的范围共同决定哪些数据包能到达 tun 设备、本地设备为什么会消失,以及构建器拒绝什么。
VpnService 本身并不拦截流量。它向系统请求一个 tun 接口,把一组地址和路由交给这个接口,从那一刻起,内核就把与这些路由匹配的每个数据包复制到应用可读的文件描述符中。应用代码永远看不到路由没有选中的数据包,所以隧道范围首先是路由表问题,然后才是编程问题。正因如此,同一套服务实现在一台手机上可以吞下所有流,在另一台上只吞下一部分;也正因如此,添加一条路由有时能修好看似损坏的隧道。你得到的正是你声明的内容,再减去构建器默默拒绝的一切。
路由决定哪些数据包到达
Builder.addRoute(prefix, prefixLength) 把一个前缀安装进 VPN 网络的路由表。系统把该网络当作范围内应用的默认网络,因此它的条目会先于物理网络的默认路由被查询,一个匹配的前缀就足以把某条流引走。所以 0.0.0.0/0 的路由会完整接管 IPv4,包括打印机、cast 目标和路由器管理页面。
想要这种覆盖又不想删除物理默认条目的客户端,改用 0.0.0.0/1 和 128.0.0.0/1 这一对。两条更具体的路由会胜过一条默认路由,合起来覆盖整个地址空间,而手机自己的默认条目仍留在表中,供那些仍绑定物理网络的套接字使用。
地址和 DNS 条目来自同一个构建器,回答的是另一个问题。addAddress 给接口一个带前缀长度的地址,addDnsServer 安装一个解析器,范围内的应用会通过接口向它查询。两个地址族可以同时出现在一个接口上,所以只有 IPv4 的路由集合配上 IPv6 地址,是一种读起来正确、却仍然泄漏的配置。如果你为接口没有地址的族设置路由,内核就没有这些数据包的源地址,它们永远不会出现在描述符上。
establish() 交回什么
establish() 返回一个 ParcelFileDescriptor,应用未获授权时返回 null。null 是一种授权同意状态,而不是网络错误:VpnService.prepare() 必须已经返回 null,或者用户必须在调用前批准系统对话框。同意可以在服务运行期间被撤销,接口会在你脚下关闭,而不会回调你的读取循环。
除非另行说明,描述符是非阻塞的,所以一次读取会立刻返回而没有数据,而不是把线程挂起。为阻塞套接字写的循环会烧掉一个核,而把空读取当成致命错误的循环会在一个空闲的秒里撕开隧道。Builder.setBlocking(true) 会改变这种行为,代价是一个在读取里等待的线程。
路由和地址参数会作为 LinkAddress 值校验,224.0.0.0/4 这类多播目标则直接被拒绝。点对点 tun 接口也没有广播域,所以通过它做多播发现需要另一种设计,而不是再加一条路由。要核对的清单是 VpnService.Builder,在断定某次调用被忽略之前。
按应用的范围收窄的是集合,不是隧道
从 API 21 起,构建器接受 addAllowedApplication 和 addDisallowedApplication,两个列表在同一个构建器里互斥。允许列表让隧道只为这些包存在,其余一切仍走物理网络;禁止列表则捕获除指定包之外的一切,这就是客户端提供的分流隧道。API 21 之前根本没有平台机制,按应用的行为来自第三方客户端在自己的转发器里按 UID 过滤。这个区别在调试时很重要,因为一个只允许一个包的应用级隧道几乎不产生流量,而那是正确行为,不是坏掉的路由。
无论范围如何,应用向 VPN 服务器打开的套接字都不能穿过它正在建立的隧道。VpnService.protect(socket) 把这个套接字绑定到底层网络上,让它的数据包跳过 tun 设备。省掉这次调用,握手就会被本应把它立起来的那个接口吞掉。
VpnService.setUnderlyingNetworks 告诉系统哪条物理网络承载隧道,从而闭合环路。这个值决定 VPN 如何向其他应用报告连通性,而 null 或过期的值是“连上的隧道被手机其余部分当成离线”的常见原因。要在底层网络变化时更新它,而不只是启动时更新。
校验和与 MTU:正确路由仍然失败的地方
数据包到达之后,包层的两个细节决定端到端是否可用。第一个是源地址:有多个接口地址时,内核可能发出携带第一个地址的包,所以重写头部的转发器必须修正地址和覆盖它的校验和。
第二个是校验和的范围。当应用自己构造 TCP 或 UDP 包时,校验和覆盖一个伪首部,其中含源地址、目的地址、协议号和传输长度,漏掉其中任何一个都会得到到达后被丢弃的包。这种失败看起来像在等一个永不到来的回复之后超时,于是大多数人又回去查路由。setMtu 固定接口 MTU,每次读取正好返回一个完整 IP 包,所以按字节流写的缓冲区会把你的解析撕成碎片,而不是把包撕开。
把层次分开的一轮验证
要找出范围在哪里出错,就按顺序测试各层。先问设备 VPN 网络实际持有哪些路由,再检查发往已知地址的流是否表现为描述符上的一次读取,然后检查转发器发出的包是否格式良好。路由问题在描述符上什么都不显示,而校验和问题会显示一个到达后消失的包。
如果什么也没到达描述符,嫌疑就在路由集合而不是读取方:确认前缀,确认同一族里有地址,确认应用在你设定的范围内。如果包到达了描述符却没有东西回来,在再次改动路由之前,先检查 protect() 和底层网络的值。如果远端报告包格式错误,校验和与 MTU 就是该看的地方。每层一次探测,能省下半天瞎猜。