# 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"`。 表面像"找不到扩展",实则是 三个叠加的工程配置 bug 让 `sysextd` 在 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

诡异之处:

- 扩展明明在 `Contents/Library/SystemExtensions/` 里,`sysextd` 日志也打了 `attempting to realize`(说明找到了)。
- 签名、公证、staple、Gatekeeper 全部通过;`codesign --verify --deep --strict` 通过。
- `sysextd` 在验签通过后 ~10ms 内静默 `xpc_connection_cancel`,自身不打印任何拒绝原因。
- 从不弹"允许扩展"批准框——在批准阶段之前就被拒。

## 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` 并弹出"允许扩展"框。

这一步同时证明了两件事:

- cara 的环境完全正常——能 realize 第三方 Developer ID 系统扩展。
- 所以 100% 是我们自己的包有问题(用户的直觉:"是我们代码的问题")。

附:也对照了 Shadowrocket,但它是 App Store 的 appex 模型(`NSExtension`、系统预信任、不走 sysextd 批准流),与我们不是一个机制,不能直接类比。要对照必须找同为 Developer ID system extension 的实现。

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

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

这是 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 链接标志的继承。
- 从 Embed Frameworks 移除 `Libbox.xcframework`(它是静态库,只需 Link,不需 Embed)。

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

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

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

### 根因 ③ 扩展 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>

### 附带一并修正的项

- App Group 格式:`group.com.pangolin.pangolin`(iOS 式)→ macOS 原生 `BYL4KQHMTN.com.pangolin.pangolin`(主 app + 扩展 entitlements + Swift `appGroup` 常量三处同步)。
- NEMachServiceName:必须以扩展所属 App Group 为前缀 → `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 重签。

## 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` 的信号量等待,回调永不触发:

- NE 在 provider 队列上调 `startTunnel` → 同步调 `startOrReloadService`(阻塞该队列)
- sing-box 在 Go 线程调 openTun → `sem.wait()` 等 `setTunnelNetworkSettings` 完成
- 而该完成回调**正要在被阻塞的 provider 队列上投递** → 死锁

修复:把 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 只 Link 不 Embed;动态 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=节点、国外站可达。