diff --git a/app/kernel/build-ios.sh b/app/kernel/build-ios.sh index 69ae41b..c7ca244 100644 --- a/app/kernel/build-ios.sh +++ b/app/kernel/build-ios.sh @@ -55,6 +55,29 @@ BUILD_TAGS="with_quic,with_utls,with_clash_api,with_gvisor" # BUILD_TAGS="${BUILD_TAGS},with_ech" # BUILD_TAGS="${BUILD_TAGS},with_wireguard" +# ── M3 内存裁剪决策记录(tsk_nsobbj_rJdy0)──────────────────────────────────── +# NE 进程内存上限实测(MemoryMonitor 打点): +# 初始 build tags(with_quic,with_utls,with_clash_api,with_gvisor): +# 预估 RSS:~20-25 MB(含 Go runtime ~8MB + gVisor ~4MB + QUIC ~5MB + uTLS ~2MB + 基础~6MB) +# 旧设备(≤ A9)15MB 上限下会触顶;新设备(≥ A12)50MB 上限下可运行 +# +# 若真机测试出现 "NEProvider memory limit exceeded" / jetsam 杀进程,按以下顺序裁减: +# Step 1: 去掉 with_gvisor → 改用系统 TUN 网络栈;减约 2-4 MB +# 取消注释下一行: +# BUILD_TAGS="with_quic,with_utls,with_clash_api" +# Step 2: 去掉 with_grpc → 若不需要 gRPC 入站;减约 4 MB +# 取消注释下一行: +# BUILD_TAGS="with_quic,with_utls,with_clash_api" # 已无 gvisor,此行等效 +# Step 3: 去掉 with_quic → 仅在放弃 Hysteria2/QUIC 协议时考虑;减约 3 MB +# 取消注释下一行: +# BUILD_TAGS="with_utls,with_clash_api" +# +# 裁减后执行: ./build-ios.sh --force 重出 XCFramework,然后在 Xcode 中 +# 重新拖入 dist/ios/Libbox.xcframework(或更新 SPM/Pods 引用)。 +# 在此注释中记录最终选定的 build tags 及峰值 RSS(由 MemoryMonitor 汇报)。 +# +# 当前状态:等待真机 10min 压测结果(MemoryMonitor 每 10s 打点)。 + # ── 幂等检查 ───────────────────────────────────────────────────────────────── if [[ "${FORCE}" == false && -d "${OUT_XCFW}" ]]; then printf '✓ %s 已存在,跳过构建(传 --force 强制重建)\n' "${OUT_XCFW}" diff --git a/client/ios/PacketTunnel/Info.plist b/client/ios/PacketTunnel/Info.plist index 68c0a5b..4efbb54 100644 --- a/client/ios/PacketTunnel/Info.plist +++ b/client/ios/PacketTunnel/Info.plist @@ -2,8 +2,10 @@ + CFBundleDisplayName - 穿山甲 Tunnel + Pangolin 加速通道 CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/client/ios/PacketTunnel/MemoryMonitor.swift b/client/ios/PacketTunnel/MemoryMonitor.swift new file mode 100644 index 0000000..1356b58 --- /dev/null +++ b/client/ios/PacketTunnel/MemoryMonitor.swift @@ -0,0 +1,140 @@ +// MemoryMonitor.swift — NE 进程内存用量周期打点 +// +// 职责: +// 1. 周期调用 os_proc_available_memory()(iOS 13+)取 NE 进程剩余可分配内存。 +// 2. 通过 task_info(MACH_TASK_BASIC_INFO) 取当前 resident size(兼容 iOS 12)。 +// 3. 若 availableMemory < warningThreshold,输出 ⚠️ 警告日志。 +// 4. 10 分钟持续运行打点,为 M3 内存上限验收提供数据。 +// +// 使用: +// let monitor = MemoryMonitor() +// monitor.start() // 开始打点(启动隧道后调用) +// monitor.stop() // 停止打点(隧道停止后调用) +// monitor.peakResidentBytes // 读取峰值 +// +// 参考阈值(NE 进程 jetsam 限制): +// 旧设备(≤ A9):~15 MB residual limit +// 新设备(≥ A12):~50 MB residual limit +// 保守警告线:availableMemory < 5 MB 时认为高风险 + +import Darwin +import Foundation +import os.log + +// ── os_proc_available_memory 声明(iOS 13+ libSystem 符号,低版本回退到 mach)── +// Swift 无法直接引用 os/proc.h 中的符号,用 @_silgen_name 桥接。 +@_silgen_name("os_proc_available_memory") +private func _os_proc_available_memory() -> UInt64 + +// MARK: – MemoryMonitor + +final class MemoryMonitor { + + // ── 配置 ──────────────────────────────────────────────────── + /// 打点间隔(默认 10 秒,可在单测中缩短) + let interval: TimeInterval + /// 剩余内存警告阈值(默认 5 MB) + let warningThreshold: UInt64 + /// os.log 子系统 + private let log: OSLog + + // ── 状态 ──────────────────────────────────────────────────── + private var timer: DispatchSourceTimer? + private let queue = DispatchQueue(label: "com.pangolin.memory-monitor", qos: .utility) + private(set) var peakResidentBytes: UInt64 = 0 + private(set) var minAvailableBytes: UInt64 = UInt64.max + private(set) var sampleCount: Int = 0 + + init( + interval: TimeInterval = 10, + warningThreshold: UInt64 = 5 * 1024 * 1024, + subsystem: String = "com.pangolin.pangolinVpn.PacketTunnel" + ) { + self.interval = interval + self.warningThreshold = warningThreshold + self.log = OSLog(subsystem: subsystem, category: "MemoryMonitor") + } + + // MARK: – 控制 + + func start() { + stop() + let t = DispatchSource.makeTimerSource(queue: queue) + t.schedule(deadline: .now(), repeating: interval, leeway: .seconds(1)) + t.setEventHandler { [weak self] in self?.tick() } + timer = t + t.resume() + os_log("MemoryMonitor: 开始打点,间隔=%.0fs", log: log, type: .info, interval) + } + + func stop() { + timer?.cancel() + timer = nil + os_log( + "MemoryMonitor: 停止打点。总采样 %d 次 | 峰值 RSS=%.1f MB | 最小可用=%.1f MB", + log: log, type: .info, + sampleCount, + Double(peakResidentBytes) / 1_048_576, + minAvailableBytes == UInt64.max ? -1.0 : Double(minAvailableBytes) / 1_048_576 + ) + } + + // MARK: – 采样 + + private func tick() { + let resident = residentBytes() + let available = availableBytes() + + sampleCount += 1 + if resident > peakResidentBytes { peakResidentBytes = resident } + if available < minAvailableBytes { minAvailableBytes = available } + + let level: OSLogType = available < warningThreshold ? .error : .info + os_log( + "MemoryMonitor [#%d] RSS=%.1f MB | 可用=%.1f MB%{public}@", + log: log, type: level, + sampleCount, + Double(resident) / 1_048_576, + Double(available) / 1_048_576, + available < warningThreshold ? " ⚠️ 接近上限,建议裁剪 build tags" : "" + ) + } + + // MARK: – 底层读取 + + /// 当前进程 resident size(RSS),iOS 12+ 兼容。 + func residentBytes() -> UInt64 { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let result: kern_return_t = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + return result == KERN_SUCCESS ? UInt64(info.resident_size) : 0 + } + + /// NE 进程剩余可分配内存。 + /// iOS 13+:调用 os_proc_available_memory()。 + /// iOS 12:退而估算 MAX_RSS - resident(不精确,仅供参考)。 + func availableBytes() -> UInt64 { + if #available(iOS 13.0, *) { + return _os_proc_available_memory() + } else { + // iOS 12 回退:用 15 MB 保守上限 - 当前 RSS + let conservativeLimit: UInt64 = 15 * 1024 * 1024 + let resident = residentBytes() + return resident < conservativeLimit ? conservativeLimit - resident : 0 + } + } + + /// 生成一行摘要字符串(供 handleAppMessage/getStats 返回)。 + func summaryString() -> String { + let rss = Double(residentBytes()) / 1_048_576 + let avail = Double(availableBytes()) / 1_048_576 + return String(format: "RSS=%.1fMB avail=%.1fMB peak=%.1fMB samples=%d", + rss, avail, + Double(peakResidentBytes) / 1_048_576, + sampleCount) + } +} diff --git a/client/ios/PacketTunnel/PacketTunnel.entitlements b/client/ios/PacketTunnel/PacketTunnel.entitlements new file mode 100644 index 0000000..df58bc1 --- /dev/null +++ b/client/ios/PacketTunnel/PacketTunnel.entitlements @@ -0,0 +1,31 @@ + + + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + + + com.apple.security.application-groups + + group.com.pangolin.pangolinVpn + + + diff --git a/client/ios/PacketTunnel/PacketTunnelProvider.swift b/client/ios/PacketTunnel/PacketTunnelProvider.swift index 9a46fa0..f22a214 100644 --- a/client/ios/PacketTunnel/PacketTunnelProvider.swift +++ b/client/ios/PacketTunnel/PacketTunnelProvider.swift @@ -1,25 +1,82 @@ -// PacketTunnelProvider.swift — Packet Tunnel Extension 骨架 +// 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 // -// 生命周期占位日志;实际 TUN 隧道建立与 libbox 接线由 11F 完成。 +// 架构概述: +// startTunnel → 解析 configJson → NEPacketTunnelNetworkSettings +// → LibboxNewService(libbox/sing-box 内核)→ 启动内核 +// → 包流桥接(packetFlow ↔ socketpair ↔ libbox TUN fd) +// → MemoryMonitor 开始打点 +// stopTunnel → 内核干净停止 → MemoryMonitor 输出摘要 +// handleAppMessage → JSON IPC:getStatus / getMemory / selectOutbound // -// 与主 App 通信: -// 主 App → extension: NETunnelProviderSession.sendProviderMessage(_:responseHandler:) -// extension → 主 App: 通过 NEProvider.cancelTunnelWithError 或 NETunnelProviderSession 回调 +// 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, …) // -// TODO(11F): 在 startTunnel 中初始化 sing-box/libbox TUN 模式内核。 +// 条件编译: +// #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: "VPN" + 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" + /// MTU(NE 建议值 1500,sing-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 的 tag(IPC 查询用) + private var activeOutboundTag: String = "auto" // ── NEPacketTunnelProvider 生命周期 ────────────────────────── @@ -27,27 +84,98 @@ class PacketTunnelProvider: NEPacketTunnelProvider { options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void ) { - os_log( - "PacketTunnelProvider: startTunnel — options keys: %{public}@", - log: log, type: .info, - (options?.keys.joined(separator: ", ") ?? "(none)") - ) - // TODO(11F): 解析 options["config"] → 写入临时配置文件 - // → 调用 libbox/sing-box TUN 启动 API - // → 配置 packetFlow / setTunnelNetworkSettings - completionHandler(nil) + 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 - ) - // TODO(11F): 停止 sing-box 内核 → 关闭 TUN fd + 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() } @@ -55,24 +183,339 @@ class PacketTunnelProvider: NEPacketTunnelProvider { _ messageData: Data, completionHandler: ((Data?) -> Void)? ) { - os_log( - "PacketTunnelProvider: handleAppMessage — %d bytes", - log: log, type: .debug, - messageData.count - ) - // TODO(11F): 解码 messageData(JSON 或 protobuf)→ 路由到内核命令 - // 如 selectOutbound / getStats 等 - completionHandler?(nil) + 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) - // TODO(11F): 挂起内核流量处理 + os_log("PacketTunnelProvider: sleep(挂起流量处理)", log: log, type: .debug) + memoryMonitor.stop() completionHandler() } override func wake() { - os_log("PacketTunnelProvider: wake", log: log, type: .debug) - // TODO(11F): 恢复内核流量处理 + 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.size)) + setsockopt(fds[0], SOL_SOCKET, SO_RCVBUF, &bufSize, socklen_t(MemoryLayout.size)) + setsockopt(fds[1], SOL_SOCKET, SO_SNDBUF, &bufSize, socklen_t(MemoryLayout.size)) + setsockopt(fds[1], SOL_SOCKET, SO_RCVBUF, &bufSize, socklen_t(MemoryLayout.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 } + + // Outgoing(App → 外网):packetFlow → write to neFD → libbox 读 + readFromPacketFlow(neFD: neFD) + + // Incoming(外网 → App):libbox 写 → 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[..> 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 API(sing-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 协议名:LibboxPlatformInterface(gomobile 生成,见 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/net(iOS 无 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 diff --git a/client/ios/Runner.xcodeproj/project.pbxproj b/client/ios/Runner.xcodeproj/project.pbxproj index dd620b3..dc94882 100644 --- a/client/ios/Runner.xcodeproj/project.pbxproj +++ b/client/ios/Runner.xcodeproj/project.pbxproj @@ -21,6 +21,8 @@ CAFE000000000003000000AB /* Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = CAFE000000000003000000AA /* Info.plist */; }; /* PacketTunnel.appex embedded in Runner */ CAFE000000000004000000AB /* PacketTunnel.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CAFE000000000004000000AA /* PacketTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + /* MemoryMonitor.swift in PacketTunnel Sources */ + CAFE000000000006000000AC /* MemoryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE000000000006000000AB /* MemoryMonitor.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -78,6 +80,12 @@ CAFE000000000003000000AA /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* PacketTunnel.appex product */ CAFE000000000004000000AA /* PacketTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PacketTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + /* Runner.entitlements */ + CAFE000000000005000000AA /* Runner.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + /* PacketTunnel.entitlements */ + CAFE000000000006000000AA /* PacketTunnel.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = PacketTunnel.entitlements; sourceTree = ""; }; + /* MemoryMonitor.swift (PacketTunnel) */ + CAFE000000000006000000AB /* MemoryMonitor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MemoryMonitor.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -137,6 +145,7 @@ 97C146F11CF9000F007C117D /* Info.plist */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, CAFE000000000001000000AA /* VpnManager.swift */, + CAFE000000000005000000AA /* Runner.entitlements */, 1498D2311E8E86230040F4C2 /* GeneratedPluginRegistrant.m */, 1771E259F7494EBC8BB1F97A /* GeneratedPluginRegistrant.h */, ); @@ -147,6 +156,8 @@ isa = PBXGroup; children = ( CAFE000000000002000000AA /* PacketTunnelProvider.swift */, + CAFE000000000006000000AB /* MemoryMonitor.swift */, + CAFE000000000006000000AA /* PacketTunnel.entitlements */, CAFE000000000003000000AA /* Info.plist */, ); path = PacketTunnel; @@ -303,6 +314,7 @@ buildActionMask = 2147483647; files = ( CAFE000000000002000000AB /* PacketTunnelProvider.swift in Sources */, + CAFE000000000006000000AC /* MemoryMonitor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -373,6 +385,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -502,6 +515,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -524,6 +538,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -544,6 +559,7 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; INFOPLIST_FILE = PacketTunnel/Info.plist; @@ -567,6 +583,7 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; INFOPLIST_FILE = PacketTunnel/Info.plist; @@ -590,6 +607,7 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; INFOPLIST_FILE = PacketTunnel/Info.plist; diff --git a/client/ios/Runner/Info.plist b/client/ios/Runner/Info.plist index d665060..ba703ee 100644 --- a/client/ios/Runner/Info.plist +++ b/client/ios/Runner/Info.plist @@ -47,8 +47,9 @@ UIApplicationSupportsIndirectInputEvents - + NSVPNUsageDescription - 穿山甲使用 VPN 为您的网络连接提供加密保护。 + Pangolin 使用网络加速通道为您的连接提供安全保护。 diff --git a/client/ios/Runner/Runner.entitlements b/client/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..9699003 --- /dev/null +++ b/client/ios/Runner/Runner.entitlements @@ -0,0 +1,33 @@ + + + + + + + com.apple.developer.networking.networkextension + + packet-tunnel-provider + + + + com.apple.security.application-groups + + group.com.pangolin.pangolinVpn + + + diff --git a/client/ios/Runner/VpnManager.swift b/client/ios/Runner/VpnManager.swift index 447e93a..039930d 100644 --- a/client/ios/Runner/VpnManager.swift +++ b/client/ios/Runner/VpnManager.swift @@ -1,14 +1,15 @@ -// VpnManager.swift — 主 App 侧 NETunnelProviderManager 封装 +// VpnManager.swift — 主 App 侧 NETunnelProviderManager 封装(M3 PoC 实现) // // 职责: // 1. loadAllFromPreferences / saveToPreferences — 首次安装时建立 VPN 配置条目 // 2. start / stop — 通过 NETunnelProviderSession 驱动 PacketTunnel extension // 3. sendProviderMessage — 透传 IPC 消息到 PacketTunnelProvider.handleAppMessage +// 4. NEVPNStatus 订阅 → VpnStatusStreamHandler.shared 推送到 Dart 侧 // // AppDelegate 持有本类单例,在 MethodChannel handler 中调用其方法。 // -// TODO(11F): 实现 start/stop 前的证书/权限检查;订阅 NEVPNStatus 通知并 -// 通过 VpnStatusStreamHandler.shared.eventSink 推送到 Dart 侧。 +// 红线词检查(⚠️ 产品文案不得出现:VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / +// Go anywhere):localizedDescription 使用「Pangolin 加速」口径。 import Foundation import NetworkExtension @@ -65,6 +66,8 @@ class VpnManager: NSObject { // Bundle ID 与 PacketTunnel target PRODUCT_BUNDLE_IDENTIFIER 保持同步 private let extensionBundleId = "com.pangolin.pangolinVpn.PacketTunnel" + // App Group:与 PacketTunnel extension 共享 UserDefaults 容器 + private let appGroup = "group.com.pangolin.pangolinVpn" private var statusObserver: NSObjectProtocol? @@ -99,7 +102,8 @@ class VpnManager: NSObject { proto.providerBundleIdentifier = extensionBundleId proto.serverAddress = serverAddress manager.protocolConfiguration = proto - manager.localizedDescription = "穿山甲 Pangolin VPN" + // ⚠️ 红线词规范:localizedDescription 用「加速」口径,不得出现 VPN/翻墙 等词。 + manager.localizedDescription = "Pangolin 加速" manager.isEnabled = true manager.saveToPreferences { [weak self] error in if error == nil { self?.tunnelManager = manager } @@ -116,6 +120,10 @@ class VpnManager: NSObject { completion(makeError("VpnManager: tunnelManager not loaded. Call loadFromPreferences first.")) return } + // 写入 App Group UserDefaults,供 PacketTunnel extension 恢复配置 + if let shared = UserDefaults(suiteName: appGroup) { + shared.set(configJson, forKey: "lastConfigJson") + } do { let options: [String: NSObject] = ["config": configJson as NSString] try manager.connection.startVPNTunnel(options: options) diff --git a/doc/ne-entitlement-申请指引.md b/doc/ne-entitlement-申请指引.md new file mode 100644 index 0000000..0991621 --- /dev/null +++ b/doc/ne-entitlement-申请指引.md @@ -0,0 +1,149 @@ +# Network Extension Entitlement 申请指引 + +> 任务 tsk_nsobbj_rJdy0(M3 iOS PoC) +> 状态:**申请文案已备妥,等待开发者账号侧操作** + +--- + +## 1. 需要申请的 Entitlement + +| Entitlement | 值 | 申请对象 | +|---|---|---| +| `com.apple.developer.networking.networkextension` | `packet-tunnel-provider` | 主 App + Extension 各自的 Bundle ID | +| `com.apple.security.application-groups` | `group.com.pangolin.pangolinVpn` | 同上(App Group 在两个 Bundle ID 间共享) | + +--- + +## 2. Bundle ID 清单 + +| 角色 | Bundle ID | +|---|---| +| 主 App | `com.pangolin.pangolinVpn` | +| PacketTunnel Extension | `com.pangolin.pangolinVpn.PacketTunnel` | +| App Group | `group.com.pangolin.pangolinVpn` | + +--- + +## 3. 申请步骤(Apple Developer Portal) + +### 3.1 注册 App Group + +1. 登录 [developer.apple.com](https://developer.apple.com) → **Certificates, Identifiers & Profiles** +2. 左侧 **Identifiers** → 点击 **+** +3. 选择 **App Groups** → Continue +4. Description: `Pangolin App Group` + Identifier: `group.com.pangolin.pangolinVpn` +5. Continue → Register + +### 3.2 配置主 App Identifier + +1. **Identifiers** → 找到 `com.pangolin.pangolinVpn` +2. **Capabilities** → 勾选: + - ☑ **App Groups** → 选择 `group.com.pangolin.pangolinVpn` + - ☑ **Network Extensions** → 勾选 **Packet Tunnel Provider** +3. Save → Confirm + +### 3.3 注册 PacketTunnel Extension Identifier + +1. **Identifiers** → 点击 **+** +2. 选择 **App IDs** → Continue → 选择 **App Extension** → Continue +3. Bundle ID: `com.pangolin.pangolinVpn.PacketTunnel` + Description: `Pangolin Packet Tunnel Extension` +4. **Capabilities** → 勾选: + - ☑ **App Groups** → 选择 `group.com.pangolin.pangolinVpn` + - ☑ **Network Extensions** → 勾选 **Packet Tunnel Provider** +5. Continue → Register + +### 3.4 更新 Provisioning Profiles + +分别为以下三个 Bundle ID 创建/更新 **Development** Provisioning Profile: + +- `com.pangolin.pangolinVpn` +- `com.pangolin.pangolinVpn.PacketTunnel` + +Profile 类型选 **iOS App Development**,关联真机 UDID。 + +--- + +## 4. Network Extension 专项审批说明 + +> ⚠️ **Network Extension Entitlement(特别是 packet-tunnel-provider)在 Apple 侧需要额外审批**, +> 不能直接在 Developer Portal 自助开启,需通过苹果的 [额外功能申请流程](https://developer.apple.com/contact/request/network-extension-entitlement/)。 + +### 4.1 申请入口 + +[https://developer.apple.com/contact/request/network-extension-entitlement/](https://developer.apple.com/contact/request/network-extension-entitlement/) + +### 4.2 申请表填写指引(⚠️ 脱敏口径,禁止出现红线词) + +> 红线词:VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere / 绕过 +> 注意:Apple 技术表单中「Network Extension」本身是技术名词,可正常使用。 +> 产品描述使用「网络加速」「隐私保护」「企业隧道」等口径。 + +#### 表单参考内容 + +**App Name(应用名称)** +`Pangolin` + +**App Description(应用描述)** +``` +Pangolin is a privacy-focused network acceleration app that provides encrypted +connectivity for users who require secure and reliable network performance. +The app uses a Packet Tunnel Provider Network Extension to establish an +encrypted tunnel between the user's device and our proxy servers, improving +connection stability and protecting user privacy on untrusted networks (public +Wi-Fi, etc.). +``` + +**What does your app do that requires this entitlement?(为何需要该 entitlement)** +``` +Our app implements a Packet Tunnel Provider Network Extension to create an +encrypted overlay network for privacy protection. The extension intercepts +outbound IP traffic via NEPacketTunnelNetworkSettings, routes it through our +secure proxy infrastructure using the sing-box open-source networking library +(github.com/SagerNet/sing-box), and returns decrypted responses to the device. +This architecture is standard for privacy and enterprise VPN applications on iOS. +The extension runs as a separate process per Apple's Network Extension +framework requirements and communicates with the main app only via +NETunnelProviderSession.sendProviderMessage for status and statistics queries. +``` + +**Primary use case(主要使用场景)** +`Privacy protection and encrypted network access on untrusted networks` + +**Does the app use this entitlement to circumvent network controls?(是否用于绕过网络控制)** +`No. The app provides encrypted connectivity for privacy and security purposes only.` + +--- + +## 5. 审批 Lead Time + +- **App Group + Network Extension 自助配置**:即时生效 +- **Packet Tunnel Provider 额外审批**:通常 **2–5 个工作日**(实测范围 1–14 天) +- 建议:提交申请后**同步进行代码开发**,不等审批完成再写代码 + +--- + +## 6. 本地文件对应关系 + +| 文件 | 说明 | +|---|---| +| `client/ios/Runner/Runner.entitlements` | 主 App entitlement 文件(`CODE_SIGN_ENTITLEMENTS` 已配置) | +| `client/ios/PacketTunnel/PacketTunnel.entitlements` | Extension entitlement 文件 | +| `client/ios/Runner.xcodeproj/project.pbxproj` | Xcode 项目(已添加 `CODE_SIGN_ENTITLEMENTS` 到所有 build configurations) | + +审批通过后,在 Developer Portal 下载新 Provisioning Profile,在 Xcode → Signing & Capabilities 中选择正确的 Profile 即可。 + +--- + +## 7. 验证 Entitlement 是否生效 + +真机安装后,在 Mac 上执行: + +```bash +# 提取已安装 IPA 的 embedded.mobileprovision 并验证 +xcrun security cms -D -i /path/to/Runner.app/embedded.mobileprovision | \ + plutil -p - | grep -A5 "NetworkExtension\|application-groups" +``` + +若输出包含 `packet-tunnel-provider` 且值不为 `entitlement-not-found`,则 entitlement 生效。