关于 Android VpnService 的四条假设曾经让我搭进去大半个星期,而它们没有一条和协议本身有关。应用说已连接,状态栏里躺着钥匙图标,流量却仍旧走普通路由出去。我一直以为隧道是我的,直到我把它停掉;以为我加的路由就是生效的路由;以为调用 stopSelf 会把接口一起带下去。服务的行为完全符合文档。坏掉的是我心里的模型。

误区 1:隧道会一直存在,直到你把它停掉

Android 同一时间只持有一条 VPN 接口。再建一条,平台不会排队,而是停用你那条,并在你的服务上调用 onRevoke()。如果你的代码只在按下按钮时才拆除,那么一次接管会在身后留下一个描述符和一个读取线程,下一次启动会把两者一并继承下来。

VpnService 参考文档 明确写道:应用已有同意时 prepare() 返回 null,用户仍需批准时返回 Intent。这份同意只有通过 startActivityForResult() 交付 intent 才会落地。跳过这一步,establish() 就返回 null,读起来像静默失败,而不是权限问题。

Always-on VPN 会引入一个你控制不了的拥有者。系统可以在开机或崩溃之后把隧道拉回来,也会抢在用户手动启动的 VPN 应用前面。把 onRevoke() 写成和常规关闭走同一套资源释放,并把它当作主要的停止路径,而不是边角情况。用户会换 VPN 应用,你的代码必须扛得住。

误区 2:addRoute 抓到的正好是你列出的那些

路由决定哪些数据包能到达你的描述符,范围写错就会出现那种令人抓狂的情况:一部分流量走了隧道,另一部分没走。addRoute("0.0.0.0", 0) 收下全部 IPv4,意味着每个数据包现在都送到你这里,必须转发或者丢弃。给单个主机的 /32 几乎什么都不收,其余一切表现得就像你的 VPN 从未安装过。

两个地址族分开计算。只写 IPv4 的路由把 IPv6 留在底层网络上,于是双栈主机在隧道内看起来连接完好,实际上仍能在隧道外解析并连接。

DNS 遵循同样的逻辑。用 addDnsServer() 添加的服务器只会到达使用隧道解析器的应用;而自带加密 DNS 端点、或把 Private DNS 设成主机名的应用会完全无视它们。当某个应用解析失败、另一个却正常时,先检查解析器路径,再动哪怕一条路由。

误区 3:按应用的规则是一个能两头拨的开关

addDisallowedApplication() 和 addAllowedApplication() 是两种模式,不是两个过滤器。构建器每条隧道只接受一种模型,所以想“除了三个包之外全都要”的应用用的是禁止名单,允许名单留空。两者互斥,而人们是在设置界面已经把两个开关都摆出来之后,才在运行时发现这一点。

包的可见性也会咬人。Android 11 及以后只能指定你能看见的包,所以用应用无权执行的查询拼出来的排除名单会悄悄漏掉条目。信任那张名单之前,先和包管理器真正返回的结果对比一下。

工作资料又把人群切开。在工作资料里启动的隧道只覆盖该资料及其应用,个人那边保留自己的连接。这是只需决策一次的范围问题,应该落在界面上,而不是落进支持工单。

误区 4:stopSelf 会结束隧道

并不会。接口活在 establish() 返回的描述符里,服务对象可以死掉,而那个描述符仍然开着。先关闭 ParcelFileDescriptor,放掉引用,join 或中断读线程和写线程,然后按这个顺序调用 stopForeground(true) 和 stopSelf()。

被遗忘的描述符,就是服务看起来熬过了自己的停止请求的常见原因,它也解释了那条比添加它的应用活得更久的陈旧路由。

反向的错误同样常见:读线程正读到一半就关掉描述符,然后把一个谁都没写处理的异常留给那个线程。把关闭序列放在一个地方,让用户操作和 onRevoke() 都调用进去。

隧道看起来死掉时该查什么

在隧道起来之前,先用 protect() 保护长连接套接字。没有它,隧道自己的传输会被重新路由回隧道,连接卡住的样子和服务器故障一模一样。测试要对只在远端网络背后存在的地址做,而不是对能从缓存或底层路径应答的公开站点做。

把 establish() 的返回值、路由、被排除的包以及关闭原因都记进日志。这四行不需要抓包,就能把同意问题、范围问题和生命周期问题分开。

相关文章