如何在任何平台上从代码检测活动的 VPN 隧道
隧道是否存在、这个目的地是否经过它,还是远端给出了回应?这是三种不同的检查,也是三种不同的信号。
“VPN 开着吗?”其实是三个问题挤在一句话里。你的代码需要哪一个,这是第一个决定,而多数实现恰恰跳过了它。本机是否存在隧道、去往这个目的地的流量是否从隧道出去,还是远端以你预期的身份作答。先选性质,再选测量它的信号,最后在日志里写清楚你实际测的是哪一个。
三种性质,三种信号
把三个问题各压缩成一行,就得到三个不同的答案。下表本身就是那个决定,而不是决定的摘要。
| Property | Signal that answers it | What it still leaves open |
|---|---|---|
| Existence | The platform's own connection state: RAS on Windows, NetworkManager on D-Bus on Linux, scutil on macOS | Tunnels of a type the platform does not manage, which includes most third-party clients |
| Path | A route lookup for the one destination, or a scoped proxy or interface key that names the tunnel | Whether the far end is the peer you trust |
| Identity | An authenticated response from the far end, or a source-address policy applied on the server | Which local interface carried the packets |
请慢慢读最后一行。能应答的私有主机能证明有东西应答了,却不能证明应答是从隧道里来的,也完全没说明对面是谁。如果你要的性质是路径,那就去测路径。
存在:平台自身的状态
Windows 对原生配置文件通过 Remote Access Service API 回答这个问题。RasEnumConnections 列出已连接的内容,RasGetConnectStatus 报告单个条目的状态,RasGetLinkStatistics 补充速度、字节计数和连接时长。局限才是重点:OpenVPN、WireGuard 和厂商客户端从不注册 RAS 连接,因此这类隧道即使健康,列表也是空的,天真的代码便会报告为断开。
Linux 桌面通过 D-Bus 上的 NetworkManager 暴露同一性质:VPN 连接对象上的 PropertiesChanged 把检查变成事件,State 属性则报告链路何时进入已连接状态。在 shell 里,nmcli connection show --active 读取同样的信息,调试时你要的正是这个。macOS 把这份列表放在 System Configuration 框架里,scutil 就是它的命令行视图。三者描述的都是平台自己管理的连接,而对平台不管理的隧道,三者一律沉默。
路径:数据包实际去了哪里
按名字匹配是所有人最先抓的捷径:枚举接口,拿字符串去对 tun、tap、ppp 或 ipsec,命中就当作证据。这只是启发式,无论有没有东西真的经过,接口集合看起来都可能一样。较旧的 iOS 版本用这种办法作答,后来的版本不再这么做,因为可见接口已经无法区分这两种状态。
在那里取代它的是系统代理字典。VPN 连接时,代理设置的调用会返回限定到隧道相关接口的键;什么也没连时,字典返回为空。这比听起来要弱,因为它记录的是系统“相信”隧道存在,而不是哪个目的地真的在用。
Android 对这种陷阱有自己的版本:vpn.connectivity 广播在某些构建上能到达清单里声明的接收器,在另一些构建上只能到达运行时注册的接收器。把它当作尽力而为,并在其后保留一次直接探测。
当真正重要的是目的地时,去问路由表,而不是网卡列表。默认网关的启发式在多网络主机上会失效,在自带网关的隧道上也会失效。
身份:探测远端
每隔几秒向远端发一次 ICMP 回显的循环,是这类检查里最便宜的一种,而且它失败的方向很有用。要把它指向只存在于私有网络内部的地址,因为公网地址在任何可用的上行链路都会应答,什么都证明不了。在 ICMP 被过滤的地方,对服务端口做一次短 TCP 连接也能起到同样作用。
服务端检查可用的信号只有一个:连接上的源地址。维护地址段清单是运维工作,而且它无法把隧道与碰巧使用同一网段的其它网络区分开。
粗放的策略可以接受这一点。身份不行,所以要认证客户端,而不是相信它的数据包看起来来自哪里。
记录分类,而不是结论
把失败分类在日志里分开记:完全没有接口、接口存在但目的地不可达、目的地可达但认证被拒。这三者属于三个不同的负责人,各自对应不同的排查方向。
把它们合并成一个“VPN 挂了”,报告就会送到修不了它的团队手里。真正有用的是分类,结论反而会让它出错。