6bcdbbe752
用户判定 killswitch 优先级低、暂不做: - includeAllNetworks 实测堵死整机网络;退化方案 enforceRoutes+on-demand 几乎等于没做(核心 fail-closed 没拿到),on-demand 还对免费版配额造成 重连 churn —— 性价比低 - VpnChannel.swift 恢复到 L0 stub(setKillSwitch→result(nil)); PacketTunnelProvider.swift 已是原样 - docs §6.5 改为「尝试与暂缓」记录,状态表 macOS 回 L0 - local_test.sh 的 ks-* 漏测子命令保留(将来重拾 killswitch 时可用) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
288 lines
15 KiB
Swift
288 lines
15 KiB
Swift
// 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 成 <private>,排障时看不到内容)。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?
|
||
|
||
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":
|
||
// includeAllNetworks / on-demand 实现 kill switch(后续)。
|
||
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 {
|
||
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
|
||
vpnLog(" saveToPreferences(providerBundleId=\(Self.tunnelBundleId)) …")
|
||
try await mgr.saveToPreferences()
|
||
try await mgr.loadFromPreferences() // 保存后重载,拿到有效 connection
|
||
vpnLog(" manager 保存+重载完成 ✓")
|
||
return mgr
|
||
}
|
||
|
||
// 请求系统加载/更新 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<Void, Error>) 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<Void, Error>
|
||
private var resumed = false
|
||
init(continuation: CheckedContinuation<Void, Error>) { 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
|
||
}
|
||
}
|