你需要让某个界面在隧道开启时表现不同,却没有任何调用能告诉你隧道是否开启。这就是发布时的约束:在 macOS 上,没有公开框架会报告任意 VPN 是否已连接;在 iOS 上,状态属于安装该配置的应用。下面的检查从这一限制出发,走向可以真正发布的东西,其中任何一项都可能提前结束探索。

检查 1:这个问题到底能不能回答?

沙盒在任何 API 之前就给出了答案。第三方进程无法枚举其他厂商的扩展,也无法读取该扩展的配置,因此不存在可以询问的受支持对象。

所有绕行方案都会撞上同一堵墙。接口列表、scutil --nc list 以及直接读取系统配置,要么需要沙盒不予授予的权限,要么依赖 Apple 从未承诺的行为。如果方案需要其中之一,那问题就出在方案本身。建立在未公开细节上的功能,等于一张带交付日期的支持工单。

检查 2:你的应用自己管理隧道吗?

如果是,你对自己的配置就有受支持的答案。加载偏好设置,处理错误,然后读取状态并订阅变化,而不是轮询:

vpnManager.loadFromPreferences { error in
    // handle a load failure before reading status
}

NotificationCenter.default.addObserver(
    forName: .NEVPNStatusDidChange,
    object: nil,
    queue: nil
) { note in
    let status = (note.object as? NEVPNConnection)?.status
}

要准确理解它返回什么:你安装的那条隧道的状态。运行其他客户端的用户会显示为未连接,这不是你代码里的缺陷。在基于这个答案构建之前,先决定你的功能真正问的是这两个问题中的哪一个。

检查 3:启发式判断实际能告诉你什么?

被广泛照搬的做法是向系统代理字典查询其带作用域的条目并读取键名,而这些键恰好就是接口名:

let settings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as NSDictionary?
let scoped = settings?["__SCOPED__"] as? [String: Any]
let names = scoped?.keys ?? []

以 tap、tun、ppp、ipsec 或 utun 开头的键,意味着存在一个隧道作用域的接口。匹配完整名称会立刻失败,因为 iOS 12 自带 utun1 和 utun2,而某些配置会以 ipsec4、ppp0 或 utun0 出现。应当比较前缀。

即便如此,名称并不能标识所有者。系统功能和其他厂商的扩展也会创建 utun 接口,而且可能有一条接口存在却不承载你的任何流量。把它当作附近有隧道状东西的提示,仅此而已。这里的误报在你只拦截一个功能时很便宜,在拦截登录界面时则很昂贵。

检查 4:能否改为测试目的地?

如果应用只需要知道某个内部服务是否可达,就测试那个服务。带超时的短探测回答了功能真正依赖的问题,而且当用户切换客户端或隧道形态变化时它仍然有效。

可达性是关于你这条路径的观察。接口名则是关于路径邻近之物的推断,所以启发式判断会在缺陷报告里引发争论,而探测会带来证据。如果用户坚称 VPN 开着而探测失败,你现在就有了一条具体的排查路径:名称解析、路由或服务权限。

检查 5:平台能否强制实现你的需求?

当需求是保证任何东西都不会逃出隧道时,该放的地方是配置描述文件,而不是检测器。在 iOS 14 或更高版本的完整隧道配置中开启 includeAllNetworks,改变的是平台行为,而不是让应用去推断。这个标志在 NEVPNProtocol 参考文档 中有说明。

这是另一类答案:强制而非观察。它也只适用于你控制的配置描述文件,所以对监视别人隧道的消费级应用没有帮助。

检查 6:让失败安全且可观测

只关掉需要隧道的那个能力,让应用其余部分仍可用。一个在不确定时就隐藏整个界面的检测器,会给任何使用你从未预料到的隧道名的用户制造故障。相反,应回退到保守行为:本地功能继续工作,只有真正需要隧道的远程调用被禁用。这个选择是产品决策,值得写在启发式判断旁边。

记录你观察到的内容:接口名、平台版本以及一次探测的结果。这会把下一份报告从争论变成检查。然后,在有受支持声明的地方优先使用它,因为受管理的配置可以明确携带意图,并消除大部分检查名称的理由。

如果检查 1 到 5 让你没有得到受支持的答案,就发布探测并诚实标注结果。一个有文档记录的最佳努力信号,比一个会在下个版本中失效的私有 API 是更好的产品决策。

相关文章