你的应用显示断开,菜单栏显示已连接,而流量明明在走。这个落差不是重试就能修好的竞态:应用和隧道是两个独立进程,你的代码读到的对象只是系统所存配置的一份缓存视图。Apple 的 Personal VPN 接口是控制 API,不是数据通路,它大部分古怪行为都源自这一事实。下面是每次调用真正发生的事。

管理器对象只是缓存视图,不是隧道

NETunnelProviderManager 由一个归系统所有的首选项存储填充,因此在 loadFromPreferences 完成之前,它的属性没有任何意义。若在此之前读取 connection.status,你拿到的是默认值,这正是重新启动后第一次刷新状态会在隧道仍然连通的当口报告断开的原因。先加载,然后在同一个完成回调里读取状态和协议配置。之后的变化应通过 NEVPNStatusDidChange 观察,而不是轮询;还要注意,由按需连接(On Demand)发起的连接也会触发该通知,那种情况下根本不存在任何可以返回结果的应用调用。协议设置也一样:它们从已加载的配置中读取,而不是从应用传入的值中读取。

隧道本身活在 Network Extension 提供者进程里,所以应用可以丢失自己的状态而不丢失连接。界面上显示的一切都必须从管理器重建,而不能凭上一次会话记忆。

启动顺序:先保存,再加载,最后启动

基于提供者的连接需要按顺序三步。写入提供者配置并调用 saveToPreferences,加载管理器,让已保存的配置成为你手中的那一份,然后通过 NETunnelProviderSession.startVPNTunnel() 启动。跳过保存,启动就没有可启动的东西;在保存之前加载,管理器反映的仍是旧配置。

startVPNTunnel 返回的布尔值只说明请求被接受了。提供者层面的失败稍后才以状态变为断开的形式出现,你要通过 NEVPNStatusDidChange 通知观察它;通知处理程序必须在 loadFromPreferences 之后重新读取 connection.status,因为缓存值可能仍是旧的。请求层面的失败会立刻从会抛异常的调用形式返回,或通过 startVPNTunnelAndReturnError: 的错误输出参数返回。把返回值当成结果的代码,会报告一个从未建立的连接。另一个静默失败是提供者 bundle 标识符与已安装扩展不匹配:请求被拒绝,而消息里没有任何内容指向该标识符。如果标识符写错,请求通常立刻失败,提供者日志一行都还没出现。

启动后重新加载首选项,才是让状态页面正确的原因。系统改动配置时,管理器不会主动推送给你。

信任材料通过描述文件下发,绝不来自应用代码

应用没有受支持的方式把根证书加入 iOS 信任存储,因此 Personal VPN 配置只能引用设备已经信任的材料:由已安装描述文件、设备管理,或系统因其他原因信任的 CA 所建立的证书链。一个在首次启动时自建 CA 并指望系统认可它的应用,会在提供者内部的校验中失败,而应用界面看不到原因。

把这条边界当作它本来的设计约束,并把信任锚随注册描述文件一起下发。把 VPN 认证与信任决定分开,因为它们在不同的地方失败,也由不同的人排查。

计数器来自接口,而不是来自 VPN API

管理器只暴露状态和配置,没有任何按隧道统计字节的东西。如果你需要用量数字,调用 getifaddrs() 并读取相应接口的 if_data:Wi-Fi 用 en0,蜂窝网络用 pdp_ip 接口,隧道本身用 utun 设备。utun 的计数器描述的是内层流量,并且包含协议开销。

这些数字只是估算,不是按应用计费,而且接口重建时会被重置。请在界面上照此标注,否则有人会拿它去给客户开账单。如果你需要按应用的数字,你需要的是完全不同的机制,而不是把这些字段读得更准。

清理应当放在 stopVPNTunnel 里,而不是终止流程里

applicationWillTerminate 只给你几秒钟,随后系统就可能杀掉进程,这不是拆连接的可靠位置。把停止请求发给 stopVPNTunnel(),或在提供者的停止回调里处理,并保留你启动时所用的管理器或配置标识符,让停止只作用于那条连接,而不是设备上的每一个配置。

把这个标识符和它所属的描述文件存在一起,因为管理器对象不会记录用户选择了哪个配置。还要记住,由按需连接拉起的连接背后根本没有应用调用,所以状态页面在绘制任何内容之前,仍然必须先加载首选项。

相关文章