// 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 { let configContent = try self.resolveConfig(options) // 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 采集:连本进程自己的 command.sock(同容器,root 可达), // 缓存最新上/下行 + urltest,供 handleAppMessage 回给主 app。 self.stats.start() 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) } } 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:扩展进程内的 libbox 命令客户端 ─────────────────────────── // 连「本进程自己的」command.sock(同容器、root 可达,无跨进程/跨用户问题),订阅 // status/group,缓存最新上/下行 + urltest;由 handleAppMessage 回给主 app。等价于旧的 // 主 app 端 StatsClient,但跑在扩展里——根治主 app 跨 root/用户容器连不上 socket 的死局。 final class StatsCollector: NSObject { private let queue = DispatchQueue(label: "pangolin.stats.collector") private var client: LibboxCommandClient? private var started = false private var urlTestTimer: DispatchSourceTimer? private let lock = NSLock() private var latest: [String: Any] = StatsCollector.zero private var latestUrltest: [(tag: String, delayMs: Int)] = [] private var groupTags: [String] = [] private static let zero: [String: Any] = [ "uploadBytes": 0, "downloadBytes": 0, "uploadSpeed": 0.0, "downloadSpeed": 0.0, "urltestResults": [[String: Any]](), ] func start() { queue.async { [weak self] in guard let self, !self.started else { return } let options = LibboxCommandClientOptions() options.statusInterval = 1_000_000_000 // 1s options.addCommand(LibboxCommandStatus) options.addCommand(LibboxCommandGroup) guard let c = LibboxNewCommandClient(self, options) else { NSLog("[pangolin/collector] LibboxNewCommandClient nil") return } // command.sock 刚由 server.start() 建好,可能稍滞后,重试到连上。 for _ in 0..<20 { do { try c.connect() self.client = c self.started = true self.startUrlTestTimer() NSLog("[pangolin/collector] connected to own command.sock") return } catch { Thread.sleep(forTimeInterval: 0.25) } } NSLog("[pangolin/collector] gave up connecting to own command.sock") } } func stop() { queue.async { [weak self] in guard let self else { return } self.urlTestTimer?.cancel(); self.urlTestTimer = nil if let c = self.client { try? c.disconnect() } self.client = nil self.started = false self.lock.lock() self.latest = StatsCollector.zero self.latestUrltest = [] self.groupTags = [] self.lock.unlock() } } func latestJSON() -> Data { lock.lock(); let snap = latest; lock.unlock() return (try? JSONSerialization.data(withJSONObject: snap)) ?? Data("{}".utf8) } private func startUrlTestTimer() { urlTestTimer?.cancel() let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + 2, repeating: 12) timer.setEventHandler { [weak self] in guard let self, let c = self.client else { return } for tag in self.groupTags { try? c.urlTest(tag) } } timer.resume() urlTestTimer = timer } } 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 } lock.lock() latest = [ "uploadBytes": m.uplinkTotal, "downloadBytes": m.downlinkTotal, "uploadSpeed": Double(m.uplink), "downloadSpeed": Double(m.downlink), "urltestResults": latestUrltest.map { ["tag": $0.tag, "delayMs": $0.delayMs] }, ] lock.unlock() } func writeGroups(_ message: (any LibboxOutboundGroupIteratorProtocol)?) { guard let groups = message else { return } var urltest: [(tag: String, delayMs: Int)] = [] var tags: [String] = [] while groups.hasNext() { guard let group = groups.next() else { break } tags.append(group.tag) guard let items = group.getItems() else { continue } while items.hasNext() { guard let item = items.next() else { break } urltest.append((tag: item.tag, delayMs: Int(item.urlTestDelay))) } } lock.lock() latestUrltest = urltest latest["urltestResults"] = urltest.map { ["tag": $0.tag, "delayMs": $0.delayMs] } lock.unlock() queue.async { [weak self] in self?.groupTags = tags } } }