大多数 Android 应用用来检测 VPN 的检查是一个接口名,而它从来不是 VPN 检查。它只报告存在某个带有开发者预期字符串的接口,这在某台设备上意味着 OpenVPN,在另一台设备上却意味着完全无关的东西。ping 也是同一类证据:它证明某个目的地作出了应答,而不证明有隧道承载了这个包。平台真正暴露的是一组网络以及每个网络所声明的传输方式,这是另一个问题,也有另一个答案。

误区:只要存在 tun0,就说明 VPN 已开启

Android 的 VPN 应用是 VpnService 实现,它们把 tun 文件描述符交给系统。接口名取决于创建者:OpenVPN 通常生成 tun0,PPTP 和 L2TP 生成 ppp0,WireGuard 以隧道名命名接口,而 IPsec 隧道可能根本不呈现常规名称。因此读取 /proc/net/dev 或枚举 NetworkInterface 对象,找的是约定而不是事实。

两个方向的错误都真实存在。设备上可能有与用户 VPN 毫无关系的本地隧道所用的 tun0,而一条完全健康的 WireGuard 隧道,对只知道字符串 tun0 的检查来说却可能不可见。同一个函数里既有假阳性又有假阴性,使它无法作为诊断依据,因为写着「未检测到 VPN」的工单在任何一个方向上都不值得信任。

访问权限也在收紧。较新的 Android 版本限制普通应用能从 /proc 读到什么,因此在 Android 8 上有效的基于名称的检查,在之后的设备上可能返回空列表,而代码毫无改动。当检查静默地向开放一侧失败时,应用就断定在这台隧道正常的硬件上没有 VPN。

误区:ping 成功就说明隧道正在承载流量

只要存在通往某台内网主机的路由,ping 它就会成功。在使用拆分隧道时,隧道开启并在做别的事情,而该主机仍可通过 Wi-Fi 访问。在使用按应用 VPN 时,执行 ping 的应用可能被排除在隧道之外,于是 ICMP 回显从隧道外发出又返回,这恰恰证明了检查所假设的反面。

ICMP 和 TCP 也是分别过滤的。一条允许回显请求、却丢弃服务所需 TCP 端口的隧道会通过 ping 测试,却在应用里失败,而这是对端的防火墙规则,不是设备的属性。对成功 ping 唯一诚实的解读是:这个目的地在这一刻应答了这个协议。

平台实际报告的内容

从 API 21 起,ConnectivityManager.getAllNetworks() 返回系统已知的每个网络,getNetworkCapabilities(network) 报告该网络声明了什么。VPN 网络带有 TRANSPORT_VPN。遍历所有网络回答一个问题:这台设备上当前是否有任何 VPN 处于开启状态?

第二个更窄的问题需要另一次调用。getActiveNetwork() 返回应用流量此刻会使用的网络,检查它上面的同一能力就回答了:我的流量是否会经过 VPN?两者可能不一致,而这种不一致是有信息量的。已开启但不是当前网络的 VPN,指向按应用路由或拆分隧道配置,而不是缺少隧道。

Network active = cm.getActiveNetwork();
NetworkCapabilities caps = cm.getNetworkCapabilities(active);
boolean viaVpn = caps != null
    && caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN);

两次调用都要做保护。对于在枚举与查询之间消失的网络,能力可能为 null;在切换过程中,当前网络也可能为 null。NET_CAPABILITY_NOT_VPN 在相反方向上很有用:确认某个网络确定不是隧道,而不只是缺少 VPN 传输标志。

NetworkInfo 是同一个误区,只是换了个 API

ConnectivityManager.TYPE_VPN 配合 NetworkInfo 是这类检查的第二代做法,它被弃用是有原因的。在现代版本上,隧道已连接时 VPN 返回的对象仍可能为 null,因此把 null 当作「没有 VPN」的代码,会在答案最重要的设备上悄悄报告假阴性。

通常与之相伴的广播也好不到哪去。CONNECTIVITY_CHANGE 有漏掉 VPN 状态变化的记录,甚至有些版本只在 VPN 连接或断开时触发,而不是在每次网络变化时触发。基于它构建的监听器会产生一个状态变量,它会逐渐偏离平台的视角,直到下一次完全重连。

对回调作出反应,而不是轮询

注册一个要求 TRANSPORT_VPN 并移除 NET_CAPABILITY_NOT_VPN 的 NetworkRequest,然后用该请求调用 registerNetworkCallback。这样 onAvailable 和 onLost 会告诉你声明何时变化,而那才是你真正关心的事件。组件停止时要注销回调,否则应用会保留对已销毁界面的引用,回调还会继续触发。

NetworkRequest req = new NetworkRequest.Builder()
    .addTransportType(NetworkCapabilities.TRANSPORT_VPN)
    .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
    .build();
cm.registerNetworkCallback(req, callback);

这需要 ACCESS_NETWORK_STATE,它是普通权限:不需要 root,不需要用户同意对话框,不需要 VPN 权限。用定时器轮询是人们退而求其次的方案,它耗电,却仍会漏掉比间隔更短的变化,而这恰恰是用户报告的那种变化。

答案仍然无法告诉你的

这些 API 都不报告隧道是否健康、在哪台服务器终止,或某个应用的流量是否在其中。VPN 网络可以在没有可用路由的情况下存在,按应用隧道也可能把提问的应用排除在外。

所以要记录检查看到了什么,而不只是一个布尔值。记录网络标识符、所声明的传输方式,以及该网络是否为当前网络。写着「未检测到 VPN」的报告没有用处,而写着某网络声明了 TRANSPORT_VPN、但 getActiveNetwork() 返回 Wi-Fi 的报告,直接指向路由或排除列表,那是有人可以着手处理的修正。

相关文章