Files
pangolin/client/ios/PacketTunnel/PacketTunnelProvider.swift
T
wangjia e129f094c2 feat(ios): M3 PoC — PacketTunnelProvider + entitlement + 内存监控 [tsk_nsobbj_rJdy0]
### 新增文件
- client/ios/PacketTunnel/MemoryMonitor.swift
  周期打点 NE 进程内存(os_proc_available_memory iOS13+ / mach_task_basic_info 兼容
  iOS12);每 10s 采样一次;availableMemory < 5MB 时输出 ⚠️ 警告;提供
  summaryString() 供 handleAppMessage/getMemory IPC 返回。

- client/ios/PacketTunnel/PacketTunnel.entitlements
  Extension 的 NE + App Group entitlement 文件;申请说明写入注释。

- client/ios/Runner/Runner.entitlements
  主 App 的 NE + App Group entitlement 文件;申请说明写入注释。

- doc/ne-entitlement-申请指引.md
  完整申请操作手册:Bundle ID 清单、Apple Developer Portal 步骤、
  专项审批英文申请文案(已脱敏,无红线词)、审批 lead time 说明。

### 修改文件
- client/ios/PacketTunnel/PacketTunnelProvider.swift
  完整 M3 实现(替换原骨架 TODO):
  · startTunnel:解析 configJson(options/App Group 两路回退)→
    buildNetworkSettings → setTunnelNetworkSettings → setupTunBridge
    → startLibbox → startPacketBridging → memoryMonitor.start()
  · stopTunnel:memoryMonitor.stop() → stopLibbox → closeTunBridge
  · handleAppMessage:JSON IPC 协议(getStatus/getMemory/selectOutbound)
  · LibboxPlatformInterface 扩展(#if canImport(Libbox)):
    openTun 返回 socketpair libbox 侧 fd,autoDetectInterfaceControl no-op,
    writeLog 转 os.log,useProcFS 返回 false
  · packetFlow 双向桥接(readFromPacketFlow/writeToPacketFlow 循环)
  · parseTunAddress/parseDNSServers 从 JSON 提取网络参数

- client/ios/Runner/VpnManager.swift
  · localizedDescription "穿山甲 Pangolin VPN" → "Pangolin 加速"(红线词修复)
  · 新增 appGroup 常量(group.com.pangolin.pangolinVpn)
  · start() 写入 App Group UserDefaults 缓存 configJson

- client/ios/Runner/Info.plist
  NSVPNUsageDescription "穿山甲使用 VPN…" → "Pangolin 使用网络加速通道…"(红线词修复)

- client/ios/PacketTunnel/Info.plist
  CFBundleDisplayName "穿山甲 Tunnel" → "Pangolin 加速通道"(红线词修复)

- client/ios/Runner.xcodeproj/project.pbxproj
  · Runner + PacketTunnel 所有 build config 添加 CODE_SIGN_ENTITLEMENTS
  · 新增 MemoryMonitor.swift 到 PacketTunnel Sources 构建阶段
  · 新增 Runner.entitlements / PacketTunnel.entitlements / MemoryMonitor.swift
    文件引用及分组

- app/kernel/build-ios.sh
  添加 M3 内存裁剪决策记录:gVisor→grpc→QUIC 优先裁减顺序及内存估算,
  等待真机 10min 压测结果后更新最终结论

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:44:11 +08:00

522 lines
20 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.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PacketTunnelProvider.swift Packet Tunnel Extension M3 PoC
//
// Target: PacketTunnel (NEPacketTunnelProvider extension)
// Bundle: com.pangolin.pangolinVpn.PacketTunnel
// Entitlement: com.apple.developer.networking.networkextension / packet-tunnel-provider
//
//
// startTunnel configJson NEPacketTunnelNetworkSettings
// LibboxNewServicelibbox/sing-box
// packetFlow socketpair libbox TUN fd
// MemoryMonitor
// stopTunnel MemoryMonitor
// handleAppMessage JSON IPCgetStatus / getMemory / selectOutbound
//
// libbox
// app/kernel/dist/ios/Libbox.xcframework
// gomobile bind ./experimental/libbox ObjC
// - LibboxNewService(configContent:platformInterface:disableMemoryLimit:error:)
// - LibboxService.start() / .stop()
// - LibboxPlatformInterface (ObjC protocol)
// - LibboxTunOptions (ObjC class, fields: inet4Address, inet6Address, mtu, autoRoute, )
//
//
// #if canImport(Libbox) / Release
//
//
// NE ~15MB ~50MB
// MemoryMonitor 10s < 5MB
// jetsam app/kernel/build-ios.sh
// build tags with_gvisor with_grpc XCFramework
//
// IPC handleAppMessage / sendProviderMessage
// JSON{ "action": "getStatus" | "getMemory" | "selectOutbound", "tag"?: "xxx" }
// JSON{ "status": "...", "memory": "...", "error": "..." }
//
// 线
// localizedDescription VpnManager.swift
import NetworkExtension
import Darwin
import Foundation
import os.log
#if canImport(Libbox)
import Libbox
#endif
// MARK: PacketTunnelProvider
class PacketTunnelProvider: NEPacketTunnelProvider {
//
private let log = OSLog(
subsystem: "com.pangolin.pangolinVpn.PacketTunnel",
category: "PacketTunnel"
)
private let appGroup = "group.com.pangolin.pangolinVpn"
/// NE IPv4 退 config tun.address
private let defaultTunAddr = "172.19.0.1"
private let defaultTunMask = "255.255.255.0"
/// DNS sing-box DNS
private let defaultDNS = "198.18.0.1"
/// MTUNE 1500sing-box tun 9000 - NE
private let tunMTU: NSNumber = 1500
//
private let memoryMonitor = MemoryMonitor()
#if canImport(Libbox)
private var libboxService: LibboxService?
#endif
/// socketpair[0]=NE[1]=libbox
private var tunFDs: (ne: Int32, libbox: Int32) = (-1, -1)
private var isStopped = false
private let stopLock = NSLock()
/// selectOutbound tagIPC
private var activeOutboundTag: String = "auto"
// NEPacketTunnelProvider
override func startTunnel(
options: [String: NSObject]?,
completionHandler: @escaping (Error?) -> Void
) {
os_log("PacketTunnelProvider: startTunnel begin", log: log, type: .info)
stopLock.lock()
isStopped = false
stopLock.unlock()
// 1. configJson options App Group UserDefaults 退
let configJson: String
if let opt = options?["config"] as? String, !opt.isEmpty {
configJson = opt
} else if let shared = UserDefaults(suiteName: appGroup),
let cached = shared.string(forKey: "lastConfigJson"),
!cached.isEmpty {
configJson = cached
os_log("PacketTunnelProvider: 从 App Group 读取缓存 configJson", log: log, type: .info)
} else {
os_log("PacketTunnelProvider: 无 configJson,使用内置最小配置", log: log, type: .error)
configJson = minimalFallbackConfig()
}
// App Group NE
if let shared = UserDefaults(suiteName: appGroup) {
shared.set(configJson, forKey: "lastConfigJson")
}
// 2. JSON tun.address
let tunAddress = parseTunAddress(from: configJson) ?? defaultTunAddr
let dnsServers = parseDNSServers(from: configJson) ?? [defaultDNS]
// 3. NEPacketTunnelNetworkSettings
let settings = buildNetworkSettings(tunAddress: tunAddress, dnsServers: dnsServers)
setTunnelNetworkSettings(settings) { [weak self] error in
guard let self = self else { return }
if let error = error {
os_log("PacketTunnelProvider: setTunnelNetworkSettings 失败: %{public}@",
self.log, type: .error, error.localizedDescription)
completionHandler(error)
return
}
os_log("PacketTunnelProvider: 网络设置已应用", self.log, type: .info)
// 4. socketpair TUN
do {
try self.setupTunBridge()
} catch {
os_log("PacketTunnelProvider: socketpair 失败: %{public}@",
self.log, type: .error, error.localizedDescription)
completionHandler(error)
return
}
// 5. & libbox
do {
try self.startLibbox(configJson: configJson)
} catch {
os_log("PacketTunnelProvider: libbox 启动失败: %{public}@",
self.log, type: .error, error.localizedDescription)
completionHandler(error)
return
}
// 6.
self.startPacketBridging()
// 7.
self.memoryMonitor.start()
os_log("PacketTunnelProvider: 隧道已就绪", self.log, type: .info)
completionHandler(nil)
}
}
override func stopTunnel(
with reason: NEProviderStopReason,
completionHandler: @escaping () -> Void
) {
os_log("PacketTunnelProvider: stopTunnel reason=%d", log: log, type: .info, reason.rawValue)
stopLock.lock()
isStopped = true
stopLock.unlock()
//
memoryMonitor.stop()
os_log("PacketTunnelProvider: 内存峰值摘要: %{public}@",
log, type: .info, memoryMonitor.summaryString())
// libbox
stopLibbox()
// socketpair
closeTunBridge()
completionHandler()
}
override func handleAppMessage(
_ messageData: Data,
completionHandler: ((Data?) -> Void)?
) {
guard let request = try? JSONSerialization.jsonObject(with: messageData) as? [String: Any],
let action = request["action"] as? String else {
os_log("PacketTunnelProvider: handleAppMessage 无法解析 JSON",
log: log, type: .error)
completionHandler?(nil)
return
}
os_log("PacketTunnelProvider: IPC action=%{public}@", log: log, type: .debug, action)
var response: [String: Any] = [:]
switch action {
case "getStatus":
// libbox "on" "error"
let running: Bool
#if canImport(Libbox)
running = libboxService != nil
#else
running = false
#endif
response["status"] = running ? "on" : "error"
case "getMemory":
// App
response["memory"] = memoryMonitor.summaryString()
response["peakRssBytes"] = memoryMonitor.peakResidentBytes
response["minAvailableBytes"] = memoryMonitor.minAvailableBytes
response["sampleCount"] = memoryMonitor.sampleCount
case "selectOutbound":
// tag libbox Clash API
let tag = request["tag"] as? String ?? "auto"
activeOutboundTag = tag
selectLibboxOutbound(tag: tag)
response["activeOutbound"] = tag
case "getActiveOutbound":
response["activeOutbound"] = activeOutboundTag
default:
response["error"] = "unknown action: \(action)"
}
if let data = try? JSONSerialization.data(withJSONObject: response) {
completionHandler?(data)
} else {
completionHandler?(nil)
}
}
override func sleep(completionHandler: @escaping () -> Void) {
os_log("PacketTunnelProvider: sleep(挂起流量处理)", log: log, type: .debug)
memoryMonitor.stop()
completionHandler()
}
override func wake() {
os_log("PacketTunnelProvider: wake(恢复流量处理)", log: log, type: .debug)
memoryMonitor.start()
}
// MARK:
private func buildNetworkSettings(
tunAddress: String,
dnsServers: [String]
) -> NEPacketTunnelNetworkSettings {
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
// IPv4 0.0.0.0/0
let ipv4 = NEIPv4Settings(addresses: [tunAddress], subnetMasks: [defaultTunMask])
ipv4.includedRoutes = [NEIPv4Route.default()]
ipv4.excludedRoutes = []
settings.ipv4Settings = ipv4
// DNS sing-box fake-ip/tun DNS
let dns = NEDNSSettings(servers: dnsServers)
dns.matchDomains = [""] // DNS
settings.dnsSettings = dns
settings.mtu = tunMTU
return settings
}
// MARK: TUN socketpair
private func setupTunBridge() throws {
var fds: [Int32] = [-1, -1]
// AF_UNIX SOCK_DGRAM read/write IP
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, &fds)
guard ret == 0 else {
throw makePosixError("socketpair 失败")
}
// socket 4 MB
let bufSize: Int32 = 4 * 1024 * 1024
setsockopt(fds[0], SOL_SOCKET, SO_SNDBUF, &bufSize, socklen_t(MemoryLayout<Int32>.size))
setsockopt(fds[0], SOL_SOCKET, SO_RCVBUF, &bufSize, socklen_t(MemoryLayout<Int32>.size))
setsockopt(fds[1], SOL_SOCKET, SO_SNDBUF, &bufSize, socklen_t(MemoryLayout<Int32>.size))
setsockopt(fds[1], SOL_SOCKET, SO_RCVBUF, &bufSize, socklen_t(MemoryLayout<Int32>.size))
tunFDs = (ne: fds[0], libbox: fds[1])
os_log("PacketTunnelProvider: socketpair 创建成功 ne=%d libbox=%d",
log: log, type: .debug, fds[0], fds[1])
}
private func closeTunBridge() {
if tunFDs.ne >= 0 {
close(tunFDs.ne)
tunFDs.ne = -1
}
if tunFDs.libbox >= 0 {
close(tunFDs.libbox)
tunFDs.libbox = -1
}
}
// MARK: packetFlow socketpair libbox
private func startPacketBridging() {
let neFD = tunFDs.ne
guard neFD >= 0 else { return }
// OutgoingApp packetFlow write to neFD libbox
readFromPacketFlow(neFD: neFD)
// Incoming Applibbox read from neFD packetFlow
writeToPacketFlow(neFD: neFD)
}
/// NE packetFlow socketpair NE libbox
private func readFromPacketFlow(neFD: Int32) {
packetFlow.readPacketObjects { [weak self] packets in
guard let self = self else { return }
self.stopLock.lock()
let stopped = self.isStopped
self.stopLock.unlock()
guard !stopped else { return }
for packet in packets {
let data = packet.data
_ = data.withUnsafeBytes { ptr -> Int in
guard let base = ptr.baseAddress else { return 0 }
return write(neFD, base, data.count)
}
}
//
self.readFromPacketFlow(neFD: neFD)
}
}
/// socketpair NE libbox NE packetFlow
private func writeToPacketFlow(neFD: Int32) {
let bufSize = 65536
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return }
var buffer = [UInt8](repeating: 0, count: bufSize)
while true {
self.stopLock.lock()
let stopped = self.isStopped
self.stopLock.unlock()
guard !stopped else { break }
let n = read(neFD, &buffer, bufSize)
if n <= 0 {
if errno == EINTR { continue }
break
}
let data = Data(buffer[..<n])
// IP
let version = (buffer[0] & 0xF0) >> 4
let proto: NSNumber = version == 6
? NSNumber(value: AF_INET6)
: NSNumber(value: AF_INET)
self.packetFlow.writePackets([data], withProtocols: [proto])
}
os_log("PacketTunnelProvider: writeToPacketFlow 退出循环", self.log, type: .debug)
}
}
// MARK: libbox
private func startLibbox(configJson: String) throws {
#if canImport(Libbox)
var serviceError: NSError? = nil
let svc = LibboxNewService(configJson, self, false, &serviceError)
if let err = serviceError { throw err }
guard let svc else {
throw makeError("LibboxNewService 返回 nil")
}
var startError: NSError? = nil
svc.start(&startError)
if let err = startError { throw err }
libboxService = svc
os_log("PacketTunnelProvider: libbox 内核已启动", log: log, type: .info)
#else
os_log("PacketTunnelProvider: Libbox 未链接,骨架模式运行", log: log, type: .fault)
// NE "on" UI
#endif
}
private func stopLibbox() {
#if canImport(Libbox)
guard let svc = libboxService else { return }
var stopError: NSError? = nil
svc.stop(&stopError)
if let err = stopError {
os_log("PacketTunnelProvider: libbox stop 错误: %{public}@",
log: log, type: .error, err.localizedDescription)
}
libboxService = nil
os_log("PacketTunnelProvider: libbox 内核已停止", log: log, type: .info)
#endif
}
private func selectLibboxOutbound(tag: String) {
#if canImport(Libbox)
// sing-box Clash API selectOutbound libbox HTTP
// libbox LibboxClashServer
os_log("PacketTunnelProvider: selectOutbound tag=%{public}@", log: log, type: .info, tag)
// TODO(11G): libboxService outbound selector APIsing-box v1.13+
#endif
}
// MARK:
/// configJson tun.inet4_address "172.19.0.1/30"
private func parseTunAddress(from json: String) -> String? {
guard let data = json.data(using: .utf8),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let tun = root["tun"] as? [String: Any] else { return nil }
// inet4_address
if let addrStr = tun["inet4_address"] as? String {
return addrStr.components(separatedBy: "/").first
}
if let addrArr = tun["inet4_address"] as? [String],
let first = addrArr.first {
return first.components(separatedBy: "/").first
}
return nil
}
/// configJson dns.servers address
private func parseDNSServers(from json: String) -> [String]? {
guard let data = json.data(using: .utf8),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let dns = root["dns"] as? [String: Any],
let servers = dns["servers"] as? [[String: Any]] else { return nil }
let addrs = servers.compactMap { $0["address"] as? String }
return addrs.isEmpty ? nil : addrs
}
/// 退 pass-through
private func minimalFallbackConfig() -> String {
return """
{
"log": {"level": "info"},
"dns": {"servers": [{"address": "\(defaultDNS)"}]},
"inbounds": [],
"outbounds": [{"type": "direct", "tag": "direct"}],
"tun": {
"enabled": true,
"inet4_address": "\(defaultTunAddr)/24",
"auto_route": true,
"mtu": 1500
}
}
"""
}
// MARK:
private func makeError(_ message: String) -> NSError {
NSError(domain: "PacketTunnelProvider", code: -1,
userInfo: [NSLocalizedDescriptionKey: message])
}
private func makePosixError(_ prefix: String) -> NSError {
NSError(domain: NSPOSIXErrorDomain, code: Int(errno),
userInfo: [NSLocalizedDescriptionKey: "\(prefix): \(String(cString: strerror(errno)))"])
}
}
// MARK: LibboxPlatformInterface
// Libbox.xcframework
// ObjC LibboxPlatformInterfacegomobile experimental/libbox
#if canImport(Libbox)
extension PacketTunnelProvider: LibboxPlatformInterface {
/// SO_MARK / setsockopt
/// iOS NE no-op
func autoDetectInterfaceControl(_ fd: Int32) throws {
// NE SO_MARK
}
/// sing-box
/// NE 访 nil
func findProcessInfo(
_ network: String?,
source: String?,
destination: String?,
uid: Int32
) throws -> LibboxProcessInfo? {
return nil
}
/// TUN fd
/// NE
/// 1. LibboxTunOptions startTunnel
/// 2. libbox fd read/write fd IP
/// 3. NE fd packetFlow
func openTun(_ options: LibboxTunOptions?) throws -> Int32 {
os_log("PacketTunnelProvider: openTun called (mtu=%d)", log: log, type: .info,
options?.mtu ?? 0)
guard tunFDs.libbox >= 0 else {
throw makeError("openTun: socketpair 未初始化")
}
return tunFDs.libbox
}
/// 使 /proc/netiOS procfs
func useProcFS() -> Bool { return false }
/// libbox os.log
func writeLog(_ message: String?) {
guard let msg = message, !msg.isEmpty else { return }
os_log("libbox: %{public}@", log: log, type: .debug, msg)
}
/// DNS iOS DNS no-op
func clearDNSCache() { }
}
#endif