Files
pangolin/client/macos/PacketTunnel/PacketTunnelProvider.swift
T
wangjia 71132df669 fix(client/macos): sysext 把 command.sock 建在登录用户容器并 chown,修实时统计断流
根因:系统扩展以 root 运行,其 App Group 容器是 /var/root/Library/Group Containers/...,
而用户 app 的是 /Users/<user>/...(容器 home 相对)。libbox 把 command.sock 建在 basePath
下 → 落在 root 容器;无 root 权限的 app 永远 connect 不上 → 连接页 — KB/s + 延迟不刷新。
(经 sudo ls 实测确认:socket 在 /var/root 容器,用户容器里没有。)

- PacketTunnelProvider 用 SCDynamicStoreCopyConsoleUser 取登录用户,把 libbox basePath/
  workingPath 指向该用户的同名容器(root 可写),command.sock 即落在 app 查找的路径。
- server.start() 后 chown 容器/work/command.sock 给登录用户:Unix socket connect() 需写
  权限,仅 owner 满足,否则 app 仍 EACCES。拿不到登录用户时回退 root 容器(隧道可用、仅统计不可用)。
- sysext 代码改动 → CURRENT_PROJECT_VERSION 43→44,让 sysextd 重装新扩展。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:43:41 +08:00

363 lines
17 KiB
Swift
Raw 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
import SystemConfiguration
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?
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)
// root , App Group /var/root/Library/Group
// Containers/..., app /Users/<user>/... ( home
// )libbox command.sock basePath root , root
// app connect ( KB/s + )
// : libbox (root ),
// socket chown ,使 app connect退
// root (,)
let consoleUser = Self.consoleUser()
let base: URL
if let u = consoleUser {
base = u.home
.appendingPathComponent("Library/Group Containers/\(appGroup)", isDirectory: true)
log.info("libbox base → 登录用户容器 \(base.path, privacy: .public)")
} else if let root = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroup) {
base = root
log.error("拿不到登录用户,回退 root 容器 \(base.path, privacy: .public)(app 连不上 command.sock)")
} 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()
// server.start() base command.sock(root )Unix socket
// connect() socket , owner /work/socket
// chown , root app root home root
// , chown app connect EACCES
if let u = consoleUser {
chown(base.path, u.uid, u.gid)
chown(work.path, u.uid, u.gid)
chown(base.appendingPathComponent("command.sock").path, u.uid, u.gid)
}
// options: libbox StartOrReloadService options,
// nil command_server.go:175 SIGSEGV()
try server.startOrReloadService(configContent, options: LibboxOverrideOptions())
self.commandServer = server
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)")
try? commandServer?.closeService()
commandServer?.close()
commandServer = nil
platform?.stopMonitor()
platform = nil
completionHandler()
}
override func handleAppMessage(_ messageData: Data,
completionHandler: ((Data?) -> Void)?) {
completionHandler?(nil)
}
// (GUI console user) home libbox
// , root(/var/root)(/) nil
private static func consoleUser() -> (uid: uid_t, gid: gid_t, home: URL)? {
guard let store = SCDynamicStoreCreate(nil, "com.pangolin.pangolin.PacketTunnel" as CFString, nil, nil) else {
return nil
}
var uid: uid_t = 0
var gid: gid_t = 0
guard let name = SCDynamicStoreCopyConsoleUser(store, &uid, &gid) as String?,
!name.isEmpty, name != "loginwindow", uid >= 500,
let pw = getpwuid(uid) else {
return nil
}
let home = URL(fileURLWithPath: String(cString: pw.pointee.pw_dir), isDirectory: true)
return (uid, gid, home)
}
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])
}