问一个 iOS 应用「这台手机是否在用 VPN」,诚实的答案是:它无法按你所想的那种方式知道。SDK 没有提供「设备处于隧道中」的标志位,而可能接近这一点的私有接口对正式发布的应用是关闭的。你能观察到的只有应用自身的配置、挂在它上面的状态,以及来自网络栈的一两条间接线索。它们各自回答不同的问题,而大多数出错的检查,都源于把其中一条当成了全部答案。

单一 VPN 标志位的神话

人们相信 SDK 里某处有一个属性,能像可达性标志位报告主机是否响应那样,报告设备是否处于隧道中。这样的公开属性并不存在。即便存在,「隧道存在」与「我的流量在用它」也是两回事,而对多数应用来说只有后者才重要。

正是这个缺口,使得那些宣传「一行代码检测 VPN」的库往往去读一些脆弱的东西,比如接口名或代理键,然后把它当作判决。

在相信一个 nil 之前,先把偏好加载完

共享管理器实例在你请求的那一刻就存在,但它的协议属性会一直是 nil,直到加载回调完成。所以 nil 协议至少和「没安装」一样频繁地意味着「还没加载」。请在完成块内部读取,然后再下结论:一个存在且启用标志位已打开的协议对象,才是「已安装」的样子。

第二个信号在连接对象上:当这个应用没有安装任何配置时,系统会报告 invalid 状态。加载完成之后仍是 nil 协议,加上 invalid 状态,才是真正的缺失,而不是时序竞争。

连接变化以通知的形式到达

传给 load 和 save 的处理程序报告的是配置发生了什么,而不是隧道正在做什么。在连接时等待其中某一个触发,是一场永远不会解开的竞争。应当改为观察状态变更通知,并在它到达时读取连接对象的当前值。

配置安装之后,连接状态会走过一段很短的序列:已断开、连接中、已连接、正在重新确认、正在断开。「已连接」描述的是隧道,而不是隧道背后的服务器,因此它并不能证明流量在走。On-demand 还能在应用挂起时把隧道拉起来,那时通知会在你还没有任何东西可更新之前就到了。

范围受限的代理快照是线索,不是判决

CFNetwork 可以把系统当前的代理设置交给应用,而那个结果里有一份以接口名为键的受限字典。看到以 tun、utun、ppp、ipsec 或 tap 开头的名字,是隧道存在的线索。它仍然只是这个应用所能看到的快照,而不是关于数据包去哪里的陈述。

各家提供商的命名并不一致,所以缺少前缀什么都证明不了,而存在的接口也可能并不承载流量。可达性更弱:一次对私有地址的成功请求,只能说明那条路径在那一刻对那次请求有效,并不能说明装了 VPN。

模拟器借用的是 Mac,其他应用始终不可见

模拟器没有自己的网络栈,它建立的每一个连接都是 Mac 的。因此主机 Mac 上运行的 VPN 对模拟应用已经生效,而模拟器自身的设置什么也显示不出来。如果隧道是在模拟器启动之后才起来的,重启模拟器通常比调试应用更快。非标准端口上的主机需要在 URL 里带上那个端口,或者用像 socat TCP-LISTEN:12345,fork TCP:host:12345 这样的本地转发。

公开 API 不会列出属于其他应用或配置描述文件的 VPN 配置。对某个已知应用来说,通过 canOpenURL 做 URL scheme 探测是可移植的技巧;从 iOS 9 起,被查询的 scheme 必须在 Info.plist 中声明,漏掉它调用就会返回 false。OpenVPN Connect 会响应 openvpn://,这使它成为一个不错的例子,却是一个糟糕的通用探测器。

把三种观察组合起来,并说明它们意味着什么

在实践中把三个结果叠在一起:一份已加载且已启用的配置、一个不是 invalid 的连接状态,以及一次只可能经由隧道抵达的地址请求。任何一条单独拿出来都会误导;合在一起,它们陈述的是应用能够知道什么,而不是用户假设了什么。

把结果写成诊断信息里的三行,而不是一个布尔值。当用户说 VPN 是关着的、而你的应用不同意时,这三行会指出是哪一条观察出现了分歧,而这正是「可以据以行动的工单」与「以『我这儿是好的』收场的工单」之间的区别。

相关文章