Files
pangolin/client/macos/PacketTunnel/PacketTunnelProvider.swift
wangjia a32d87c9c0 fix(macos): killswitch 撤掉 includeAllNetworks(堵死整机网络) — 改 enforceRoutes+on-demand
实测 includeAllNetworks=true 把所有流量(含 sing-box 连服务器握手、app 调控制面
请求)在隧道建起前就塞进隧道 → 握手出不去 → 连接失败 + on-demand 死循环 + 整机
断网(连「我的」页账户信息都拉不到、显示 —)。

改为不会误伤握手/控制面的组合:
- configureKillSwitch:includeAllNetworks 恒 false;保留 enforceRoutes(连接期防漏)
  + excludeLocalNetworks + NEOnDemandRule 常开(兜重连)
- 撤回 start() 的 killSwitch option 及扩展侧 includeAllNetworks 透传(回 false)
- 诚实标注:当前 L1→L2 之间,未到 includeAllNetworks-L3;要拿「扛进程被杀」须先
  在扩展内放行服务器/控制面连接,留待真机验证

docs/killswitch-design.html: §6.5 加踩坑修正、状态表 macOS 改「L1→L2 之间」

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:09:04 +08:00

571 lines
27 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PacketTunnelProvider.swift Pangolin macOS (B / P1)
//
// NEPacketTunnelProvider , sing-box(libbox) TUN, root
// docs/p1-macos-system-extension.md
//
// gomobile : Go protocol + class;Swift `XxxProtocol`,
// / classNE override completion-handler( Swift 6.2
// asyncObjC 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 <TeamID>.<name>( 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/<name>/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<Int32>?) 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<Int32>.size))
_ = setsockopt(fd, IPPROTO_IPV6, IPV6_BOUND_IF, &idx, socklen_t(MemoryLayout<Int32>.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<ctl_info>.stride) & 0x1fff) << 16)
| (UInt(UInt8(ascii: "N")) << 8) | 3
for fd in Int32(0)...1024 {
var addr = sockaddr_ctl()
var len = socklen_t(MemoryLayout<sockaddr_ctl>.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<ifaddrs>?
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/<name>/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/<name>/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/<name>/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)?) {}
}