// VpnChannel.swift — 主 app 侧 VPN 控制(方案B / P1 骨架) // // 把 Flutter 的 pangolin/vpn(MethodChannel)+ pangolin/vpn/status、pangolin/vpn/stats // (EventChannel)接到 NETunnelProviderManager:安装/启停 PacketTunnel System Extension、 // 回传状态与速率。契约对齐 client/lib/bridge/vpn_bridge.dart 的 VpnNativeBridge。 // // 注册:在 MainFlutterWindow.awakeFromNib 里 `VpnChannel.register(with: flutterViewController)`。 // // ⚠️ 站外分发首启需先**激活 System Extension**(OSSystemExtensionRequest,见 // activateSystemExtensionIfNeeded + 安装文档步骤 4),用户会在「系统设置→隐私与安全性」 // 点允许;激活成功后 NETunnelProviderManager 才能加载该 provider。 import FlutterMacOS import NetworkExtension import SystemExtensions import os.log // os_log + %{public} —— 让日志在 Console.app / `log show` 里可见(NSLog 的 %@ 参数会被 // 系统 redact 成 ,排障时看不到内容)。os_log(C API)自 macOS 10.12 起可用, // 兼容 Runner 的 10.15 部署目标。过滤:`log show --predicate 'subsystem == "com.pangolin.pangolin"'`。 private let vpnLogObj = OSLog(subsystem: "com.pangolin.pangolin", category: "vpn") private func vpnLog(_ message: String) { os_log("%{public}@", log: vpnLogObj, type: .default, message) NSLog("[pangolin/vpn] %@", message) // 同时进 stderr,flutter run 控制台也能看到 } final class VpnChannel: NSObject { private static let tunnelBundleId = "com.pangolin.pangolin.PacketTunnel" private var statusSink: FlutterEventSink? private var statsSink: FlutterEventSink? private var statusObserver: NSObjectProtocol? private let statsClient = StatsClient() private var manager: NETunnelProviderManager? private var sysextDelegate: SysExtActivationDelegate? // KillSwitch(断网保护)开关,默认 true 与 Dart AppSettings.killSwitch 默认对齐。 // 决定 NE 层是否 fail-closed:includeAllNetworks + enforceRoutes(L2 OS 强制) // + NEOnDemandRule 常开(L3 开机/掉线窗口也堵)。详见 docs/killswitch-design.html §6.5。 private var killSwitchEnabled = true static func register(with registrar: FlutterPluginRegistrar) { let instance = VpnChannel() let method = FlutterMethodChannel(name: "pangolin/vpn", binaryMessenger: registrar.messenger) method.setMethodCallHandler(instance.handle) FlutterEventChannel(name: "pangolin/vpn/status", binaryMessenger: registrar.messenger) .setStreamHandler(StatusStreamHandler(owner: instance)) FlutterEventChannel(name: "pangolin/vpn/stats", binaryMessenger: registrar.messenger) .setStreamHandler(StatsStreamHandler(owner: instance)) instance.observeStatus() // 启动即请求激活 System Extension。关键:隧道可能已被旧 sysext(on-demand/常驻) // 拉起并自动重连,此时 app 只走 primeExistingConnection、永不调 start() → // 永不提交激活请求 → 新版 sysext 永远不被装上(旧版一直跑,command.sock 不更新, // 实时统计断流)。在 register 阶段主动激活一次:同开发者签名的高版本会静默 replace, // 把运行中的旧扩展换成 bundle 里的新版。 instance.activateOnLaunch() } /// 启动时主动激活/更新 sysext(幂等):让 bundle 内的新版替换运行中的旧版。 private func activateOnLaunch() { Task { do { vpnLog("launch: 主动激活 System Extension(确保运行的是 bundle 内最新版)…") try await activateSystemExtensionIfNeeded() vpnLog("launch: System Extension 激活/更新完成 ✓") } catch { let ns = error as NSError vpnLog("launch: System Extension 激活失败 ✗ code=\(ns.code) desc=\(ns.localizedDescription)") } } } // ── MethodChannel ─────────────────────────────────────────────── private func handle(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { switch call.method { case "start": guard let configJson = call.arguments as? String else { result(FlutterError(code: "bad_args", message: "expected config json", details: nil)) return } Task { await self.start(configJson, result) } case "stop": Task { await self.stop(result) } case "getStatus": result(Self.statusString(manager?.connection.status ?? .invalid)) case "selectOutbound": // TODO: 经 sendProviderMessage / libbox CommandClient 切 outbound。 result(nil) case "getActiveOutbound": result("auto") case "setKillSwitch": let on = (call.arguments as? Bool) ?? true killSwitchEnabled = on vpnLog("setKillSwitch=\(on)") // 已装配 manager 则即时重写 NE 配置(includeAllNetworks/enforceRoutes/on-demand); // 尚无 manager 时仅缓存,下次 loadOrCreateManager 装配时应用。 if let mgr = manager { Task { do { try await self.applyKillSwitchConfig(to: mgr) vpnLog("setKillSwitch: NE 配置已更新并保存 ✓") result(nil) } catch { let ns = error as NSError vpnLog("setKillSwitch: 保存 NE 配置失败 ✗ code=\(ns.code) desc=\(ns.localizedDescription)") result(FlutterError(code: "killswitch_failed", message: error.localizedDescription, details: nil)) } } } else { result(nil) } default: result(FlutterMethodNotImplemented) } } private func start(_ configJson: String, _ result: @escaping FlutterResult) async { vpnLog("start() 收到调用, config 长度=\(configJson.count) bytes") do { vpnLog("step① 激活 System Extension …") try await activateSystemExtensionIfNeeded() vpnLog("step① System Extension 激活完成 ✓") vpnLog("step② 装配 NETunnelProviderManager …") let mgr = try await loadOrCreateManager() self.manager = mgr vpnLog("step② manager 就绪, 当前隧道状态=\(Self.statusString(mgr.connection.status))") vpnLog("step③ startVPNTunnel(options: configContent) …") try mgr.connection.startVPNTunnel(options: [ "configContent": configJson as NSString, ]) vpnLog("step③ startVPNTunnel 调用已返回(实际起停由 NEVPNStatus 流驱动)✓") result(nil) } catch { let ns = error as NSError vpnLog("start FAILED ✗ domain=\(ns.domain) code=\(ns.code) desc=\(ns.localizedDescription) userInfo=\(ns.userInfo)") result(FlutterError(code: "start_failed", message: error.localizedDescription, details: nil)) } } private func stop(_ result: @escaping FlutterResult) async { // on-demand 常开时直接 stopVPNTunnel 会被 OS 立刻拉回 → 手动断开须先关 on-demand 再断。 // 下次手动 start() 时 loadOrCreateManager 会按 killSwitchEnabled 重新启用 on-demand。 if let mgr = manager, mgr.isOnDemandEnabled { mgr.isOnDemandEnabled = false do { try await mgr.saveToPreferences() try await mgr.loadFromPreferences() vpnLog("stop: 已临时关闭 on-demand(避免手动断开被自动拉回)") } catch { let ns = error as NSError vpnLog("stop: 关闭 on-demand 失败(仍继续断开) code=\(ns.code) desc=\(ns.localizedDescription)") } } manager?.connection.stopVPNTunnel() result(nil) } // ── NETunnelProviderManager 装配 ──────────────────────────────── private func loadOrCreateManager() async throws -> NETunnelProviderManager { let all = try await NETunnelProviderManager.loadAllFromPreferences() vpnLog(" loadAllFromPreferences: 已有 \(all.count) 个 VPN 配置") let mgr = all.first ?? NETunnelProviderManager() let proto = (mgr.protocolConfiguration as? NETunnelProviderProtocol) ?? NETunnelProviderProtocol() proto.providerBundleIdentifier = Self.tunnelBundleId proto.serverAddress = "Pangolin" // 仅展示用 mgr.protocolConfiguration = proto mgr.localizedDescription = "Pangolin" mgr.isEnabled = true configureKillSwitch(on: mgr) // 按 killSwitchEnabled 写入 NE fail-closed 字段 vpnLog(" saveToPreferences(providerBundleId=\(Self.tunnelBundleId)) …") try await mgr.saveToPreferences() try await mgr.loadFromPreferences() // 保存后重载,拿到有效 connection vpnLog(" manager 保存+重载完成 ✓") return mgr } // ── KillSwitch(断网保护)NE 配置 ──────────────────────────────── // 把当前 killSwitchEnabled 写入 manager 的 NE 字段(不保存,由调用方保存): // enforceRoutes=true:隧道 includedRoutes(0.0.0.0/0 默认路由)优先于物理接口, // 连接期间所有流量强制走隧道、不从旁路接口泄漏。 // NEOnDemandRule 常开:OS 在掉线/开机窗口自动拉起隧道,缩小重连缺口。 // excludeLocalNetworks=true:放行 LAN(打印机/AirPlay 等),不影响防泄漏目标。 // ⚠️ 刻意不设 includeAllNetworks:它会把「所有」流量(含 sing-box 连服务器的握手、 // app 调控制面的请求)在隧道建起来前就塞进隧道 → 握手出不去 → 连接失败 + on-demand // 死循环 + 整机断网(实测)。要拿「扛进程被杀」那一档须先在扩展内正确放行服务器/ // 控制面连接,留待真机验证后再评估,见 docs/killswitch-design.html §6.5。 // enforceRoutes/excludeLocalNetworks 为 macOS 11+ API,Runner 部署目标 10.15 → #available 守卫。 private func configureKillSwitch(on mgr: NETunnelProviderManager) { if let proto = mgr.protocolConfiguration as? NETunnelProviderProtocol { proto.includeAllNetworks = false if #available(macOS 11.0, *) { proto.enforceRoutes = killSwitchEnabled proto.excludeLocalNetworks = true } } if killSwitchEnabled { let rule = NEOnDemandRuleConnect() rule.interfaceTypeMatch = .any mgr.onDemandRules = [rule] mgr.isOnDemandEnabled = true } else { mgr.onDemandRules = [] mgr.isOnDemandEnabled = false } vpnLog(" killSwitch 配置: enforceRoutes=\(killSwitchEnabled) onDemand=\(killSwitchEnabled) includeAllNetworks=false") } /// 即时应用 killswitch(改字段 + 保存 + 重载)。供 setKillSwitch 在 manager 已装配时调用。 private func applyKillSwitchConfig(to mgr: NETunnelProviderManager) async throws { configureKillSwitch(on: mgr) try await mgr.saveToPreferences() try await mgr.loadFromPreferences() } // 请求系统加载/更新 PacketTunnel System Extension。首启系统会弹「隐私与安全性」 // 让用户允许;允许后 didFinishWithResult 回来。已是最新则快速完成。 private func activateSystemExtensionIfNeeded() async throws { vpnLog(" 提交 OSSystemExtensionRequest.activationRequest(id=\(Self.tunnelBundleId)) …") vpnLog(" 主 bundle=\(Bundle.main.bundlePath)") let sysextDir = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/SystemExtensions").path vpnLog(" SystemExtensions 目录=\(sysextDir) 内容=\((try? FileManager.default.contentsOfDirectory(atPath: sysextDir)) ?? ["<读取失败>"])") try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in let req = OSSystemExtensionRequest.activationRequest( forExtensionWithIdentifier: Self.tunnelBundleId, queue: .main) let delegate = SysExtActivationDelegate(continuation: cont) self.sysextDelegate = delegate // 保活到回调结束 req.delegate = delegate OSSystemExtensionManager.shared.submitRequest(req) vpnLog(" submitRequest 已提交, 等待 sysextd 回调(didFinish / didFail / needsApproval)…") } } // ── 状态 / 速率回传 ───────────────────────────────────────────── private func observeStatus() { // StatsClient 拿到实时帧 → 推当前 statsSink。 statsClient.onStats = { [weak self] stats in self?.statsSink?(stats) } statusObserver = NotificationCenter.default.addObserver( forName: .NEVPNStatusDidChange, object: nil, queue: .main ) { [weak self] note in guard let self, let conn = note.object as? NEVPNConnection else { return } let s = Self.statusString(conn.status) vpnLog("NEVPNStatus 变化 → \(s) (raw=\(conn.status.rawValue))") self.statusSink?(s) // 实时统计跟随连接状态启停(经 sendProviderMessage 向扩展拉取)。 switch conn.status { case .connected: if let s = conn as? NETunnelProviderSession { self.statsClient.start(session: s) } case .disconnected, .invalid: self.statsClient.stop() default: break } } // 隧道(sysext)常驻:app 重启时它可能早已 connected,此后不会再发 .connected // 状态变化 → 上面的 observer 不触发、statsClient 永不 start。启动时主动探一次 // 现存连接:拿到 manager(让 onStatsListen 兜底也能判定),已连则立刻拉起统计。 Task { await self.primeExistingConnection() } } /// 启动时若隧道已在连接,主动装配 manager 并拉起实时统计。幂等(StatsClient.start 自带去重)。 private func primeExistingConnection() async { guard let all = try? await NETunnelProviderManager.loadAllFromPreferences(), let mgr = all.first else { return } await MainActor.run { self.manager = mgr if mgr.connection.status == .connected, let s = mgr.connection as? NETunnelProviderSession { vpnLog("启动时检测到隧道已连接(raw=\(mgr.connection.status.rawValue)),主动启动实时统计") self.statusSink?(Self.statusString(mgr.connection.status)) self.statsClient.start(session: s) } } } fileprivate func onStatusListen(_ sink: @escaping FlutterEventSink) { statusSink = sink sink(Self.statusString(manager?.connection.status ?? .invalid)) } fileprivate func onStatusCancel() { statusSink = nil } fileprivate func onStatsListen(_ sink: @escaping FlutterEventSink) { statsSink = sink // 实时速率由 StatsClient(经 sendProviderMessage 向扩展拉取)在隧道 connected 时 // 产出并经 onStats 回调推送;此处仅登记 sink。若已连接则补一次拉起。 if manager?.connection.status == .connected, let s = manager?.connection as? NETunnelProviderSession { statsClient.start(session: s) } } fileprivate func onStatsCancel() { statsSink = nil } private static func statusString(_ s: NEVPNStatus) -> String { switch s { case .connected: return "on" case .connecting, .reasserting: return "connecting" case .disconnecting: return "disconnecting" case .disconnected, .invalid: return "off" @unknown default: return "error" } } } // EventChannel 流处理器。 private final class StatusStreamHandler: NSObject, FlutterStreamHandler { weak var owner: VpnChannel? init(owner: VpnChannel) { self.owner = owner } func onListen(withArguments _: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { owner?.onStatusListen(events); return nil } func onCancel(withArguments _: Any?) -> FlutterError? { owner?.onStatusCancel(); return nil } } private final class StatsStreamHandler: NSObject, FlutterStreamHandler { weak var owner: VpnChannel? init(owner: VpnChannel) { self.owner = owner } func onListen(withArguments _: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { owner?.onStatsListen(events); return nil } func onCancel(withArguments _: Any?) -> FlutterError? { owner?.onStatsCancel(); return nil } } // System Extension 激活请求回调。首启需用户在「系统设置→隐私与安全性」允许, // 允许后收到 didFinishWithResult(故 await 会阻塞到用户批准 —— 这是对的, // 扩展没加载前隧道起不来)。 private final class SysExtActivationDelegate: NSObject, OSSystemExtensionRequestDelegate { private let continuation: CheckedContinuation private var resumed = false init(continuation: CheckedContinuation) { self.continuation = continuation } func request(_ request: OSSystemExtensionRequest, didFinishWithResult result: OSSystemExtensionRequest.Result) { vpnLog("sysext didFinishWithResult ✓ result=\(result.rawValue) (0=completed, 1=willCompleteAfterReboot)") guard !resumed else { return } resumed = true continuation.resume() } func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) { let ns = error as NSError // OSSystemExtensionErrorDomain code 速查:1 unknown,2 missingEntitlement, // 3 unsupportedParentBundleLocation,4 extensionNotFound,8 codeSignatureInvalid, // 9 validationFailed,10 forbiddenBySystemPolicy,13 authorizationRequired。 vpnLog("sysext didFailWithError ✗ domain=\(ns.domain) code=\(ns.code) desc=\(ns.localizedDescription)") guard !resumed else { return } resumed = true continuation.resume(throwing: error) } func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) { vpnLog("sysext requestNeedsUserApproval —— 需在 系统设置 → 隐私与安全性 点「允许」(等待中…)") } func request(_ request: OSSystemExtensionRequest, actionForReplacingExtension existing: OSSystemExtensionProperties, withExtension ext: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction { vpnLog("sysext 替换扩展: 已装 v\(existing.bundleVersion)/\(existing.bundleShortVersion) → 新 v\(ext.bundleVersion)/\(ext.bundleShortVersion), 选择 replace") return .replace } }