Merge branch 'main' into feature/windows

This commit is contained in:
wangjia
2026-06-22 14:17:19 +08:00
26 changed files with 1294 additions and 224 deletions
@@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>macOS PacketTunnel 系统扩展 realize 失败(code=4)踩坑复盘</title>
<style>
:root{
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
h2{font-size:21px;margin:48px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:16px;margin:28px 0 8px;color:var(--accent2)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
pre .c{color:#6b7385}
pre .r{color:var(--bad)}
pre .g{color:var(--ok)}
pre .y{color:var(--warn)}
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
.card.root{border-left:3px solid var(--accent)}
.card h3{margin-top:0}
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
th{color:var(--fg2);font-weight:600;font-size:13px}
td code{font-size:.85em}
.ok-c{color:var(--ok)} .bad-c{color:var(--bad)} .warn-c{color:var(--warn)}
ul,ol{padding-left:22px;margin:10px 0}
li{margin:5px 0}
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
.kbd{font-family:var(--mono);font-size:.85em;color:var(--accent)}
.small{color:var(--fg2);font-size:13px}
.step{counter-increment:step;position:relative;padding-left:6px}
hr{border:none;border-top:1px solid var(--border);margin:40px 0}
a{color:var(--accent2)}
</style>
</head>
<body>
<div class="wrap">
<h1>macOS PacketTunnel 系统扩展 realize 失败(<code>OSSystemExtensionErrorDomain code=4</code>)踩坑复盘</h1>
<p class="sub">Pangolin 客户端 · P1 原生隧道 · 2026-06-21 · 在 macOS 15.3.2 / 26 上排查与修复</p>
<div class="lead">
<strong>一句话结论:</strong> 客户端内嵌的 <code>PacketTunnel</code> 系统扩展(<code>NEPacketTunnelProvider</code>)一直无法激活,报
<code>code=4 "Extension not found in App bundle. Unable to find any matched extension"</code>
表面像"找不到扩展",实则是 <b>三个叠加的工程配置 bug</b><code>sysextd</code> 在 realize/暂存/分类校验三个不同阶段先后拒绝。
逐个修复后扩展成功 <code>activated enabled</code>、隧道连通。
</div>
<h2>1. 现象</h2>
<p>客户端点"连接"时,主 app 调 <code>OSSystemExtensionRequest.activationRequest</code> 激活内嵌的 packet-tunnel 系统扩展,但回调恒为失败:</p>
<pre><span class="r">sysext didFailWithError ✗ domain=OSSystemExtensionErrorDomain code=4</span>
desc=Extension not found in App bundle. Unable to find any matched extension
with identifier: com.pangolin.pangolin.PacketTunnel</pre>
<p>诡异之处:</p>
<ul>
<li>扩展明明在 <code>Contents/Library/SystemExtensions/</code> 里,<code>sysextd</code> 日志也打了 <code>attempting to realize</code>(说明找到了)。</li>
<li>签名、公证、staple、Gatekeeper 全部通过;<code>codesign --verify --deep --strict</code> 通过。</li>
<li><code>sysextd</code> 在验签通过后 <b>~10ms 内静默 <code>xpc_connection_cancel</code></b>,自身不打印任何拒绝原因。</li>
<li><b>从不弹"允许扩展"批准框</b>——在批准阶段之前就被拒。</li>
</ul>
<h2>2. 走过的弯路(全部排除)</h2>
<p>以下都查过并确认<strong>不是</strong>根因,记录下来免得后人重复:</p>
<table>
<tr><th>假设</th><th>结论</th></tr>
<tr><td>签名 / 公证 / 时间戳 / hardened runtime / get-task-allow</td><td class="ok-c">全部正确</td></tr>
<tr><td>entitlements 变体(<code>packet-tunnel-provider-systemextension</code>)</td><td class="ok-c">正确,且描述文件已授权</td></tr>
<tr><td>Info.plist 用 <code>NetworkExtension</code>/<code>NEProviderClasses</code>(而非 appex 的 <code>NSExtension</code>)</td><td class="ok-c">正确(sysext 就该用这个)</td></tr>
<tr><td>Xcode 26 beta / macOS 26.2 SDK 工具链太新</td><td class="bad-c">证伪:Tailscale 用更新的 SDK 26.5 照样能用</td></tr>
<tr><td>App 文件归属(user vs root:wheel)</td><td class="bad-c">改 root:wheel 无效</td></tr>
<tr><td>App 不在 /Applications / LaunchServices 注册 / 翻译重定位</td><td class="bad-c">都正常,无效</td></tr>
<tr><td><code>systemextensionsctl reset</code> 清缓存</td><td class="bad-c">无效</td></tr>
<tr><td>App Group 用 iOS 式 <code>group.</code> 前缀</td><td class="warn-c">确实该改成 macOS 原生格式,但单独改它仍失败</td></tr>
</table>
<h2>3. 破局方法:在同一台机器上对照一个"能用的"开源同类</h2>
<p>关键转折是<strong>不再盲猜,而是拿一个已知能用、且分发模型完全相同的开源 app 在同一台机器上对照</strong>。我们选了 <b>Tailscale 独立版</b>(<code>io.tailscale.ipn.macsys</code>):同样是 Developer ID 公证 + <code>NEPacketTunnelProvider</code> <b>系统扩展</b>分发(非 App Store appex)。</p>
<div class="card">
<p>把本机能用的 <code>Tailscale.app</code> 直接拷到出问题的测试机(cara,干净 macOS 15.3.2,Intel),它的扩展<strong>一路走完</strong> <code>validating → staging → validating_by_category → activated_waiting_for_user</code> 并弹出"允许扩展"框。</p>
<p><strong>这一步同时证明了两件事:</strong></p>
<ul>
<li>cara 的环境完全正常——能 realize 第三方 Developer ID 系统扩展。</li>
<li>所以 100% 是<strong>我们自己的包</strong>有问题(用户的直觉:"是我们代码的问题")。</li>
</ul>
</div>
<p class="small">附:也对照了 Shadowrocket,但它是 App Store 的 <b>appex</b> 模型(<code>NSExtension</code>、系统预信任、不走 sysextd 批准流),与我们不是一个机制,不能直接类比。要对照必须找<strong>同为 Developer ID system extension</strong> 的实现。</p>
<h2>4. 三个根因(按 sysextd 处理阶段排序)</h2>
<div class="card root">
<h3>根因 ① 扩展不自包含 <span class="tag bad">staging 前被拒</span></h3>
<p>这是 Flutter + 扩展工程的经典坑。工程用 CocoaPods,<b>项目级</b>配置经 <code>Release.xcconfig</code> 注入了链接所有 pod 框架的 <code>OTHER_LDFLAGS</code><code>PacketTunnel</code> target 没有自己的 <code>OTHER_LDFLAGS</code>,于是<strong>继承了项目级的</strong>,把主 app 的 Flutter 插件 <code>flutter_secure_storage_macos.framework</code> 也链进了扩展——而该框架并不在扩展 bundle 内:</p>
<pre>$ otool -L PacketTunnel.systemextension/Contents/MacOS/PacketTunnel
<span class="r">@rpath/flutter_secure_storage_macos.framework/.../flutter_secure_storage_macos</span> ← 悬空依赖!
/System/Library/Frameworks/...</pre>
<p>同时,gomobile 产出的 <code>Libbox.xcframework</code> 实为<strong>静态库</strong>(<code>ar archive</code>),已被静态链进扩展二进制,却又被 <b>Embed Frameworks</b> 冗余地塞了一份畸形的 <code>Libbox.framework</code> 进扩展。</p>
<p><b>system extension 必须自包含</b>(realize 时会被拷到 <code>/Library/SystemExtensions/&lt;UUID&gt;/</code> 独立运行,相对 rpath 回不到主 app)。</p>
<p><strong>修复:</strong></p>
<ul>
<li><code>PacketTunnel</code> 的 Debug/Release 配置加 <code class="kbd">OTHER_LDFLAGS = "";</code>,切断对项目级 pod 链接标志的继承。</li>
<li><b>Embed Frameworks</b> 移除 <code>Libbox.xcframework</code>(它是静态库,只需 <b>Link</b>,不需 Embed)。</li>
</ul>
<p class="small">验证:<code>otool -L</code> 扩展二进制应只剩 <code>/System/...</code><code>/usr/lib/...</code>,无任何 <code>@rpath</code>;<code>Contents/</code> 下不再有 <code>Frameworks/</code> 目录(与 Tailscale 一致)。</p>
</div>
<div class="card root">
<h3>根因 ② 扩展 bundle 名 ≠ bundle 标识符 <span class="tag bad">staging 阶段</span></h3>
<p>我们扩展的产物名是 <code>PacketTunnel.systemextension</code>,而 <code>CFBundleIdentifier</code><code>com.pangolin.pangolin.PacketTunnel</code>。Tailscale 的产物名 = 标识符(<code>io.tailscale.ipn.macsys.network-extension.systemextension</code>)。</p>
<p>在受影响的 macOS 上,<code>sysextd</code> 必须能按标识符把 bundle 拷进暂存区;名字对不上时,在 <code>attempting to realize</code> 之后直接静默 cancel,<b>进不了 staging</b>。改名后日志立刻出现 <code>[Staging] Imported: .../com.pangolin.pangolin.PacketTunnel.systemextension</code></p>
<p><strong>修复:</strong><code>PacketTunnel</code> target 的 <code class="kbd">PRODUCT_NAME</code><code>$(TARGET_NAME)</code> 改为 <code>com.pangolin.pangolin.PacketTunnel</code>(三个配置 Debug/Release/Profile 都改)。</p>
<p class="small">连带:<code>PRODUCT_MODULE_NAME</code> 会变成 <code>com_pangolin_pangolin_PacketTunnel</code>,而 Info.plist 的 <code>NEProviderClasses</code><code>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</code> 自动跟随,无需手改。</p>
</div>
<div class="card root">
<h3>根因 ③ 扩展 Info.plist 缺 <code>NSSystemExtensionUsageDescription</code> <span class="tag bad">category 校验阶段</span></h3>
<p>过了 staging 后,日志终于给出<strong>明确原因</strong>:</p>
<pre><span class="r">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.</span>
→ uninstalling invalid extension</pre>
<p>网络扩展类别<strong>强制要求扩展自己的 Info.plist</strong> 里有 <code>NSSystemExtensionUsageDescription</code>。我们之前只在<strong>主 app</strong> 的 Info.plist 加了——<b>不顶用</b>。Tailscale 的扩展 Info.plist 里就有这个键。</p>
<p><strong>修复:</strong><code>PacketTunnel/Info.plist</code> 顶层加:</p>
<pre>&lt;key&gt;NSSystemExtensionUsageDescription&lt;/key&gt;
&lt;string&gt;Pangolin 需要安装网络扩展以提供安全的网络连接。&lt;/string&gt;</pre>
</div>
<h3>附带一并修正的项</h3>
<ul>
<li><b>App Group 格式</b>:<code>group.com.pangolin.pangolin</code>(iOS 式)→ macOS 原生 <code>BYL4KQHMTN.com.pangolin.pangolin</code>(主 app + 扩展 entitlements + Swift <code>appGroup</code> 常量三处同步)。</li>
<li><b>NEMachServiceName</b>:必须以扩展所属 App Group 为前缀 → <code>BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel</code></li>
<li><b>扩展网络权限</b>:沙箱扩展补 <code>com.apple.security.network.client</code> / <code>network.server</code>(packet tunnel 要对外建连)。</li>
<li><b>公证前置</b>:<code>get-task-allow=false</code>、Xcode 签名加 <code>--timestamp</code>(<code>OTHER_CODE_SIGN_FLAGS</code>)、构建期把静态 Libbox 以 Developer ID 重签。</li>
</ul>
<h2>5. 成功的 sysextd 日志长这样</h2>
<pre>attempting to realize extension with identifier com.pangolin.pangolin.PacketTunnel
[Staging] Imported: .../com.pangolin.pangolin.PacketTunnel.systemextension
advancing state from staging to <span class="g">validating</span>
advancing state from validating to <span class="g">validating_by_category</span>
<span class="g">Category delegate com.apple.system_extension.network_extension returned error (null)</span>
advancing state ... to <span class="g">activated_waiting_for_user</span>
observer 'notify user' reached a success state <span class="c"># ← 弹"允许扩展"框</span>
<span class="c"># 用户在 系统设置 → 隐私与安全性 点允许后:</span>
sysext didFinishWithResult ✓ result=0 (completed)
$ systemextensionsctl list
* * BYL4KQHMTN com.pangolin.pangolin.PacketTunnel (1.0/1) <span class="g">[activated enabled]</span></pre>
<h2>5.5 加载之后:从"扩展能起"到"真正连通"(运行时)</h2>
<p>系统扩展能 realize/激活只是第一步。让内嵌的 libbox(sing-box)真正建起隧道、能上网,又踩了四个坑(均在 <code>PacketTunnelProvider.swift</code> / 服务端配置):</p>
<div class="card root">
<h3>运行时 ① libbox <code>startOrReloadService(options:)</code> 传 nil → 空指针崩溃 <span class="tag bad">扩展进程 SIGABRT</span></h3>
<p>扩展进程启动 360ms 后自杀(SIGABRT)。把 stderr 重定向到 App Group 容器文件后抓到 Go panic:</p>
<pre><span class="r">panic: runtime error: invalid memory address or nil pointer dereference</span>
libbox.(*CommandServer).StartOrReloadService(server, {config}, <span class="r">0x0</span>) command_server.go:175</pre>
<p>此版本 libbox 会解引用第三个 <code>options</code> 参数(虽标 <code>_Nullable</code>)。<strong>修复:</strong>传非空 <code>LibboxOverrideOptions()</code> 而非 <code>nil</code></p>
</div>
<div class="card root">
<h3>运行时 ② 默认接口监控异步返回 → <code>no available network interface</code> <span class="tag bad">启动报错</span></h3>
<p><code>startDefaultInterfaceMonitor</code> 启动 <code>NWPathMonitor</code> 后立即返回,但首个 path 回调是异步晚到的;sing-box 紧接着的网络操作拿到的默认接口索引还是 -1。<strong>修复:</strong>用信号量**阻塞到首个 path 更新再返回**(带 5s 超时),对齐 sing-box-for-apple。</p>
</div>
<div class="card root">
<h3>运行时 ③ provider 队列三方死锁 → 隧道永远卡 connecting <span class="tag bad">最隐蔽</span></h3>
<p>openTun 打点显示卡在 <code>setTunnelNetworkSettings</code> 的信号量等待,回调永不触发:</p>
<ul>
<li>NE 在 <b>provider 队列</b>上调 <code>startTunnel</code> → 同步调 <code>startOrReloadService</code>(阻塞该队列)</li>
<li>sing-box 在 Go 线程调 openTun → <code>sem.wait()</code><code>setTunnelNetworkSettings</code> 完成</li>
<li>而该完成回调**正要在被阻塞的 provider 队列上投递** → 死锁</li>
</ul>
<p><strong>修复:</strong>把 libbox 启动整段放进后台队列(<code>DispatchQueue.global().async</code>),<code>startTunnel</code> 立即返回、provider 队列腾出来投递回调。</p>
</div>
<div class="card root">
<h3>运行时 ④ 缺 DNS 劫持 → 隧道连上但打不开网站 <span class="tag bad">最后一关</span></h3>
<p>隧道起来了、TCP 能经 REALITY 出海,但域名打不开。box.log 显示发往隧道 DNS 的查询走了直连:</p>
<pre>inbound packet connection to <span class="y">172.19.0.2:53</span>
router: match ip_cidr=[..<span class="r">172.16.0.0/12</span>..] => route(<span class="r">direct</span>) <span class="c"># DNS 被 LAN 规则吞去直连 → 解析失败</span></pre>
<p>隧道 DNS 地址 172.19.0.2 落在 LAN 直连规则 172.16/12 内,被路由成直连(发往不存在的主机)→ 解析全失败。<strong>修复(服务端 <code>clientconfig.go</code>):</strong>route.rules 首条加 <code>{"action":"hijack-dns","port":[53]}</code>(排在 LAN 规则之前),把 :53 查询交给 sing-box DNS 模块。</p>
<p class="small">注:sing-box 1.13 的 <code>protocol:"dns"</code> 匹配需先 sniff;按目的端口 53 匹配最稳、不依赖 sniff。</p>
</div>
<div class="card">
<p><strong>另一个发版必知:每次构建必递增 <code>CFBundleVersion</code></strong> <code>sysextd</code> 按(标识符, 版本)去重;同版本号重装**不会替换**已激活的旧扩展,跑的还是旧代码(排查时极易被误导)。</p>
</div>
<p><strong>连通验证(从命令行):</strong></p>
<pre>$ curl https://api.ipify.org → <span class="g">103.119.13.48</span> <span class="c"># 出口=节点 IP,流量走隧道</span>
$ curl -o/dev/null -w '%{http_code}' https://github.com → <span class="g">200</span>
box.log: router: match[0] port=53 => <span class="g">hijack-dns</span> → dns: exchanged A github.com 140.82.116.3</pre>
<h2>6. 给后人的排查 checklist(Developer ID 网络系统扩展)</h2>
<ol>
<li><b>先找同模型的能用实现对照</b>(Tailscale 独立版 / Mullvad),<u>在同一台机器</u>上验证环境没问题,把范围锁到自己的包。</li>
<li>扩展二进制 <code>otool -L</code> 必须<strong><code>@rpath</code> 外部依赖</strong>(自包含)。警惕 CocoaPods/Flutter 的 <code>OTHER_LDFLAGS</code> 继承。</li>
<li>扩展 <b>bundle 名 = CFBundleIdentifier</b>(设 <code>PRODUCT_NAME</code> = 标识符)。</li>
<li>扩展 Info.plist <strong>必须有 <code>NSSystemExtensionUsageDescription</code></strong>(不是主 app 的)。</li>
<li>App Group 用 macOS 原生 <code>&lt;TeamID&gt;.&lt;name&gt;</code>;<code>NEMachServiceName</code> 以该 group 为前缀。</li>
<li>沙箱扩展按需补 <code>network.client/server</code>、文件访问等能力。</li>
<li>静态库 xcframework 只 <b>Link</b><b>Embed</b>;动态 framework 才需 Embed + CodeSignOnCopy。</li>
<li>排查时:<code>log stream --debug --predicate 'process=="sysextd" OR process=="syspolicyd"'</code>,看卡在 <code>realize / staging / validating_by_category</code> 哪一步;category 校验失败会打出明确缺什么键。</li>
</ol>
<h2>7. 已知环境坑:macOS 26 (Tahoe) 回归</h2>
<div class="card">
<p>在 macOS 26 上,即使包完全正确,<code>sysextd</code> 仍可能报
<code>no policy, cannot allow apps outside /Applications</code>(app 明明在 /Applications)。这是 <b>Apple 在 Tahoe 的已知回归</b>(开发者论坛多人复现、非 MDM 个人机),<u>不是我们能修的</u>,也不影响 macOS 15/14 的真实用户。开发机若是 26,本机调试可考虑关 SIP 后开
<code>systemextensionsctl developer on</code>,或在 15/14 机器上验证。</p>
</div>
<h2>8. 运行时 checklist(libbox + NE 集成)</h2>
<ol>
<li>libbox <code>startOrReloadService(options:)</code> 传**非空** <code>LibboxOverrideOptions()</code>,别传 nil。</li>
<li><code>startTunnel</code> 里的 libbox 启动放**后台队列**(<code>DispatchQueue.global().async</code>),别在 provider 队列同步跑(死锁)。</li>
<li><code>startDefaultInterfaceMonitor</code> **阻塞到首个 path 更新再返回**。</li>
<li>TUN 配置必须有 **DNS 劫持**(<code>action:hijack-dns</code>,按 port 53),排在 LAN/分流规则之前。</li>
<li>**每次构建递增 <code>CFBundleVersion</code>**,否则 sysextd 不更新已激活的扩展。</li>
<li>看 libbox 自身日志:配置 <code>log.output</code> 指向容器文件(<code>writeLogs</code> 回调启动期不触发);Go fatal 走 stderr,需把扩展 stderr 重定向到文件才看得到。</li>
</ol>
<hr>
<p class="small">改动文件:扩展 <code>Info.plist</code>/<code>entitlements</code>/<code>main.swift</code>/<code>PacketTunnelProvider.swift</code> · <code>Runner/Release.entitlements</code> · <code>Runner.xcodeproj/project.pbxproj</code>(<code>OTHER_LDFLAGS</code>/<code>PRODUCT_NAME</code>/移除 Embed Libbox/<code>--timestamp</code>/版本号) · <code>scripts/local_test.sh</code> · <code>client/macos/sign_libbox.sh</code> · 服务端 <code>server/internal/httpapi/clientconfig.go</code>(DNS 劫持)。验证机:cara,干净 macOS 15.3.2 Intel,出口 IP=节点、国外站可达。</p>
</div>
</body>
</html>
+172
View File
@@ -0,0 +1,172 @@
# Pangolin VPN 测试方案 + 报告
> 两个方向:**黑盒**(用户视角,只看输入→输出)+ **白盒**(利用我们对客户端/节点/sing-box
> 的完全可见性,拆链路、看协议、做稳定性)。日期 2026-06-22。配套:`scripts/vpn_test.py`、
> 调研见 `docs/vpn-testing-research.md`。
---
## 一、测试方向总览
| | 黑盒 | 白盒 |
|---|---|---|
| 视角 | 用户视角,不看内部 | 工程视角,看内部链路/协议/状态 |
| 目的 | 能不能用、快不快(可用性 + 体验) | 慢在哪、走什么协议、稳不稳(定位 + 稳定性) |
| 数据源 | `curl` / `ping` 黑盒探测 | sing-box Clash API、libbox 命令服务、curl 五段拆解、节点侧探测、长跑监控 |
| 工具 | `scripts/vpn_test.py`(已实现) | 待实现:`scripts/vpn_whitebox.sh` + 稳定性长跑 |
---
## 二、黑盒测试方案
**对象:国内外常用站点矩阵**,开/关分流各测一遍,多时段(白天 / 晚高峰 20:0024:00)。
### 指标
- **连通性**:HTTP 状态码(能否访问)
- **延迟**:**TLS 握手时间(`curl time_appconnect`)= 统一延迟口径**;TTFB(`time_starttransfer`)作参考。见下方「延迟口径定义」。
- **下载速度**:固定大小下载 `speed_download`
- **上传速度**:POST 固定大小 `speed_upload`(待补)
- **DNS 解析时间**:`curl time_namelookup`
- **出口 IP**:是否 = 节点(走隧道)/ 本地(直连)
### 延迟口径定义(统一标准 ⚠️ 必读)
> **本项目所有"延迟"一律指 TLS 握手时间(`time_appconnect`);TTFB 仅作参考。
> 严禁用 `ping` 或 `tcp_connect` 当延迟** —— 经 TUN 代理时它们被隧道本地协议栈
> 就地应答(几 ms 的假象),不反映真实到目标的 RTT。
curl 各时间点都是**从请求开始累计**(非各段独立):
```
开始 → DNS解析 → TCP连接 → [TLS握手完成] → 发请求 → [收到首字节=TTFB] → 传输完 → 总时长
namelookup connect time_appconnect time_starttransfer time_total
```
| | **TLS 握手(`time_appconnect`)= 延迟口径** | **TTFB(`time_starttransfer`)= 参考** |
|---|---|---|
| 测到哪 | TLS/REALITY 握手协商**完成** | 收到响应的**第一个字节** |
| 含义 | TCP 连接 + TLS 协商往返;握手时服务器只做加密协商、**不碰业务逻辑** → **纯网络链路往返**(client→隧道→节点→目标 的 RTT × 握手轮数) | TLS 握手 + 发请求 + **目标服务器后端处理** + 回首字节 |
| 比对方多 | — | 比 TLS 握手多 **≈1 个 RTT(请求-响应)+ 目标站后端处理时间** |
| 受谁影响 | 只受**网络链路**影响 → 跨站可比、稳定 | 网络 **+ 目标站后端快慢/CDN** → 含站点自身因素 |
| 为何选它 | 不被目标后端污染,横向比较公平,衡量"我们隧道有多快"最干净 | 反映"打开体感",但混入站点因素,不当基准 |
**实测印证**(节点在洛杉矶):`google` TLS握手 424ms、TTFB 640ms,差 ~216ms ≈ 一个
中国→LA→google 往返(generate_204 后端处理≈0)。换 github 这类后端重的站,TTFB 会被
明显拉大,而 TLS 握手仍稳定反映链路。
**判定参考**(单程经隧道到国外):TLS 握手 <500ms 优 / 5001000ms 偏高 / >1000ms 差。
**真实到节点的直连 RTT**:须在 **VPN 全关后 `ping` 节点**测(开着 TUN ping 是本地假象)。
> 注:TLS 1.3 握手 1-RTT、TLS 1.2 为 2-RTT,故 TLS 握手 ≈ 链路 RTT × 握手轮数;同隧道内横向对比目标站公平。
### 站点矩阵
- **国外(应经隧道)**:google、youtube、github、cloudflare(speed.cloudflare.com)、netflix、openai
- **国内(开分流应直连)**:baidu、qq、bilibili、aliyun/清华镜像、taobao
- **测速点**:Cloudflare(国外)、清华/阿里镜像(国内,用 `-r 0-N` range 取固定大小)
### 方法
-**裸连基线**(不开 VPN 跑一遍存档)→ 再开 VPN 对比衰减。
- 每站取 中位数(多次),记录时段。
- 一条命令产报告:`vpn_test.py`(现覆盖连通/出口/DNS/可达/延迟/下载/IPv6;**待补:上传、ping TTL、站点矩阵扩展、多时段 cron**)。
---
## 三、白盒测试方案(重点,更细)
我们同时掌握**客户端(libbox 命令服务)+ 节点(sing-box Clash API :19090/:19091)+ 配置**,
可做黑盒做不到的链路拆解。
### 3.1 链路五段拆解(每个请求耗时花在哪)
`curl -w` 拆解一次请求的五个阶段,定位瓶颈在接入段还是出海段:
| 阶段 | curl 字段 | 含义 |
|---|---|---|
| DNS 解析 | `time_namelookup` | 域名→IP |
| TCP 连接 | `time_connect` | 到隧道/目标的 TCP 握手 |
| TLS 握手 | `time_appconnect` | TLS/REALITY 握手完成 |
| 首字节 TTFB | `time_starttransfer` | 服务器开始回数据(含出海往返) |
| 总时长 | `time_total` | 整体 |
→ TTFB 高 = 出海段慢;connect 高 = 接入段/节点慢;namelookup 高 = DNS 慢。
### 3.2 协议与出站选择
- 当前激活出站:`reality-out`(VLESS/REALITY,TCP 443)还是 `hy2-out`(Hysteria2,UDP 443)。
- `urltest`(auto)各成员探测延迟(决定选谁)。
- **数据源**:sing-box Clash API `GET /proxies`(看 `auto` 组的 `now` 与各成员 `history` 延迟);
或客户端 libbox 命令服务的 group/outbound 状态。
### 3.3 分段 RTT(定位瓶颈)
- **接入段**:客户端 → 节点 REALITY 端口 TCP RTT(`curl time_connect``节点:443`)。
- **出海段**:在**节点上**直接 `ping`/`curl` 目标站(节点 → 目标 RTT)。
- 对比:接入段快 + 出海段慢 → 节点出海链路是瓶颈;反之亦然。
### 3.4 流量与连接(实时内部状态)
- **Clash API `GET /connections`**:活跃连接列表,每连接的 目标 / 上下行字节 / 走哪个出站 / 命中哪条规则 / 建连时长 → 看分流是否如预期(国内直连、国外走代理)。
- **Clash API `GET /traffic`(SSE)**:实时上下行速率。
- **客户端 libbox**:`writeConnectionEvents` / `writeGroups` / 流量统计(stats EventChannel)。
### 3.5 sing-box 内部决策日志(debug)
- 路由命中:`router: match ... => route(outbound)` → 验证分流规则。
- DNS:`dns: exchange/exchanged ...` → 验证 hijack-dns + 解析路径。
- 连接生命周期:建连/关闭/错误。
- **方法**:配置 `log.level=debug` + `log.output` 落盘读(排障期已用过)。
### 3.6 路径 MTU / 分片
- `ping -D -s <size>` 二分探测路径 MTU,评估 TUN `mtu 9000` 是否导致分片/卡顿
(调研:运营商对大包敏感,业界常 clamp 到 1350)。
### 3.7 节点侧资源(瓶颈定位)
- 节点 CPU/内存/带宽(单核 512MB 是硬约束)、sing-box 进程占用、网卡流量。
- agent / 控制面健康。
### 3.8 稳定性测试(白盒重点)
- **长跑**:cron 每 15 分钟探测(出口 IP + 可达 + 延迟 + 丢包),持续数小时~数天,出趋势曲线。
- **掉线 / 重连**:监测 `NEVPNStatus` 变化,记掉线次数、自动重连耗时。
- **Kill switch**:强杀扩展进程 / 断节点,验证 `strict_route` 把流量掐断(不裸奔),恢复后能重连。
- **GFW 存活**:多日连续可达性;监测**三元组封锁特征**(突然全断 + 120–180s 后恢复);
REALITY 端口被动探测(用普通 TLS 客户端连 :443,应表现得像伪装站 www.apple.com)。
- **热重载**:节点 agent `SIGHUP` 重载 sing-box 后,客户端是否断流 / 平滑。
- **协议切换**:reality-out 不可用时是否切到 hy2-out(urltest 容灾)。
---
## 四、当前测试报告(2026-06-22,初轮)
### 4.1 黑盒(cara,macOS 15.3.2 Intel,白天)
`scripts/vpn_test.py` 全量:**15 PASS / 0 WARN / 0 FAIL**
- 连通性:扩展 `activated enabled`、tun `172.19.0.1`
- 出口 IP:`103.119.13.48`(= 节点,确实走隧道)✅
- DNS:github/google 解析+连通正常 ✅(服务端 `hijack-dns` 已生效)
- 国外可达:google/youtube/github/gstatic HTTP 200/204 ✅
- 国内可达:baidu/qq ✅
- 延迟:**真实 RTT 看 TLS 握手 / TTFB**。实测国外 TLS 握手 **~420490ms**、TTFB ~600715ms
(节点在洛杉矶,中国→LA→目标多次往返,符合预期)。
- IPv6:无泄漏 ✅
> ⚠️ **延迟测量教训(重要)**:经 TUN 代理时,`ping` 和 `curl time_connect` 会被隧道的
> 本地协议栈**就地应答**(0.3ms / 2-4ms 的假象),**不反映真实到目标的 RTT**。初轮误把
> `time_connect` 当延迟(2-4ms),实为本地值。真实延迟必须看 **TLS 握手(`time_appconnect`)**
> 或 **TTFB(`time_starttransfer`)**,或在 **VPN 全关后 ping 节点**测直连 RTT。`vpn_test.py`
> 已据此修正。
### 4.2 吞吐对比(关键)
| 路径 | 速度 | 说明 |
|---|---|---|
| **国外(经隧道)** Cloudflare 10MB | **~1017 Mbps**(多次波动:7.66 / 10.6 / 17.4) | 走 REALITY→节点→出海 |
| **国内(直连)** 清华镜像 10MB | **74.28 Mbps** | geoip-cn 命中 → 直连,不经隧道 |
| 国内 阿里云镜像 | 未测到(URL 失效) | 待换有效测速 URL |
**结论:**
1. **分流生效**:国内直连(74 Mbps)远快于国外经隧道(~10-17),说明 geoip-cn 直连正常。
2. **瓶颈在出海段**:国外吞吐受限于隧道——单核 512MB 联调节点 + 出海带宽 + 可能晚高峰。不是客户端/隧道软件问题。
3. **波动大**(7.66→17.4):需按调研文档**多时段、多次取中位数**,单点不可靠。
### 4.3 待补
- 黑盒:上传速度、ping TTL、站点矩阵扩展、**晚高峰复测**、裸连基线对比。
- 白盒:Clash API 拉连接/出站/urltest 延迟、五段拆解、分段 RTT(接入 vs 出海)、稳定性长跑、Kill switch、GFW 存活。
---
## 五、工具落地计划
1. **扩展 `scripts/vpn_test.py`(黑盒)**:加 上传测速 / ping TTL / 站点矩阵 / `--baseline` 基线 / 多时段 cron。
2. **新增 `scripts/vpn_whitebox.sh`(白盒)**:读节点 Clash API(连接/出站/延迟)+ curl 五段拆解 + 接入/出海分段 RTT + 路径 MTU。
3. **新增稳定性长跑**:cron 周期探测落 CSV,出趋势;Kill switch / 重连 / GFW 存活脚本。
+124
View File
@@ -0,0 +1,124 @@
# VPN 测试调研(测什么 / 怎么测 / Pangolin 测试工具设计)
> 目的:在动手写测试工具前,先搞清楚 VPN 该测哪些维度、行业与中国/GFW 场景怎么测,
> 再据此设计 Pangolin 自己的测试工具。日期 2026-06-22。
---
## TL;DR
VPN 测试 = **连得上 + 连得对 + 连得快 + 连得稳 + 不泄漏 + (中国场景)穿得过且不被封**
六大类:**连通性 / 正确性(出口·DNS·分流)/ 泄漏 / 性能(延迟·吞吐·丢包)/ 稳定性(掉线·重连·killswitch)/ 抗封锁(GFW)**。
Pangolin 是面向大陆、REALITY+sing-box、macOS 自研客户端,**抗封锁 + 国内分流正确性 + 晚高峰吞吐**是和通用评测最不同、最该重点测的。
---
## 1. 行业通用 VPN 评测怎么测
主流评测站(Security.org、Cloudwards、PCMag 等)的共识方法论:
| 维度 | 测什么 | 怎么测 / 工具 | 通过标准 |
|---|---|---|---|
| **速度/吞吐** | 下载、上传、相对基线的衰减 | Ookla/iperf,**多服务器距离**(本地/美/英/亚)、**多时段**(早/晚高峰)、先测裸连基线再测开 VPN | 近端衰减 <1020% 算优;远端单独看 |
| **延迟** | RTT(ping)、首包延迟 | ping / curl `time_connect` | 越低越好;近端 +几十 ms 可接受 |
| **DNS 正确性** | 域名能否解析、解析延迟 | `dig`/`nslookup`、curl `time_namelookup` | 能解析、不超时 |
| **IP 泄漏** | 真实 IP 是否暴露(IPv4/IPv6) | 访问 ipify/ifconfig.me/ipleak.net,对比开关 VPN 出口 | 出口必须是节点 IP,**不能是本机真实 IP** |
| **DNS 泄漏** | DNS 查询是否走了本地 ISP | dnsleaktest.com、Wireshark 抓 53 端口去向 | 查询只经隧道/指定 DNS |
| **WebRTC 泄漏** | 浏览器 WebRTC 暴露真实 IP | browserleaks.com/webrtc | 不暴露 |
| **IPv6 泄漏** | IPv6 绕过隧道 | test-ipv6.com | 无 v6 泄漏(或 v6 也走隧道) |
| **Kill Switch** | 隧道意外断开时是否阻断流量 | 强杀扩展/断网,看是否还能裸连出网 | 断开瞬间流量被掐 |
| **流媒体/可达性** | 能否访问目标站点 | 实访 Netflix/YouTube/Google 等 | 能正常打开/播放 |
| **稳定性** | 长时连接掉线率、重连 | 长跑数小时,周期性请求 | 不频繁掉线;掉了能自动重连 |
| **抓包审计** | 是否有明文/异常外联 | Wireshark/tcpdump | 无明文、无意外外联 |
要点:**先测基线(不开 VPN)再测开 VPN 做对比**;**多次/多时段**取分布而非单点。
---
## 2. 中国 / GFW 场景特有的测法(Pangolin 重点)
通用评测不覆盖、但对面向大陆的 VPN 是生死线:
- **抗 GFW 封锁(核心)**
- GFW 一旦判定某连接是代理,会对 **(client IP, server IP, server port) 三元组封 120180 秒**(期间丢所有包)。测试:连一段时间后看是否突然全断、过几分钟又能连——这是被三元组封的特征。
- **主动探测(active probing)**:GFW 会主动连你的端口探测。REALITY 的防御是把探测流量转发给真实伪装站(如 apple.com)。测试:用普通 TLS 客户端(curl/openssl)连节点的 REALITY 端口,应表现得**像真实伪装站**(返回真站证书/内容),而不是暴露代理特征。
- **全加密流量检测**:GFW 对"高熵全加密"流量有启发式封锁。REALITY/VLESS 借真证书规避。测试:长期存活率(连续几天能否一直连)。
- **晚高峰吞吐**:国内出海带宽晚高峰(20:00–24:00)严重劣化。**必须按时段测吞吐**(白天 vs 晚高峰),单测白天没意义。
- **MTU / 分片**:运营商(尤其移动 5G)对大包/分片敏感,会借此识别。业界经验 **MTU clamp 到 1350(IPv6 1280)** 降低被识别和卡顿。测试:大包传输是否卡顿、丢包率。⚠️ 我们 TUN 配的是 `mtu 9000`(sing-box 默认),需评估对 REALITY-over-TCP 的实际影响。
- **国内分流正确性(#5)**:`geoip-cn`/`geosite-cn` 命中应**直连**(不走隧道),国外才走隧道。测试:访问国内站(如 baidu)出口应是**本地 IP**,国外站出口是**节点 IP**——分流错了要么国内变慢、要么国外漏走直连。
- **协议可达性矩阵**:REALITY(TCP 443)、Hysteria2(UDP 443)分别在不同网络(电信/联通/移动、家宽/5G)下的可达性。
参考:GFW 对全加密/QUIC 的检测与封锁机制见 gfw.report 的 USENIX 论文。
---
## 3. Pangolin 该测什么(结合架构分层)
架构:macOS 客户端(NEPacketTunnelProvider 系统扩展 + 内嵌 libbox/sing-box)→ REALITY(TCP)/Hy2(UDP)→ 节点 → 出海。控制面 :8080 下发配置。
### A. 连通性(能不能起来)
- 系统扩展 `activated enabled`(`systemextensionsctl list`)。
- 隧道接口起来(utun + `172.19.0.1`)、NEVPNStatus = connected。
- 控制面可达(:8080)、REALITY 数据口可达(节点 endpoint 端口)。
- **首次连接耗时**(含批准)、**重连耗时**。
### B. 正确性(连得对)
- **出口 IP = 节点 IP**(`curl ipify`),证明真走隧道。
- **DNS 解析正常**(域名能开;`hijack-dns` 生效——这次的坑)。
- **国内分流**:国内站出口=本地、国外站出口=节点(开 smartRoute 时)。
-**IP/DNS/IPv6 泄漏**
### C. 性能(连得快)
- 延迟:到节点 RTT、隧道内到常见站 RTT。
- 吞吐:下载/上传速度,**白天 vs 晚高峰**,对比裸连基线。
- 丢包率、DNS 解析延迟。
### D. 稳定性(连得稳)
- 长跑掉线率、自动重连。
- Kill switch(`strict_route` 已开):强杀扩展后流量是否被掐。
- 节点 agent 热重载(SIGHUP)后客户端是否平滑。
### E. 抗封锁(穿得过)
- REALITY 端口被动探测表现(伪装站特征)。
- 长期存活率(多日)。
- 三元组封锁特征监测。
---
## 4. 测试工具设计建议
分三层,**先做能自动化、回报最高的"客户端侧黑盒探测脚本"**:
### 工具一:`scripts/vpn_test.py`(客户端侧黑盒,优先做)
在已连接的机器(本机/cara)上跑,一条命令出报告。覆盖 B+C 大部分:
- **出口 IP**:`curl ipify`,判定 = 节点 / = 本地 / 超时。
- **DNS**:解析 github/google + `time_namelookup`;开/关分流分别测国内外站出口。
- **可达性矩阵**:对一组目标(google/youtube/github/baidu…)测 HTTP 码 + 耗时。
- **延迟**:`curl time_connect` 到节点和若干站。
- **吞吐**:下载固定大小文件测 `speed_download`(用 cloudflare speed / 自建测速点)。
- **泄漏**:IPv6 出口、DNS 出口对比。
- 输出:表格 + 时间戳,可重复跑做时段对比。
- 复用本会话已验证的 `ssh cara '...'` 远程跑法。
### 工具二:节点侧探针(可选)
- REALITY 端口被动探测模拟(openssl 连 443 看是否像伪装站)。
- 三元组封锁监测(从国内 vantage 持续发包看存活)。
### 工具三:客户端内置自检(产品化,后续)
- app 内"诊断"按钮:跑连通/出口/DNS 自检,给用户一键报告(也利于支持排障)。
### 输出与判定
- 每项给 **PASS/WARN/FAIL** + 实测值 + 阈值。
- 支持 **基线对比**(先 `--baseline` 裸连存一份,再开 VPN 对比衰减)。
- 支持 **多时段**(cron 跑,产出趋势)。
---
## 5. 参考来源
- Security.org《Best VPN Services of 2026: 50+ VPNs Tested》— 速度/泄漏/流媒体方法论:https://www.security.org/vpn/best/
- Cloudwards《VPN Test: DNS Leaks, IP Address Leaks, Speed Issues》:https://www.cloudwards.net/vpn-test-guide/
- DoVPN《IP Leak Test Guide》:https://dovpn.com/ip-leak-test-guide/
- gfw.report — GFW 对全加密流量的检测(USENIX'23):https://gfw.report/publications/usenixsecurity23/en/
- gfw.report — GFW 对 QUIC/SNI 的封锁(USENIX'25):https://gfw.report/publications/usenixsecurity25/en/
- VLESS-REALITY 部署/绕过实践:https://greatfirewallguide.com/lab/vless-reality-vision