// PacketTunnelProvider.swift — Pangolin macOS 隧道扩展(方案B / P1) // // NEPacketTunnelProvider 子进程,把嵌入的 sing-box(libbox)拉起来建 TUN,免 root、 // 自包含。数据流见 docs/p1-macos-system-extension.md。 // // gomobile 约定:每个 Go 接口生成同名 protocol + class;Swift 里实现用 `XxxProtocol`, // 参数/返回用同名 class。NE 生命周期 override 用 completion-handler(规避 Swift 6.2 // async→ObjC 桥接 thunk 的编译器崩溃)。 import Darwin import Foundation import Libbox import Network import NetworkExtension import os private let log = Logger(subsystem: "com.pangolin.pangolin.PacketTunnel", category: "provider") // macOS 原生 App Group 格式 .(非 iOS 的 group. 前缀)。 // sysextd 校验请求方 app 时要求此格式;用 group. 式会在 realize 暂存前被拒。 private let appGroup = "BYL4KQHMTN.com.pangolin.pangolin" final class PacketTunnelProvider: NEPacketTunnelProvider { private var commandServer: LibboxCommandServer? private var platform: PangolinPlatformInterface? private let stats = StatsCollector() override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) { log.info("startTunnel") // libbox 启动必须放后台队列:startOrReloadService 会同步回调 openTun → // setTunnelNetworkSettings,其完成回调要在 provider 队列上投递;若在 provider // 队列(NE 调 startTunnel 所在队列)同步跑 startOrReloadService 会三方死锁。 DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self else { return } do { // urltest 延迟走 clash API:服务端下发的配置没有 clash_api,sing-box 不暴露 // 出站组/urltest 历史 → libbox Group 命令返回空 → 连接页延迟为 —。这里在扩展内 // 注入 clash_api(127.0.0.1 本地监听)+ cache_file,StatsCollector 经本地 HTTP // 查 /proxies + /group//delay 取真实延迟(与 Windows 同法)。 let clashPort = Int.random(in: 20000...60000) let clashSecret = UUID().uuidString let configContent = Self.injectClashApi(try self.resolveConfig(options), port: clashPort, secret: clashSecret) // libbox 数据目录用扩展自己的 App Group 容器(root 运行 → /var/root/...)。 // ⚠️ 不要试图把它指向用户容器:扩展以 root 运行,command.sock 的目录/属主 // 与无 root 的主 app 不一致,主 app 永远 connect 不上(实测踩坑)。实时统计 // 改走 NETunnelProviderSession.sendProviderMessage(见 handleAppMessage + // StatsCollector):扩展进程内自连 command.sock 取状态、缓存,主 app 经 NE 的 // 跨进程消息通道拉取,绕开容器/属主死局。 guard let base = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { throw simpleError("no app group container") } let work = base.appendingPathComponent("work", isDirectory: true) try? FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) let setup = LibboxSetupOptions() setup.basePath = base.path setup.workingPath = work.path setup.tempPath = NSTemporaryDirectory() var setupErr: NSError? LibboxSetup(setup, &setupErr) if let setupErr { throw setupErr } let platform = PangolinPlatformInterface(provider: self) self.platform = platform var newErr: NSError? guard let server = LibboxNewCommandServer(self, platform, &newErr) else { throw newErr ?? simpleError("LibboxNewCommandServer returned nil") } try server.start() // 必须传非空 options:此版本 libbox 的 StartOrReloadService 会解引用 options, // 传 nil 会在 command_server.go:175 触发 SIGSEGV(空指针)。 try server.startOrReloadService(configContent, options: LibboxOverrideOptions()) self.commandServer = server // 进程内 stats 采集:libbox command.sock 取上/下行;clash API 取 urltest 延迟。 self.stats.start(clashPort: clashPort, clashSecret: clashSecret) log.info("startTunnel: service started") completionHandler(nil) } catch { log.error("startTunnel failed: \(error.localizedDescription, privacy: .public)") completionHandler(error) } } } override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { log.info("stopTunnel reason=\(reason.rawValue)") stats.stop() try? commandServer?.closeService() commandServer?.close() commandServer = nil platform?.stopMonitor() platform = nil completionHandler() } // 主 app 经 NETunnelProviderSession.sendProviderMessage 拉取实时统计。约定: // 发送 UTF-8 "stats" → 回最新统计 JSON(契约同 pangolin/vpn/stats);其他消息回 nil。 // 这是 macOS 上 app↔扩展跨进程(且跨 root/用户边界)的正路,免去共享文件 socket 的 // 容器/属主死局。 override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { let req = String(data: messageData, encoding: .utf8) ?? "" if req == "stats" { completionHandler?(stats.latestJSON()) } else { completionHandler?(nil) } } // 在 sing-box 配置里注入 experimental.clash_api(本地监听)+ cache_file,让内核暴露 // 出站组/urltest 历史,供 StatsCollector 经本地 HTTP 取真实延迟。已有则不覆盖。 static func injectClashApi(_ configJSON: String, port: Int, secret: String) -> String { guard var root = (try? JSONSerialization.jsonObject(with: Data(configJSON.utf8))) as? [String: Any] else { return configJSON } var experimental = (root["experimental"] as? [String: Any]) ?? [:] if experimental["clash_api"] == nil { experimental["clash_api"] = [ "external_controller": "127.0.0.1:\(port)", "secret": secret, ] } if experimental["cache_file"] == nil { experimental["cache_file"] = ["enabled": true] } root["experimental"] = experimental guard let data = try? JSONSerialization.data(withJSONObject: root), let out = String(data: data, encoding: .utf8) else { return configJSON } return out } private func resolveConfig(_ options: [String: NSObject]?) throws -> String { if let inline = options?["configContent"] as? String, !inline.isEmpty { return inline } guard let base = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { throw simpleError("no app group container") } let content = (try? String(contentsOf: base.appendingPathComponent("config.json"), encoding: .utf8)) ?? "" guard !content.isEmpty else { throw simpleError("empty sing-box config") } return content } } // ── LibboxCommandServerHandler:服务端事件回调 ────────────────────────────── extension PacketTunnelProvider: LibboxCommandServerHandlerProtocol { func serviceReload() throws { if let cfg = try? resolveConfig(nil) { try commandServer?.startOrReloadService(cfg, options: LibboxOverrideOptions()) } } func serviceStop() throws { cancelTunnelWithError(nil) } func postServiceClose() {} func getSystemProxyStatus() throws -> LibboxSystemProxyStatus { let s = LibboxSystemProxyStatus(); s.available = false; s.enabled = false; return s } func setSystemProxyEnabled(_ enabled: Bool) throws {} func writeStatus(_ message: LibboxStatusMessage?) {} func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) {} func writeConnectionEvents(_ events: LibboxConnectionEvents?) {} func writeGroups(_ message: (any LibboxOutboundGroupIteratorProtocol)?) {} func writeDebugMessage(_ message: String?) {} func setDefaultLogLevel(_ level: Int32) {} func initializeClashMode(_ modeList: (any LibboxStringIteratorProtocol)?, currentMode: String?) {} func updateClashMode(_ newMode: String?) {} func clearLogs() {} func connected() {} func disconnected(_ message: String?) {} } // ── libbox ↔ NetworkExtension 桥(LibboxPlatformInterface)────────────────── final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol { private weak var provider: NEPacketTunnelProvider? private var monitor: NWPathMonitor? private var defaultInterfaceIndex: Int32 = -1 private let monitorQueue = DispatchQueue(label: "pangolin.tunnel.pathmonitor") init(provider: NEPacketTunnelProvider) { self.provider = provider super.init() } func stopMonitor() { monitor?.cancel(); monitor = nil } // MARK: openTun —— 据 TunOptions 建 NEPacketTunnelNetworkSettings,返回 utun fd func openTun(_ options: (any LibboxTunOptionsProtocol)?, ret0_: UnsafeMutablePointer?) throws { guard let options, let provider else { throw simpleError("openTun: no options/provider") } let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") settings.mtu = NSNumber(value: options.getMTU()) let v4addrs = routePrefixes(options.getInet4Address()) if !v4addrs.isEmpty { let ipv4 = NEIPv4Settings(addresses: v4addrs.map(\.address), subnetMasks: v4addrs.map(\.mask)) ipv4.includedRoutes = includedRoutes4(options) settings.ipv4Settings = ipv4 } let v6addrs = routePrefixes(options.getInet6Address()) if !v6addrs.isEmpty { let ipv6 = NEIPv6Settings(addresses: v6addrs.map(\.address), networkPrefixLengths: v6addrs.map { NSNumber(value: $0.prefix) }) ipv6.includedRoutes = includedRoutes6(options) settings.ipv6Settings = ipv6 } if let dns = try? options.getDNSServerAddress(), !dns.value.isEmpty { settings.dnsSettings = NEDNSSettings(servers: [dns.value]) } let sem = DispatchSemaphore(value: 0) var applyError: Error? provider.setTunnelNetworkSettings(settings) { err in applyError = err; sem.signal() } sem.wait() if let applyError { throw applyError } guard let fd = Self.tunFd() else { throw simpleError("openTun: utun fd not found") } ret0_?.pointee = fd } // MARK: 默认接口监控 func startDefaultInterfaceMonitor(_ listener: (any LibboxInterfaceUpdateListenerProtocol)?) throws { let m = NWPathMonitor() monitor = m // 必须阻塞到首个 path 更新再返回:否则 sing-box 紧接着下载远程 rule-set 时 // defaultInterfaceIndex 仍为 -1,autoDetectControl 跳过绑接口 → "no available // network interface"。对齐 sing-box-for-apple 的实现。 let firstUpdate = DispatchSemaphore(value: 0) var signaled = false m.pathUpdateHandler = { [weak self] path in guard let self else { return } let iface = path.availableInterfaces.first { path.usesInterfaceType($0.type) } ?? path.availableInterfaces.first let name = iface?.name ?? "" let index = Int32(iface?.index ?? 0) self.defaultInterfaceIndex = index listener?.updateDefaultInterface(name, interfaceIndex: index, isExpensive: path.isExpensive, isConstrained: path.isConstrained) if !signaled { signaled = true; firstUpdate.signal() } } m.start(queue: monitorQueue) _ = firstUpdate.wait(timeout: .now() + 5) } func closeDefaultInterfaceMonitor(_ listener: (any LibboxInterfaceUpdateListenerProtocol)?) throws { stopMonitor() } // MARK: 把 socket 绑到默认物理接口 func autoDetectControl(_ fd: Int32) throws { guard defaultInterfaceIndex > 0 else { return } var idx = defaultInterfaceIndex _ = setsockopt(fd, IPPROTO_IP, IP_BOUND_IF, &idx, socklen_t(MemoryLayout.size)) _ = setsockopt(fd, IPPROTO_IPV6, IPV6_BOUND_IF, &idx, socklen_t(MemoryLayout.size)) } func usePlatformAutoDetectControl() -> Bool { true } // MARK: 系统接口枚举 func getInterfaces() throws -> LibboxNetworkInterfaceIteratorProtocol { InterfaceIterator(items: Self.enumerateInterfaces()) } // MARK: 其余协议方法(取合理默认) func underNetworkExtension() -> Bool { true } func useProcFS() -> Bool { false } func includeAllNetworks() -> Bool { false } func clearDNSCache() {} func readWIFIState() -> LibboxWIFIState? { nil } func systemCertificates() -> (any LibboxStringIteratorProtocol)? { nil } func localDNSTransport() -> (any LibboxLocalDNSTransportProtocol)? { nil } func send(_ notification: LibboxNotification?) throws {} func findConnectionOwner(_ ipProtocol: Int32, sourceAddress: String?, sourcePort: Int32, destinationAddress: String?, destinationPort: Int32) throws -> LibboxConnectionOwner { throw simpleError("findConnectionOwner: unsupported") } // ── 工具 ── private func routePrefixes(_ it: (any LibboxRoutePrefixIteratorProtocol)?) -> [(address: String, mask: String, prefix: Int32)] { var out: [(address: String, mask: String, prefix: Int32)] = [] guard let it else { return out } while it.hasNext() { if let p = it.next() { out.append((address: p.address(), mask: p.mask(), prefix: p.prefix())) } } return out } private func includedRoutes4(_ o: any LibboxTunOptionsProtocol) -> [NEIPv4Route] { let r = routePrefixes(o.getInet4RouteAddress()) if r.isEmpty { return [NEIPv4Route.default()] } return r.map { NEIPv4Route(destinationAddress: $0.address, subnetMask: $0.mask) } } private func includedRoutes6(_ o: any LibboxTunOptionsProtocol) -> [NEIPv6Route] { let r = routePrefixes(o.getInet6RouteAddress()) if r.isEmpty { return [NEIPv6Route.default()] } return r.map { NEIPv6Route(destinationAddress: $0.address, networkPrefixLength: NSNumber(value: $0.prefix)) } } // utun fd:getpeername 得 sockaddr_ctl,匹配 utun 控制 id(WireGuard 技法) private static func tunFd() -> Int32? { var ctlInfo = ctl_info() withUnsafeMutableBytes(of: &ctlInfo.ctl_name) { ptr in _ = "com.apple.net.utun_control".withCString { strncpy(ptr.baseAddress!.assumingMemoryBound(to: CChar.self), $0, ptr.count - 1) } } // CTLIOCGINFO = _IOWR('N', 3, struct ctl_info)(Swift 不导出该宏,按定义算) let ctliocginfo: UInt = 0x8000_0000 | 0x4000_0000 | ((UInt(MemoryLayout.stride) & 0x1fff) << 16) | (UInt(UInt8(ascii: "N")) << 8) | 3 for fd in Int32(0)...1024 { var addr = sockaddr_ctl() var len = socklen_t(MemoryLayout.size) let ok = withUnsafeMutablePointer(to: &addr) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { getpeername(fd, $0, &len) } } if ok == 0, addr.sc_family == AF_SYSTEM { if ioctl(fd, ctliocginfo, &ctlInfo) == 0, addr.sc_id == ctlInfo.ctl_id { return fd } } } return nil } private static func enumerateInterfaces() -> [LibboxNetworkInterface] { var result: [String: LibboxNetworkInterface] = [:] var head: UnsafeMutablePointer? guard getifaddrs(&head) == 0 else { return [] } defer { freeifaddrs(head) } var cur = head while let p = cur { let name = String(cString: p.pointee.ifa_name) if result[name] == nil { let i = LibboxNetworkInterface() i.name = name i.index = Int32(if_nametoindex(p.pointee.ifa_name)) result[name] = i } cur = p.pointee.ifa_next } return Array(result.values) } } // LibboxNetworkInterfaceIterator 适配器。 final class InterfaceIterator: NSObject, LibboxNetworkInterfaceIteratorProtocol { private var idx = 0 private let items: [LibboxNetworkInterface] init(items: [LibboxNetworkInterface]) { self.items = items } func hasNext() -> Bool { idx < items.count } func next() -> LibboxNetworkInterface? { guard idx < items.count else { return nil } defer { idx += 1 } return items[idx] } } private func simpleError(_ msg: String) -> NSError { NSError(domain: "pangolin.tunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: msg]) } // ── StatsCollector:扩展进程内统计采集 ────────────────────────────────────── // 上/下行速率:连本进程自己的 command.sock(libbox Status 命令)。 // urltest 延迟:经注入的 clash API(本地 HTTP)查 /proxies + /group//delay—— // 服务端配置无 clash_api,sing-box 不暴露组/urltest 历史,libbox Group 命令返回空; // 注入 clash_api 后用与 Windows 完全一致的取法(getGroupDelay)拿内核实测延迟。 // 缓存的 latest 由 handleAppMessage 回给主 app。 final class StatsCollector: NSObject { private let queue = DispatchQueue(label: "pangolin.stats.collector") private var statusClient: LibboxCommandClient? private var started = false private var clashTimer: DispatchSourceTimer? // clash API(扩展内注入,127.0.0.1 本地监听):取出站组 urltest 真实延迟。 private var clashBase = "" private var clashSecret = "" private let lock = NSLock() private var latest: [String: Any] = StatsCollector.zero private var latestUrltest: [(tag: String, delayMs: Int)] = [] private static let zero: [String: Any] = [ "uploadBytes": 0, "downloadBytes": 0, "uploadSpeed": 0.0, "downloadSpeed": 0.0, "urltestResults": [[String: Any]](), ] func start(clashPort: Int, clashSecret: String) { queue.async { [weak self] in guard let self, !self.started else { return } self.clashBase = "http://127.0.0.1:\(clashPort)" self.clashSecret = clashSecret self.statusClient = self.connectCommand(LibboxCommandStatus, "status") self.started = true self.startClashTimer() } } /// 为单一命令开一个 libbox client 并连上。command.sock 刚建好可能稍滞后,重试。 private func connectCommand(_ command: Int32, _ label: String) -> LibboxCommandClient? { let options = LibboxCommandClientOptions() options.statusInterval = 1_000_000_000 // 1s options.addCommand(command) guard let c = LibboxNewCommandClient(self, options) else { return nil } for _ in 0..<20 { do { try c.connect(); NSLog("[pangolin/collector] %@ connected", label); return c } catch { Thread.sleep(forTimeInterval: 0.25) } } NSLog("[pangolin/collector] %@ gave up", label) return nil } func stop() { queue.async { [weak self] in guard let self else { return } self.clashTimer?.cancel(); self.clashTimer = nil if let c = self.statusClient { try? c.disconnect() } self.statusClient = nil self.started = false self.lock.lock() self.latest = StatsCollector.zero self.latestUrltest = [] self.lock.unlock() } } func latestJSON() -> Data { lock.lock(); let snap = latest; lock.unlock() return (try? JSONSerialization.data(withJSONObject: snap)) ?? Data("{}".utf8) } // ── clash API 轮询:取 urltest 延迟(与 Windows getGroupDelay 同法)───────── private func startClashTimer() { let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + 3, repeating: 8) timer.setEventHandler { [weak self] in self?.pollClash() } timer.resume() clashTimer = timer } // 与 Windows(kernel_process.dart)完全同策略: // · 显示源 = /proxies 里各 URLTest 成员的 history.last.delay(每拍读,缓存住,稳定)。 // · /group//delay 只为「让内核重测并写进 history」,其结果也顺手缓存(双保险); // 单次探测失败不影响显示——下一拍仍读到 history 里上次成功的值。 private func pollClash() { clashGet("/proxies") { [weak self] obj in guard let self else { return } let hist = StatsCollector.extractUrltestResults(obj) let groups = StatsCollector.extractUrltestGroups(obj).filter { $0 != "GLOBAL" } self.queue.async { self.cacheUrltest(hist) for name in groups { self.clashGet("/group/\(name)/delay", query: ["url": "http://www.gstatic.com/generate_204", "timeout": "5000"]) { [weak self] dobj in guard let self else { return } let active = StatsCollector.parseDelayMap(dobj) self.queue.async { self.cacheUrltest(active) } } } } } } /// 缓存延迟(从 history 或 active 任一来源),非空才更新——失败/空不覆盖旧值。 private func cacheUrltest(_ list: [(tag: String, delayMs: Int)]) { guard !list.isEmpty else { return } lock.lock() latestUrltest = list latest["urltestResults"] = list.map { ["tag": $0.tag, "delayMs": $0.delayMs] } lock.unlock() } private func clashGet(_ path: String, query: [String: String]? = nil, completion: @escaping (Any?) -> Void) { guard var comps = URLComponents(string: clashBase + path) else { completion(nil); return } if let query { comps.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) } } guard let url = comps.url else { completion(nil); return } var req = URLRequest(url: url) req.timeoutInterval = 8 if !clashSecret.isEmpty { req.setValue("Bearer \(clashSecret)", forHTTPHeaderField: "Authorization") } URLSession.shared.dataTask(with: req) { data, _, _ in guard let data else { completion(nil); return } completion(try? JSONSerialization.jsonObject(with: data)) }.resume() } /// 从 /proxies 提取 URLTest/Fallback 组名(clash 约定类型名首字母大写)。 static func extractUrltestGroups(_ obj: Any?) -> [String] { guard let root = obj as? [String: Any], let proxies = root["proxies"] as? [String: Any] else { return [] } var names: [String] = [] for (name, v) in proxies { if let m = v as? [String: Any], let type = m["type"] as? String, type == "URLTest" || type == "Fallback" { names.append(name) } } return names } /// 从 /proxies 提取各 URLTest/Fallback 组成员的最新 history 延迟(与 Windows /// extractUrltestResults 字段完全一致:group.all → member.history.last.delay)。 static func extractUrltestResults(_ obj: Any?) -> [(tag: String, delayMs: Int)] { guard let root = obj as? [String: Any], let proxies = root["proxies"] as? [String: Any] else { return [] } var results: [(tag: String, delayMs: Int)] = [] for (_, v) in proxies { guard let group = v as? [String: Any], let type = group["type"] as? String, type == "URLTest" || type == "Fallback", let members = group["all"] as? [Any] else { continue } for m in members { guard let tag = m as? String, let member = proxies[tag] as? [String: Any], let history = member["history"] as? [Any], !history.isEmpty, let last = history.last as? [String: Any], let delay = (last["delay"] as? NSNumber)?.intValue, delay > 0 else { continue } results.append((tag: tag, delayMs: delay)) } } return results } /// 解析 /group//delay 的 {tag: ms} 响应(含嵌套 {tag:{delay:ms}}),只留正延迟。 static func parseDelayMap(_ obj: Any?) -> [(tag: String, delayMs: Int)] { guard let dict = obj as? [String: Any] else { return [] } var out: [(tag: String, delayMs: Int)] = [] for (tag, v) in dict { if let d = (v as? NSNumber)?.intValue, d > 0 { out.append((tag: tag, delayMs: d)) } else if let inner = v as? [String: Any], let d = (inner["delay"] as? NSNumber)?.intValue, d > 0 { out.append((tag: tag, delayMs: d)) } } return out } } extension StatsCollector: LibboxCommandClientHandlerProtocol { func connected() {} func disconnected(_ message: String?) {} func clearLogs() {} func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) {} func setDefaultLogLevel(_ level: Int32) {} func initializeClashMode(_ modeList: (any LibboxStringIteratorProtocol)?, currentMode: String?) {} func updateClashMode(_ newMode: String?) {} func write(_ events: LibboxConnectionEvents?) {} func writeStatus(_ message: LibboxStatusMessage?) { guard let m = message else { return } // 逐键更新(不整盘重建),保留 clash 轮询写入的 urltestResults。 lock.lock() latest["uploadBytes"] = m.uplinkTotal latest["downloadBytes"] = m.downlinkTotal latest["uploadSpeed"] = Double(m.uplink) latest["downloadSpeed"] = Double(m.downlink) lock.unlock() } func writeGroups(_ message: (any LibboxOutboundGroupIteratorProtocol)?) {} }