macOS PacketTunnel 系统扩展 realize 失败(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"。 表面像"找不到扩展",实则是 三个叠加的工程配置 bugsysextd 在 realize/暂存/分类校验三个不同阶段先后拒绝。 逐个修复后扩展成功 activated enabled、隧道连通。

1. 现象

客户端点"连接"时,主 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

诡异之处:

2. 走过的弯路(全部排除)

以下都查过并确认不是根因,记录下来免得后人重复:

假设结论
签名 / 公证 / 时间戳 / 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 原生格式,但单独改它仍失败

3. 破局方法:在同一台机器上对照一个"能用的"开源同类

关键转折是不再盲猜,而是拿一个已知能用、且分发模型完全相同的开源 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 的实现。

4. 三个根因(按 sysextd 处理阶段排序)

根因 ① 扩展不自包含 staging 前被拒

这是 Flutter + 扩展工程的经典坑。工程用 CocoaPods,项目级配置经 Release.xcconfig 注入了链接所有 pod 框架的 OTHER_LDFLAGSPacketTunnel 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)。

修复:

验证:otool -L 扩展二进制应只剩 /System/.../usr/lib/...,无任何 @rpath;Contents/ 下不再有 Frameworks/ 目录(与 Tailscale 一致)。

根因 ② 扩展 bundle 名 ≠ bundle 标识符 staging 阶段

我们扩展的产物名是 PacketTunnel.systemextension,而 CFBundleIdentifiercom.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 自动跟随,无需手改。

根因 ③ 扩展 Info.plist 缺 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>

附带一并修正的项

5. 成功的 sysextd 日志长这样

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]

5.5 加载之后:从"扩展能起"到"真正连通"(运行时)

系统扩展能 realize/激活只是第一步。让内嵌的 libbox(sing-box)真正建起隧道、能上网,又踩了四个坑(均在 PacketTunnelProvider.swift / 服务端配置):

运行时 ① libbox 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。

运行时 ③ provider 队列三方死锁 → 隧道永远卡 connecting 最隐蔽

openTun 打点显示卡在 setTunnelNetworkSettings 的信号量等待,回调永不触发:

修复:把 libbox 启动整段放进后台队列(DispatchQueue.global().async),startTunnel 立即返回、provider 队列腾出来投递回调。

运行时 ④ 缺 DNS 劫持 → 隧道连上但打不开网站 最后一关

隧道起来了、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

6. 给后人的排查 checklist(Developer ID 网络系统扩展)

  1. 先找同模型的能用实现对照(Tailscale 独立版 / Mullvad),在同一台机器上验证环境没问题,把范围锁到自己的包。
  2. 扩展二进制 otool -L 必须@rpath 外部依赖(自包含)。警惕 CocoaPods/Flutter 的 OTHER_LDFLAGS 继承。
  3. 扩展 bundle 名 = CFBundleIdentifier(设 PRODUCT_NAME = 标识符)。
  4. 扩展 Info.plist 必须有 NSSystemExtensionUsageDescription(不是主 app 的)。
  5. App Group 用 macOS 原生 <TeamID>.<name>;NEMachServiceName 以该 group 为前缀。
  6. 沙箱扩展按需补 network.client/server、文件访问等能力。
  7. 静态库 xcframework 只 LinkEmbed;动态 framework 才需 Embed + CodeSignOnCopy。
  8. 排查时:log stream --debug --predicate 'process=="sysextd" OR process=="syspolicyd"',看卡在 realize / staging / validating_by_category 哪一步;category 校验失败会打出明确缺什么键。

7. 已知环境坑:macOS 26 (Tahoe) 回归

在 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 机器上验证。

8. 运行时 checklist(libbox + NE 集成)

  1. libbox startOrReloadService(options:) 传**非空** LibboxOverrideOptions(),别传 nil。
  2. startTunnel 里的 libbox 启动放**后台队列**(DispatchQueue.global().async),别在 provider 队列同步跑(死锁)。
  3. startDefaultInterfaceMonitor **阻塞到首个 path 更新再返回**。
  4. TUN 配置必须有 **DNS 劫持**(action:hijack-dns,按 port 53),排在 LAN/分流规则之前。
  5. **每次构建递增 CFBundleVersion**,否则 sysextd 不更新已激活的扩展。
  6. 看 libbox 自身日志:配置 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=节点、国外站可达。