错误的假设是:VpnService 应用拨动一个系统开关,从此手机就受到了保护。实际发生的事要狭窄得多:应用请求同意,建立一个接口,通过文件描述符读写数据包,并在停止时把接口交还。同一时间只能存在一条这样的隧道,而且系统可以不经询问就把它收回。人们归咎于协议的那些令人困惑的行为,大多来自这个所有权模型。

请把每一次生命周期转换都视为设计的一部分,而不是边缘情况。

迷思:应用会打开手机的 VPN

流程从 VpnService.prepare() 开始,它返回一个用户必须接受的 Intent,然后用 establish() 继续,而后者之后仍可能因应用无法控制的原因失败。建立第二条隧道会让第一个接口失效,并替换系统通知。在这一串流程里,应用任何一处都不拥有整台设备的开关;它拥有的是被授予的描述符,以及落到该描述符上的数据包。就连常驻通知也属于系统,而不属于应用的设计。

由于接口是被授予而非被夺取的,其他客户端随时可以占用这个位置。应用是通过回调而不是返回值得知这一点的。

强制停止应用之所以会出现在通知栏里,也是同一个道理:系统报告的是它授予了什么,而不是应用选择显示什么。

迷思:同意只需要给一次

当系统把隧道交给另一个客户端时,它会在基类从 onBind() 返回的 binder 上发送撤销事务。若应用用自己的 binder 替换它,就必须自己转发撤销事务,否则 VpnService.onRevoke() 永远不会触发,服务会继续写入一个已不存在的描述符。批准也可能被用户撤回,系统还可以在重启或常开重连期间停止该服务。

处理它是一项具体工作,而不是一行日志:停止读取描述符,关闭它,清除已连接标志,并告诉用户他处于什么状态。然后决定应用应当重新建立、再次询问还是等待,因为在常开配置下系统可能自行把服务带回来。

把撤销当成异常而不是状态的应用,会在写入的每个数据包都被丢弃时仍报告已连接,而这对隐私工具来说是最糟的失败模式。

迷思:接口会替你过滤流量

到达的是原始 IP。要捕获或过滤,就得解析 IP、TCP、UDP 和 IPv6 头部,跟踪流,并把回复写回描述符,这使捕获应用变成一个小型网络栈,而不是系统设施。protect() 的存在是为了让应用自身的传输套接字绕开自己的隧道,而不是捕获自己;它返回一个布尔值,隧道建立之后创建的每个套接字都值得检查它。

构建器里的允许与禁止应用列表决定谁的流量进入接口。它们并不授予对接口所承载的 IP 数据包之外负载的访问权限,也没有任何 API 会暴露另一个客户端的隧道,或者 列出系统的 VPN 配置文件。网络能力可以说明某个网络是 VPN 传输,但不能说明它是谁的隧道。

这就是为什么关于其他应用的状态问题会得到互相矛盾的答案:这些信息根本没有向你的进程暴露。

生命周期到底要求什么

追踪你能控制的状态:已准备、已建立、已撤销、已停止。重连应当是先拆卸再建立,描述符和传输套接字只能有一个所有者,因为两个组件争着建立时,会为唯一可用的位置打起来。现代 Android 限制了后台启动,所以开机广播并不是保证;在 API 级别允许的地方,请使用带通知的前台服务。

记录转换过程而不只是最终状态,并让状态机小到可以测试每一条路径。测试中到不了的状态,就是你在现场会没有日志地遇到的状态。

在真实设备上测试生命周期

模拟器能伪造连接,但不能令人信服地伪造同意与撤销路径,所以请在有第二个 VPN 客户端(用来抢位置)的真实设备上运行这些测试。

adb shell dumpsys connectivity | grep -i -A3 vpn
adb shell dumpsys activity services | grep -i -A2 vpnservice
adb logcat -s VpnService:V AndroidRuntime:E
adb shell am force-stop com.example.vpnapp

启动隧道,确认通知和接口,然后连接另一个客户端,观察撤销回调是否运行、状态是否被清除。强制停止应用,检查是否还有东西在报告仍存活的描述符。

在常开重启之后再重复一遍这个流程,因为那条路径与用户发起的连接不同。你要找的不是一个绿色图标,而是应用已经停止使用一个它不再拥有的描述符的证据。

相关文章