OSSystemExtensionErrorDomain code=4)踩坑复盘Pangolin 客户端 · P1 原生隧道 · 2026-06-21 · 在 macOS 15.3.2 / 26 上排查与修复
PacketTunnel 系统扩展(NEPacketTunnelProvider)一直无法激活,报
code=4 "Extension not found in App bundle. Unable to find any matched extension"。
表面像"找不到扩展",实则是 三个叠加的工程配置 bug 让 sysextd 在 realize/暂存/分类校验三个不同阶段先后拒绝。
逐个修复后扩展成功 activated enabled、隧道连通。
客户端点"连接"时,主 app 调 OSSystemExtensionRequest.activationRequest 激活内嵌的 packet-tunnel 系统扩展,但回调恒为失败:
sysext didFailWithError ✗ domain=OSSystemExtensionErrorDomain code=4
desc=Extension not found in App bundle. Unable to find any matched extension
with identifier: com.pangolin.pangolin.PacketTunnel
诡异之处:
Contents/Library/SystemExtensions/ 里,sysextd 日志也打了 attempting to realize(说明找到了)。codesign --verify --deep --strict 通过。sysextd 在验签通过后 ~10ms 内静默 xpc_connection_cancel,自身不打印任何拒绝原因。以下都查过并确认不是根因,记录下来免得后人重复:
| 假设 | 结论 |
|---|---|
| 签名 / 公证 / 时间戳 / hardened runtime / get-task-allow | 全部正确 |
entitlements 变体(packet-tunnel-provider-systemextension) | 正确,且描述文件已授权 |
Info.plist 用 NetworkExtension/NEProviderClasses(而非 appex 的 NSExtension) | 正确(sysext 就该用这个) |
| Xcode 26 beta / macOS 26.2 SDK 工具链太新 | 证伪:Tailscale 用更新的 SDK 26.5 照样能用 |
| App 文件归属(user vs root:wheel) | 改 root:wheel 无效 |
| App 不在 /Applications / LaunchServices 注册 / 翻译重定位 | 都正常,无效 |
systemextensionsctl reset 清缓存 | 无效 |
App Group 用 iOS 式 group. 前缀 | 确实该改成 macOS 原生格式,但单独改它仍失败 |
关键转折是不再盲猜,而是拿一个已知能用、且分发模型完全相同的开源 app 在同一台机器上对照。我们选了 Tailscale 独立版(io.tailscale.ipn.macsys):同样是 Developer ID 公证 + NEPacketTunnelProvider 系统扩展分发(非 App Store appex)。
把本机能用的 Tailscale.app 直接拷到出问题的测试机(cara,干净 macOS 15.3.2,Intel),它的扩展一路走完 validating → staging → validating_by_category → activated_waiting_for_user 并弹出"允许扩展"框。
这一步同时证明了两件事:
附:也对照了 Shadowrocket,但它是 App Store 的 appex 模型(NSExtension、系统预信任、不走 sysextd 批准流),与我们不是一个机制,不能直接类比。要对照必须找同为 Developer ID system extension 的实现。
这是 Flutter + 扩展工程的经典坑。工程用 CocoaPods,项目级配置经 Release.xcconfig 注入了链接所有 pod 框架的 OTHER_LDFLAGS。PacketTunnel target 没有自己的 OTHER_LDFLAGS,于是继承了项目级的,把主 app 的 Flutter 插件 flutter_secure_storage_macos.framework 也链进了扩展——而该框架并不在扩展 bundle 内:
$ otool -L PacketTunnel.systemextension/Contents/MacOS/PacketTunnel
@rpath/flutter_secure_storage_macos.framework/.../flutter_secure_storage_macos ← 悬空依赖!
/System/Library/Frameworks/...
同时,gomobile 产出的 Libbox.xcframework 实为静态库(ar archive),已被静态链进扩展二进制,却又被 Embed Frameworks 冗余地塞了一份畸形的 Libbox.framework 进扩展。
system extension 必须自包含(realize 时会被拷到 /Library/SystemExtensions/<UUID>/ 独立运行,相对 rpath 回不到主 app)。
修复:
PacketTunnel 的 Debug/Release 配置加 OTHER_LDFLAGS = "";,切断对项目级 pod 链接标志的继承。Libbox.xcframework(它是静态库,只需 Link,不需 Embed)。验证:otool -L 扩展二进制应只剩 /System/... 与 /usr/lib/...,无任何 @rpath;Contents/ 下不再有 Frameworks/ 目录(与 Tailscale 一致)。
我们扩展的产物名是 PacketTunnel.systemextension,而 CFBundleIdentifier 是 com.pangolin.pangolin.PacketTunnel。Tailscale 的产物名 = 标识符(io.tailscale.ipn.macsys.network-extension.systemextension)。
在受影响的 macOS 上,sysextd 必须能按标识符把 bundle 拷进暂存区;名字对不上时,在 attempting to realize 之后直接静默 cancel,进不了 staging。改名后日志立刻出现 [Staging] Imported: .../com.pangolin.pangolin.PacketTunnel.systemextension。
修复: 把 PacketTunnel target 的 PRODUCT_NAME 从 $(TARGET_NAME) 改为 com.pangolin.pangolin.PacketTunnel(三个配置 Debug/Release/Profile 都改)。
连带:PRODUCT_MODULE_NAME 会变成 com_pangolin_pangolin_PacketTunnel,而 Info.plist 的 NEProviderClasses 用 $(PRODUCT_MODULE_NAME).PacketTunnelProvider 自动跟随,无需手改。
NSSystemExtensionUsageDescription category 校验阶段过了 staging 后,日志终于给出明确原因:
com.pangolin.pangolin.PacketTunnel: extension failed category property check:
extensions belonging to the com.apple.system_extension.network_extension category
require the presence of the 'NSSystemExtensionUsageDescription' property.
→ uninstalling invalid extension
网络扩展类别强制要求扩展自己的 Info.plist 里有 NSSystemExtensionUsageDescription。我们之前只在主 app 的 Info.plist 加了——不顶用。Tailscale 的扩展 Info.plist 里就有这个键。
修复: 在 PacketTunnel/Info.plist 顶层加:
<key>NSSystemExtensionUsageDescription</key> <string>Pangolin 需要安装网络扩展以提供安全的网络连接。</string>
group.com.pangolin.pangolin(iOS 式)→ macOS 原生 BYL4KQHMTN.com.pangolin.pangolin(主 app + 扩展 entitlements + Swift appGroup 常量三处同步)。BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel。com.apple.security.network.client / network.server(packet tunnel 要对外建连)。get-task-allow=false、Xcode 签名加 --timestamp(OTHER_CODE_SIGN_FLAGS)、构建期把静态 Libbox 以 Developer ID 重签。attempting to realize extension with identifier com.pangolin.pangolin.PacketTunnel [Staging] Imported: .../com.pangolin.pangolin.PacketTunnel.systemextension advancing state from staging to validating advancing state from validating to validating_by_category Category delegate com.apple.system_extension.network_extension returned error (null) advancing state ... to activated_waiting_for_user observer 'notify user' reached a success state # ← 弹"允许扩展"框 # 用户在 系统设置 → 隐私与安全性 点允许后: sysext didFinishWithResult ✓ result=0 (completed) $ systemextensionsctl list * * BYL4KQHMTN com.pangolin.pangolin.PacketTunnel (1.0/1) [activated enabled]
系统扩展能 realize/激活只是第一步。让内嵌的 libbox(sing-box)真正建起隧道、能上网,又踩了四个坑(均在 PacketTunnelProvider.swift / 服务端配置):
startOrReloadService(options:) 传 nil → 空指针崩溃 扩展进程 SIGABRT扩展进程启动 360ms 后自杀(SIGABRT)。把 stderr 重定向到 App Group 容器文件后抓到 Go panic:
panic: runtime error: invalid memory address or nil pointer dereference libbox.(*CommandServer).StartOrReloadService(server, {config}, 0x0) command_server.go:175
此版本 libbox 会解引用第三个 options 参数(虽标 _Nullable)。修复:传非空 LibboxOverrideOptions() 而非 nil。
no available network interface 启动报错startDefaultInterfaceMonitor 启动 NWPathMonitor 后立即返回,但首个 path 回调是异步晚到的;sing-box 紧接着的网络操作拿到的默认接口索引还是 -1。修复:用信号量**阻塞到首个 path 更新再返回**(带 5s 超时),对齐 sing-box-for-apple。
openTun 打点显示卡在 setTunnelNetworkSettings 的信号量等待,回调永不触发:
startTunnel → 同步调 startOrReloadService(阻塞该队列)sem.wait() 等 setTunnelNetworkSettings 完成修复:把 libbox 启动整段放进后台队列(DispatchQueue.global().async),startTunnel 立即返回、provider 队列腾出来投递回调。
隧道起来了、TCP 能经 REALITY 出海,但域名打不开。box.log 显示发往隧道 DNS 的查询走了直连:
inbound packet connection to 172.19.0.2:53 router: match ip_cidr=[..172.16.0.0/12..] => route(direct) # DNS 被 LAN 规则吞去直连 → 解析失败
隧道 DNS 地址 172.19.0.2 落在 LAN 直连规则 172.16/12 内,被路由成直连(发往不存在的主机)→ 解析全失败。修复(服务端 clientconfig.go):route.rules 首条加 {"action":"hijack-dns","port":[53]}(排在 LAN 规则之前),把 :53 查询交给 sing-box DNS 模块。
注:sing-box 1.13 的 protocol:"dns" 匹配需先 sniff;按目的端口 53 匹配最稳、不依赖 sniff。
另一个发版必知:每次构建必递增 CFBundleVersion。 sysextd 按(标识符, 版本)去重;同版本号重装**不会替换**已激活的旧扩展,跑的还是旧代码(排查时极易被误导)。
连通验证(从命令行):
$ curl https://api.ipify.org → 103.119.13.48 # 出口=节点 IP,流量走隧道 $ curl -o/dev/null -w '%{http_code}' https://github.com → 200 box.log: router: match[0] port=53 => hijack-dns → dns: exchanged A github.com 140.82.116.3
otool -L 必须零 @rpath 外部依赖(自包含)。警惕 CocoaPods/Flutter 的 OTHER_LDFLAGS 继承。PRODUCT_NAME = 标识符)。NSSystemExtensionUsageDescription(不是主 app 的)。<TeamID>.<name>;NEMachServiceName 以该 group 为前缀。network.client/server、文件访问等能力。log stream --debug --predicate 'process=="sysextd" OR process=="syspolicyd"',看卡在 realize / staging / validating_by_category 哪一步;category 校验失败会打出明确缺什么键。在 macOS 26 上,即使包完全正确,sysextd 仍可能报
no policy, cannot allow apps outside /Applications(app 明明在 /Applications)。这是 Apple 在 Tahoe 的已知回归(开发者论坛多人复现、非 MDM 个人机),不是我们能修的,也不影响 macOS 15/14 的真实用户。开发机若是 26,本机调试可考虑关 SIP 后开
systemextensionsctl developer on,或在 15/14 机器上验证。
startOrReloadService(options:) 传**非空** LibboxOverrideOptions(),别传 nil。startTunnel 里的 libbox 启动放**后台队列**(DispatchQueue.global().async),别在 provider 队列同步跑(死锁)。startDefaultInterfaceMonitor **阻塞到首个 path 更新再返回**。action:hijack-dns,按 port 53),排在 LAN/分流规则之前。CFBundleVersion**,否则 sysextd 不更新已激活的扩展。log.output 指向容器文件(writeLogs 回调启动期不触发);Go fatal 走 stderr,需把扩展 stderr 重定向到文件才看得到。改动文件:扩展 Info.plist/entitlements/main.swift/PacketTunnelProvider.swift · Runner/Release.entitlements · Runner.xcodeproj/project.pbxproj(OTHER_LDFLAGS/PRODUCT_NAME/移除 Embed Libbox/--timestamp/版本号) · scripts/local_test.sh · client/macos/sign_libbox.sh · 服务端 server/internal/httpapi/clientconfig.go(DNS 劫持)。验证机:cara,干净 macOS 15.3.2 Intel,出口 IP=节点、国外站可达。