diff --git a/docs/vpn-test-plan.md b/docs/vpn-test-plan.md new file mode 100644 index 0000000..f9bcb63 --- /dev/null +++ b/docs/vpn-test-plan.md @@ -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:00–24: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 优 / 500–1000ms 偏高 / >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 ` 二分探测路径 MTU,评估 TUN `mtu 9000` 是否导致分片/卡顿 + (调研:运营商对大包敏感,业界常 clamp 到 1350)。 + +### 3.7 节点侧资源(瓶颈定位) +- 节点 CPU/内存/带宽(单核 512MB 是硬约束)、sing-box 进程占用、网卡流量。 +- agent / 控制面健康。 + +### 3.8 稳定性测试(白盒重点) +- **长跑**:cron 每 1–5 分钟探测(出口 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 握手 **~420–490ms**、TTFB ~600–715ms + (节点在洛杉矶,中国→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 | **~10–17 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 存活脚本。 diff --git a/docs/vpn-testing-research.md b/docs/vpn-testing-research.md new file mode 100644 index 0000000..c81a02e --- /dev/null +++ b/docs/vpn-testing-research.md @@ -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 | 近端衰减 <10–20% 算优;远端单独看 | +| **延迟** | 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) 三元组封 120–180 秒**(期间丢所有包)。测试:连一段时间后看是否突然全断、过几分钟又能连——这是被三元组封的特征。 + - **主动探测(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 diff --git a/scripts/vpn_test.py b/scripts/vpn_test.py new file mode 100644 index 0000000..a239180 --- /dev/null +++ b/scripts/vpn_test.py @@ -0,0 +1,491 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +vpn_test.py —— Pangolin VPN 黑盒测试(纯标准库,零依赖) + +在"已连接 VPN 的机器"上跑,测国内外常见站点矩阵,生成 HTML 报告。 + +延迟标准(TTL):本工具的 "TTL/延迟" = TLS 握手耗时(从 TCP 建立完成到 TLS 握手完成), + ≈ 真实网络链路往返 × 握手轮数。**不是 IP TTL**;也不用 ping/tcp_connect——经 TUN 代理时 + 那些会被隧道本地应答(几 ms 假象),不反映真实到目标的 RTT。详见 docs/vpn-test-plan.md。 + TTFB(参考)= 发出请求到收到首字节,含目标服务器处理时间。 + +用法: + python3 scripts/vpn_test.py # 测全部,报告写到 ./vpn_report.html + python3 scripts/vpn_test.py -o /tmp/r.html # 指定报告路径 + NODE_IP=103.119.13.48 python3 scripts/vpn_test.py + 远程:scp scripts/vpn_test.py cara@HOST:/tmp/ && ssh cara@HOST 'python3 /tmp/vpn_test.py -o /tmp/r.html' \ + && scp cara@HOST:/tmp/r.html . +""" +import os, sys, ssl, socket, time, json, argparse, html, re, glob, statistics, urllib.request +from datetime import datetime + +NODE_IP = os.environ.get("NODE_IP", "103.119.13.48") +TIMEOUT = float(os.environ.get("TIMEOUT", "15")) + +# 测试产物根目录:每次跑落 runs/<时间>-<主机>.html,并重建 index.html 索引(见 tests/vpn/README.md)。 +HERE = os.path.dirname(os.path.abspath(__file__)) +TEST_HOME = os.path.normpath(os.path.join(HERE, "..", "tests", "vpn")) + +# 站点矩阵(name, host)。国外应经隧道,国内开分流应直连。 +FOREIGN = [ + ("Google", "www.google.com"), ("YouTube", "www.youtube.com"), + ("GitHub", "github.com"), ("X/Twitter", "x.com"), + ("Facebook", "www.facebook.com"), ("Instagram", "www.instagram.com"), + ("Wikipedia", "en.wikipedia.org"), ("Cloudflare", "www.cloudflare.com"), + ("OpenAI", "api.openai.com"), ("Reddit", "www.reddit.com"), +] +DOMESTIC = [ + ("百度", "www.baidu.com"), ("腾讯", "www.qq.com"), ("淘宝", "www.taobao.com"), + ("京东", "www.jd.com"), ("B站", "www.bilibili.com"), ("微博", "weibo.com"), + ("网易", "www.163.com"), ("知乎", "www.zhihu.com"), ("阿里云", "www.aliyun.com"), +] +# 测速点:(label, url, 期望路径)。国外经隧道,国内直连。 +DL_FOREIGN = ("Cloudflare(国外/隧道)", "https://speed.cloudflare.com/__down?bytes=10000000") +DL_DOMESTIC = ("清华镜像(国内/直连)", "https://mirrors.tuna.tsinghua.edu.cn/ubuntu-releases/22.04/ubuntu-22.04.5-live-server-amd64.iso") + +# 延迟判定阈值(TLS 握手,单程经隧道到国外;ms) +LAT_GOOD, LAT_WARN = 500, 1000 + + +def measure_site(host, port=443): + """返回 dict: ok, code, dns_ms, tls_ms(=延迟/TTL), ttfb_ms, err""" + r = {"host": host, "ok": False, "code": None, "dns_ms": None, + "tls_ms": None, "ttfb_ms": None, "err": ""} + try: + t0 = time.monotonic() + infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + t_dns = time.monotonic() + af, st, proto, _, sa = infos[0] + s = socket.socket(af, st, proto) + s.settimeout(TIMEOUT) + s.connect(sa) + t_tcp = time.monotonic() + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE # 只测连通/延迟,不验证证书(避免证书问题干扰) + ss = ctx.wrap_socket(s, server_hostname=host) + t_tls = time.monotonic() # ← TLS 握手完成 + req = (f"GET / HTTP/1.1\r\nHost: {host}\r\n" + "User-Agent: pangolin-vpn-test\r\nAccept: */*\r\nConnection: close\r\n\r\n") + t_req = time.monotonic() + ss.sendall(req.encode()) + first = ss.recv(256) # ← 首字节 + t_first = time.monotonic() + try: + line = first.split(b"\r\n", 1)[0].decode("latin1") + r["code"] = int(line.split()[1]) if line.startswith("HTTP/") else None + except Exception: + r["code"] = None + ss.close() + r["dns_ms"] = round((t_dns - t0) * 1000, 1) + r["tls_ms"] = round((t_tls - t_tcp) * 1000, 1) # 延迟(TLS 握手) + r["ttfb_ms"] = round((t_first - t_req) * 1000, 1) # 参考(请求→首字节) + r["ok"] = r["code"] is not None + except Exception as e: + r["err"] = type(e).__name__ + ": " + str(e) + return r + + +def local_connectivity(): + """本机连通性(macOS):tun 接口 / 系统扩展状态。best-effort。""" + import subprocess + out = {"tun": None, "sysext": None} + try: + r = subprocess.run(["ifconfig"], capture_output=True, text=True, timeout=8) + out["tun"] = "172.19.0.1" in r.stdout + except Exception: + pass + try: + r = subprocess.run(["systemextensionsctl", "list"], capture_output=True, text=True, timeout=8) + for ln in r.stdout.splitlines(): + if "pangolin" in ln.lower() and "activated" in ln: + out["sysext"] = "enabled" if "enabled" in ln else "waiting for user" + break + except Exception: + pass + return out + + +def egress_ip(): + for u in ("https://api.ipify.org", "https://ifconfig.me/ip", "https://ipinfo.io/ip"): + try: + ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE + with urllib.request.urlopen(u, timeout=TIMEOUT, context=ctx) as resp: + ip = resp.read().decode().strip() + if ip: + return ip + except Exception: + continue + return "" + + +def geo(ip): + try: + ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE + with urllib.request.urlopen(f"https://ipinfo.io/{ip}/json", timeout=TIMEOUT, context=ctx) as resp: + d = json.load(resp) + return f"{d.get('city','?')}, {d.get('country','?')} ({d.get('org','?')})" + except Exception: + return "?" + + +def download_mbps(url): + """下载固定大小测吞吐,返回 (mbps, bytes, secs) 或 (None, ...)。""" + try: + ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE + req = urllib.request.Request(url, headers={"Range": "bytes=0-9999999", + "User-Agent": "pangolin-vpn-test"}) + t0 = time.monotonic(); n = 0 + with urllib.request.urlopen(req, timeout=30, context=ctx) as resp: + while True: + chunk = resp.read(65536) + if not chunk: + break + n += len(chunk) + if n >= 10_000_000 or time.monotonic() - t0 > 25: + break + dt = time.monotonic() - t0 + if n > 100000 and dt > 0: + return round(n * 8 / 1e6 / dt, 2), n, round(dt, 2) + except Exception: + pass + return None, 0, 0 + + +def verdict_latency(tls_ms): + if tls_ms is None: + return "FAIL" + if tls_ms < LAT_GOOD: + return "PASS" + if tls_ms < LAT_WARN: + return "WARN" + return "WARN" # 高延迟标 WARN 不算 FAIL(只要能连) + + +# ── 主流程 ─────────────────────────────────────────────────────────── +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("-o", "--out", default=None, + help="报告输出路径(默认 tests/vpn/runs/<时间>-<主机>.html)") + ap.add_argument("--runs-dir", default=os.path.join(TEST_HOME, "runs"), + help="run 报告目录(默认 tests/vpn/runs)") + ap.add_argument("--index", default=os.path.join(TEST_HOME, "index.html"), + help="索引页路径(默认 tests/vpn/index.html)") + ap.add_argument("--no-index", action="store_true", help="不重建索引") + ap.add_argument("--no-download", action="store_true", help="跳过吞吐测试") + args = ap.parse_args() + + started = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"Pangolin VPN 测试 {started} 期望出口={NODE_IP}") + + conn = local_connectivity() + if conn["tun"] is not None or conn["sysext"] is not None: + print(f"本机连通性: tun(172.19.0.1)={'有' if conn['tun'] else '无'} " + f"系统扩展={conn['sysext'] or '未激活'}") + + eip = egress_ip() + egeo = geo(eip) if eip else "" + tunneled = (eip == NODE_IP) + print(f"出口 IP: {eip or '(取不到)'} {egeo} {'[走隧道]' if tunneled else '[非节点!]'}") + + def run(group, sites): + rows = [] + for name, host in sites: + r = measure_site(host) + r["name"] = name + rows.append(r) + lat = f"{r['tls_ms']}ms" if r["tls_ms"] is not None else "—" + print(f" [{group}] {name:10s} {host:28s} " + f"{'HTTP '+str(r['code']) if r['ok'] else 'FAIL '+r['err'][:30]:18s} TTL(TLS)={lat}") + return rows + + print("\n国外站点(经隧道):") + f_rows = run("国外", FOREIGN) + print("\n国内站点(开分流应直连):") + d_rows = run("国内", DOMESTIC) + + dls = [] + if not args.no_download: + print("\n吞吐:") + for label, url in (DL_FOREIGN, DL_DOMESTIC): + mbps, n, dt = download_mbps(url) + dls.append((label, mbps, n, dt)) + print(f" {label}: {mbps if mbps is not None else 'FAIL'} Mbps ({n}B/{dt}s)") + + # 输出路径:默认落 runs/<时间>-<主机>.html + host_id = socket.gethostname().split(".")[0] + run_id = datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + host_id + out = args.out or os.path.join(args.runs_dir, run_id + ".html") + os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) + + meta = run_meta(run_id, started, host_id, eip, egeo, tunneled, f_rows, d_rows, dls) + html_out = render_html(started, eip, egeo, tunneled, f_rows, d_rows, dls, conn, meta) + with open(out, "w", encoding="utf-8") as fp: + fp.write(html_out) + print(f"\n报告已生成: {os.path.abspath(out)}") + + # 重建索引(扫描 runs/ 内嵌 meta,inline 渲染,file:// 直开可用) + if not args.no_index: + try: + n = regenerate_index(args.runs_dir, args.index) + print(f"索引已更新: {os.path.abspath(args.index)} ({n} 条记录)") + except Exception as e: + print(f"索引重建失败(不影响本次报告): {e}") + + # 汇总 + okf = sum(1 for r in f_rows if r["ok"]); okd = sum(1 for r in d_rows if r["ok"]) + print(f"汇总: 国外 {okf}/{len(f_rows)} 可达, 国内 {okd}/{len(d_rows)} 可达, 出口{'=节点✓' if tunneled else '≠节点!'}") + + +def _median(vals): + vals = [v for v in vals if v is not None] + return round(statistics.median(vals), 1) if vals else None + + +def run_meta(run_id, started, host_id, eip, egeo, tunneled, f_rows, d_rows, dls): + """本次 run 的摘要,内嵌进报告供索引页读取。""" + dl = {l: m for (l, m, _n, _dt) in dls} + return { + "run_id": run_id, "started": started, "host": host_id, + "egress": eip, "geo": egeo, "tunneled": tunneled, + "foreign_ok": sum(1 for r in f_rows if r["ok"]), "foreign_n": len(f_rows), + "domestic_ok": sum(1 for r in d_rows if r["ok"]), "domestic_n": len(d_rows), + "foreign_tls_median": _median([r["tls_ms"] for r in f_rows]), + "domestic_tls_median": _median([r["tls_ms"] for r in d_rows]), + "dl": dl, + } + + +def regenerate_index(runs_dir, index_path): + """扫描 runs/*.html 内嵌的 meta,重建索引页(inline 数据,file:// 可直开)。返回记录数。""" + metas = [] + for f in glob.glob(os.path.join(runs_dir, "*.html")): + try: + with open(f, encoding="utf-8") as fp: + txt = fp.read() + m = re.search(r'', + txt, re.DOTALL) + if not m: + continue + d = json.loads(m.group(1)) + d["_file"] = os.path.relpath(f, os.path.dirname(os.path.abspath(index_path))) + metas.append(d) + except Exception: + continue + metas.sort(key=lambda d: d.get("started", ""), reverse=True) + + def cell(v, unit="", good=None, warn=None, hi_bad=True): + if v is None: + return '—' + c = "" + if good is not None: + if hi_bad: + c = "pass" if v < good else ("warn" if v < warn else "fail") + else: + c = "pass" if v > good else ("warn" if v > warn else "fail") + return f'{v}{unit}' + + rows = [] + for d in metas: + dl = d.get("dl", {}) + dlf = next((v for k, v in dl.items() if "国外" in k or "隧道" in k), None) + dld = next((v for k, v in dl.items() if "国内" in k or "直连" in k), None) + eg = html.escape(str(d.get("egress") or "—")) + eg_c = "pass" if d.get("tunneled") else "warn" + kind = "白盒" if d.get("kind") == "whitebox" else "黑盒" + kind_c = "warn" if kind == "白盒" else "" + rows.append( + f"" + f"{html.escape(d.get('started',''))}" + f"{kind}" + f"{html.escape(d.get('host',''))}" + f"{eg}" + f"{d.get('foreign_ok','?')}/{d.get('foreign_n','?')}" + f"{d.get('domestic_ok','?')}/{d.get('domestic_n','?')}" + + cell(d.get("foreign_tls_median"), " ms", LAT_GOOD, LAT_WARN) + + cell(d.get("domestic_tls_median"), " ms", LAT_GOOD, LAT_WARN) + + cell(dlf, " M", 20, 5, hi_bad=False) + + cell(dld, " M", 20, 5, hi_bad=False) + + f"查看 →") + body = "\n".join(rows) or "暂无测试记录,跑一次 vpn_test.py 即可。" + + # 架构图:三节点(cara / LA / Google)+ 编号箭头标流向 + 下方步骤表。内联 SVG,离线可看。 + flow_svg = """ +

时序图:cara 访问 Google 的流程(时间从上往下)

+
+ + + + + + + + + + + + + cara + 中国 · 浏览器 + + LA 节点 + 103.119.13.48 · sing-box + + Google + 8.8.8.8 / 142.251.x + + DNS 查询(经隧道) + + 1 + + 向 8.8.8.8 解析域名 + + 2 + + 返回 google IP(经隧道) + + 3 + + HTTPS 数据 · 接入段 · 瓶颈 ⚠️ + + 4 + + 节点出海连 google(~43ms) + + 5 + + google 响应 + + 6 + + 响应回 cara(经隧道),网页打开 + + 7 + +
图例:时间从上往下;橙=经 REALITY 隧道(接入段,慢) · 绿=节点出海(快) · ④红=延迟瓶颈
+ + + + + + + + + +
步骤路段做什么
① 1cara → LADNS 查询走隧道:google 是国外域名(不命中 geosite-cn),DNS 请求经 REALITY 隧道发往 LA 节点。
② 2LA → Google节点把 DNS 查询转给 8.8.8.8 解析,得到 google 的 IP。
③ 3LA → cara解析出的 google IP 经隧道回传给 cara。
④ 4cara → LAcara 用该 IP 发起 HTTPS,数据经 REALITY 隧道到节点 —— 接入段,~1000–2700ms,延迟瓶颈(IP 也不命中 geoip-cn,故走隧道)。
⑤ 5LA → Google节点代 cara 连接 google —— 出海段,~43ms(LA 离 Google 很近)。
⑥ 6Google → LAgoogle 把响应数据返回给节点。
⑦ 7LA → cara响应经隧道回到 cara,网页打开。
+
+ 📌 对比 · 国内站(如 baidu)不走这条路:域名命中 geosite-cn → 用 local(223.5.5.5)国内 DNS 直连解析 → 拿到真 CN IP → 命中 geoip-cn → 走 direct 直连,全程不经 LA 节点 / 不进隧道 → TLS ~50ms。本次修复正是补上了 DNS 面的分流。 +
+
+""" + + page = f""" + +Pangolin VPN 测试索引 +
+

Pangolin VPN 测试索引

+

共 {len(metas)} 次记录 · 最近更新 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} · 点行进报告

+
延迟列 = TLS 握手中位数(<{LAT_GOOD}ms 绿 / <{LAT_WARN}ms 橙 / 更高红)。 + 下载列 = Mbps(>20 绿 / >5 橙)。出口绿=走隧道(=节点),橙=非节点。口径见 docs/vpn-test-plan.md。
+{flow_svg} +

历次测试记录

+ + + +{body} +
时间类型主机出口IP国外可达国内可达国外延迟国内延迟国外下载国内下载
+
""" + os.makedirs(os.path.dirname(os.path.abspath(index_path)), exist_ok=True) + with open(index_path, "w", encoding="utf-8") as fp: + fp.write(page) + return len(metas) + + +def render_html(started, eip, egeo, tunneled, f_rows, d_rows, dls, conn=None, meta=None): + def cls(v): # 延迟着色 + if v is None: + return "fail" + return "pass" if v < LAT_GOOD else "warn" + def row(r): + if not r["ok"]: + return (f"{html.escape(r['name'])}{html.escape(r['host'])}" + f"不可达 {html.escape(r['err'][:40])}") + return (f"{html.escape(r['name'])}{html.escape(r['host'])}" + f"HTTP {r['code']}" + f"{r['tls_ms']} ms" + f"{r['ttfb_ms']} ms" + f"{r['dns_ms']} ms") + ftab = "\n".join(row(r) for r in f_rows) + dtab = "\n".join(row(r) for r in d_rows) + dlrows = "\n".join( + f"{html.escape(l)}" + f"{m if m is not None else '失败'} Mbps{n}B / {dt}s" + for l, m, n, dt in dls) + egress_cls = "pass" if tunneled else "warn" + return f""" + +Pangolin VPN 测试报告 {started} + +
+

Pangolin VPN 测试报告

+

{started} · 期望节点出口 {NODE_IP} · ← 返回索引

+ +
+ 出口 IP:{html.escape(eip or '取不到')} {html.escape(egeo)} + — {'流量走隧道(出口=节点)' if tunneled else '出口≠节点(可能直连/其他 VPN/DNS 挂)'} + {('
本机:tun(172.19.0.1)=' + ('有' if conn.get('tun') else '无') + ' · 系统扩展=' + (conn.get('sysext') or '未激活')) if conn else ''} +
+ +
+ 延迟口径(TTL):本表「TTL/延迟」= TLS 握手耗时(TCP 建立完成→TLS 握手完成), + ≈ 真实网络链路往返 × 握手轮数,跨站可比、不含目标服务器后端处理。非 IP TTL;也不用 + ping/tcp_connect(经 TUN 被隧道本地应答,几 ms 假象,不可信)。 + TTFB 为参考 = 请求→首字节(含服务器处理),受目标站自身快慢影响。 + 判定:TLS 握手 <{LAT_GOOD}ms 优 / {LAT_GOOD}–{LAT_WARN}ms 偏高 / >{LAT_WARN}ms 高。 +
+ +

国外站点(应经隧道)

+ +{ftab}
站点域名连通TTL/延迟(TLS握手)TTFB(参考)DNS
+ +

国内站点(开分流应直连)

+ +{dtab}
站点域名连通TTL/延迟(TLS握手)TTFB(参考)DNS
+ +

吞吐(下载 ~10MB)

+ +{dlrows or ''}
测速点速度明细
(已跳过)
+ +

方法详见 docs/vpn-test-plan.md / docs/vpn-testing-research.md。生成工具:scripts/vpn_test.py

+
""" + + +if __name__ == "__main__": + main() diff --git a/scripts/vpn_whitebox.py b/scripts/vpn_whitebox.py new file mode 100644 index 0000000..a61dbfd --- /dev/null +++ b/scripts/vpn_whitebox.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +vpn_whitebox.py —— Pangolin VPN 白盒下钻(纯标准库,零依赖) + +回答两个问题: + ① 国内流量是不是也走了代理(隧道)? → 路由判定(双 IP-echo) + ② 整体延迟为什么这么高,慢在链路哪一段? → 五段拆解(DNS/TCP/TLS/TTFB) + +与黑盒 vpn_test.py 的区别:黑盒看"快不快/通不通";白盒看"走哪条路、慢在哪段"。 + +—— 核心方法 —— +路由判定:客户端 sing-box 没开 Clash API,无法直接看每条连接走哪个出站。改用**双 IP-echo**: + - 国外 echo(ipify 等)看到的出口 = 隧道出口(应=节点)。 + - 国内 echo(ipip.net / 淘宝)看到的出口:若分流正常→国内站直连→看到的是**本机真实 ISP IP**; + 若 = 节点 IP,说明**国内也被隧道了**(分流失效)。 +五段拆解:对每个站点用原始 socket 量 DNS / TCP / TLS / 请求→首字节(TTFB),定位瓶颈: + TLS 段高=链路 RTT 高(走没走隧道、出海远);DNS 段高=域名解析走了远端;TTFB 高=目标后端/出海慢。 + +可选(更细):给 NODE_SSH=别名 → ssh 到节点直接 curl 目标,量"出海段"RTT,与"接入+出海"对比, + 拆出接入段 vs 出海段各占多少。 + +用法: + python3 scripts/vpn_whitebox.py # 落 tests/vpn/runs/ 并刷新索引 + NODE_SSH=racknerd python3 scripts/vpn_whitebox.py # 额外做节点侧出海段拆解 + python3 scripts/vpn_whitebox.py -o /tmp/wb.html +""" +import os, ssl, socket, time, json, argparse, html, subprocess, urllib.request +from datetime import datetime + +NODE_IP = os.environ.get("NODE_IP", "103.119.13.48") +NODE_SSH = os.environ.get("NODE_SSH", "") # 设了就用 SSH 做节点侧出海段拆解(备选) +# 控制面诊断端点(默认):无需 SSH,白盒直接 HTTP 拉「节点→目标」出海段耗时, +# 用来把端到端延迟拆成「接入段(客户端→节点) vs 出海段(节点→目标)」。 +CONTROL_PLANE = os.environ.get("PANGOLIN_API", "http://%s:8080" % NODE_IP) +TIMEOUT = float(os.environ.get("TIMEOUT", "15")) + +HERE = os.path.dirname(os.path.abspath(__file__)) +TEST_HOME = os.path.normpath(os.path.join(HERE, "..", "tests", "vpn")) + +FOREIGN = [("Google", "www.google.com"), ("GitHub", "github.com"), + ("Cloudflare", "www.cloudflare.com"), ("YouTube", "www.youtube.com")] +DOMESTIC = [("百度", "www.baidu.com"), ("淘宝", "www.taobao.com"), + ("B站", "www.bilibili.com"), ("网易", "www.163.com")] + +# 国外出口 echo(走隧道时反映节点 IP) +ECHO_FOREIGN = ["https://api.ipify.org", "https://ifconfig.me/ip"] +# 国内出口 echo(域名是国内站,分流正常时这次请求直连 → 反映本机真实 ISP IP) +ECHO_CN = ["https://myip.ipip.net", "https://www.taobao.com/help/getip.php"] + + +def _ctx(): + c = ssl.create_default_context() + c.check_hostname = False + c.verify_mode = ssl.CERT_NONE + return c + + +def five_phase(host, port=443): + """五段拆解。返回 dict: ip, dns_ms, tcp_ms, tls_ms, ttfb_ms, total_ms, code, err""" + r = {"host": host, "ip": None, "dns_ms": None, "tcp_ms": None, + "tls_ms": None, "ttfb_ms": None, "total_ms": None, "code": None, "err": ""} + try: + t0 = time.monotonic() + infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + t_dns = time.monotonic() + af, st, proto, _, sa = infos[0] + r["ip"] = sa[0] + s = socket.socket(af, st, proto) + s.settimeout(TIMEOUT) + s.connect(sa) + t_tcp = time.monotonic() + ss = _ctx().wrap_socket(s, server_hostname=host) + t_tls = time.monotonic() + req = (f"GET / HTTP/1.1\r\nHost: {host}\r\nUser-Agent: pangolin-wb\r\n" + "Accept: */*\r\nConnection: close\r\n\r\n") + ss.sendall(req.encode()) + first = ss.recv(256) + t_first = time.monotonic() + try: + line = first.split(b"\r\n", 1)[0].decode("latin1") + r["code"] = int(line.split()[1]) if line.startswith("HTTP/") else None + except Exception: + pass + ss.close() + r["dns_ms"] = round((t_dns - t0) * 1000, 1) + r["tcp_ms"] = round((t_tcp - t_dns) * 1000, 1) + r["tls_ms"] = round((t_tls - t_tcp) * 1000, 1) + r["ttfb_ms"] = round((t_first - t_tls) * 1000, 1) + r["total_ms"] = round((t_first - t0) * 1000, 1) + except Exception as e: + r["err"] = type(e).__name__ + ": " + str(e) + return r + + +def echo_ip(urls): + """从一组 echo 服务取出口 IP(返回首个成功的 ip 字符串)。""" + import re + for u in urls: + try: + with urllib.request.urlopen(u, timeout=TIMEOUT, context=_ctx()) as resp: + txt = resp.read().decode("utf-8", "ignore") + m = re.search(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", txt) + if m: + return m.group(0), u + except Exception: + continue + return None, None + + +def node_egress(host): + """量「节点→目标」出海段。优先走控制面诊断端点(HTTP,无需 SSH), + 回退到 NODE_SSH(curl)。返回 dict{tls_ms,dns_ms,ttfb_ms} 或 None。""" + # 1) 控制面诊断端点 /v1/diag/egress(节点本机量出海段) + try: + u = CONTROL_PLANE.rstrip("/") + "/v1/diag/egress?host=" + host + with urllib.request.urlopen(u, timeout=TIMEOUT, context=_ctx()) as resp: + d = json.load(resp) + if d.get("tls_ms") is not None: + return {"tls_ms": round(d["tls_ms"], 1), + "dns_ms": round(d["dns_ms"], 1) if d.get("dns_ms") is not None else None, + "ttfb_ms": round(d["ttfb_ms"], 1) if d.get("ttfb_ms") is not None else None} + except Exception: + pass + # 2) 回退:SSH 到节点 curl(出海段 TLS 握手) + if NODE_SSH: + try: + cmd = ["ssh", "-o", "ConnectTimeout=8", "-o", "BatchMode=yes", NODE_SSH, + f"curl -s -o /dev/null -m 12 -w '%{{time_appconnect}}' https://{host}/ 2>/dev/null"] + out = subprocess.run(cmd, capture_output=True, text=True, timeout=20) + v = out.stdout.strip().split()[-1] if out.stdout.strip() else "" + if v: + return {"tls_ms": round(float(v) * 1000, 1), "dns_ms": None, "ttfb_ms": None} + except Exception: + pass + return None + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("-o", "--out", default=None) + ap.add_argument("--runs-dir", default=os.path.join(TEST_HOME, "runs")) + ap.add_argument("--index", default=os.path.join(TEST_HOME, "index.html")) + ap.add_argument("--no-index", action="store_true") + args = ap.parse_args() + + started = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"Pangolin 白盒下钻 {started} 期望节点出口={NODE_IP} 出海段来源={CONTROL_PLANE}/v1/diag/egress" + + (f" (回退 SSH={NODE_SSH})" if NODE_SSH else "")) + + # ── 路由判定:双 IP-echo ────────────────────────────────────────── + fip, fsrc = echo_ip(ECHO_FOREIGN) + cip, csrc = echo_ip(ECHO_CN) + tunneled_foreign = (fip == NODE_IP) + cn_tunneled = (cip == NODE_IP) + print(f"\n[路由判定]") + print(f" 国外 echo 出口: {fip} ({'=节点,走隧道✓' if tunneled_foreign else '≠节点'}) via {fsrc}") + print(f" 国内 echo 出口: {cip} ({'=节点 → 国内也被隧道!✗' if cn_tunneled else '≠节点 → 国内直连✓'}) via {csrc}") + if cn_tunneled: + print(" >>> 结论:国内流量被隧道了(分流失效/未开)。") + elif cip and fip and cip != fip: + print(" >>> 结论:国内直连(出口≠节点),分流生效。") + else: + print(" >>> 结论:数据不足(某个 echo 取不到),需重试。") + + # ── 五段拆解 ────────────────────────────────────────────────────── + def run(group, sites): + rows = [] + for name, host in sites: + r = five_phase(host) + r["name"] = name + ne = node_egress(host) if r.get("tls_ms") else None + r["node_tls_ms"] = ne["tls_ms"] if ne else None + r["node_dns_ms"] = ne["dns_ms"] if ne else None + # 接入段 ≈ 客户端整轮 − 节点出海段(隧道这一跳的耗时) + r["access_tls_ms"] = (round(r["tls_ms"] - r["node_tls_ms"], 1) + if (r.get("tls_ms") and r.get("node_tls_ms")) else None) + rows.append(r) + if r["err"]: + print(f" [{group}] {name:8s} {host:24s} FAIL {r['err'][:36]}") + else: + seg = f"DNS {r['dns_ms']} / TCP {r['tcp_ms']} / TLS {r['tls_ms']} / TTFB {r['ttfb_ms']} = {r['total_ms']}ms" + extra = f" [节点→目标 TLS {r['node_tls_ms']}ms]" if r["node_tls_ms"] else "" + print(f" [{group}] {name:8s} {host:24s} {seg}{extra}") + return rows + + print("\n[五段拆解] 国外:") + f_rows = run("国外", FOREIGN) + print("[五段拆解] 国内:") + d_rows = run("国内", DOMESTIC) + + # ── 输出 ────────────────────────────────────────────────────────── + host_id = socket.gethostname().split(".")[0] + run_id = datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + host_id + "-whitebox" + out = args.out or os.path.join(args.runs_dir, run_id + ".html") + os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) + + meta = { + "run_id": run_id, "started": started, "host": host_id, "kind": "whitebox", + "egress": fip, "tunneled": tunneled_foreign, "cn_egress": cip, "cn_tunneled": cn_tunneled, + "foreign_ok": sum(1 for r in f_rows if not r["err"]), "foreign_n": len(f_rows), + "domestic_ok": sum(1 for r in d_rows if not r["err"]), "domestic_n": len(d_rows), + "foreign_tls_median": _median([r["tls_ms"] for r in f_rows]), + "domestic_tls_median": _median([r["tls_ms"] for r in d_rows]), + "dl": {}, + } + with open(out, "w", encoding="utf-8") as fp: + fp.write(render_html(started, fip, fsrc, tunneled_foreign, cip, csrc, cn_tunneled, + f_rows, d_rows, meta)) + print(f"\n报告已生成: {os.path.abspath(out)}") + + if not args.no_index: + try: + # 复用 vpn_test.py 的索引重建(同目录扫描) + import importlib.util + spec = importlib.util.spec_from_file_location("vt", os.path.join(HERE, "vpn_test.py")) + vt = importlib.util.module_from_spec(spec); spec.loader.exec_module(vt) + n = vt.regenerate_index(args.runs_dir, args.index) + print(f"索引已更新: {os.path.abspath(args.index)} ({n} 条记录)") + except Exception as e: + print(f"索引重建失败(不影响报告): {e}") + + +def _median(vals): + import statistics + vals = [v for v in vals if v is not None] + return round(statistics.median(vals), 1) if vals else None + + +def render_html(started, fip, fsrc, tf, cip, csrc, cnt, f_rows, d_rows, meta): + def seg_row(r): + if r["err"]: + return (f"{html.escape(r['name'])}{html.escape(r['host'])}" + f"FAIL {html.escape(r['err'][:40])}") + ip = html.escape(str(r.get("ip") or "—")) + node = f"{r['node_tls_ms']} ms" if r.get("node_tls_ms") else "—" + # 接入段估算 = 客户端TLS - 节点出海TLS(都为 TLS 握手口径,粗略) + access = "—" + if r.get("node_tls_ms") and r.get("tls_ms"): + access = f"{round(r['tls_ms'] - r['node_tls_ms'], 1)} ms" + return (f"{html.escape(r['name'])}{html.escape(r['host'])}" + f"{ip}" + f"{r['dns_ms']}{r['tcp_ms']}" + f"{r['tls_ms']}{r['ttfb_ms']}" + f"{r['total_ms']}" + f"{node}{access}") + ftab = "\n".join(seg_row(r) for r in f_rows) + dtab = "\n".join(seg_row(r) for r in d_rows) + cn_verdict = ('国内也被隧道(分流失效/未开)' if cnt + else ('国内直连(出口≠节点),分流生效' if cip and cip != fip + else '数据不足,需重试')) + + # 阶段拆解:把整轮(客户端)拆成 出海段(节点侧实测)+ 接入段(差值)。用国外站(经隧道)。 + ok_f = [r for r in f_rows if not r["err"]] + fc_dns, fn_dns = _median([r.get("dns_ms") for r in ok_f]), _median([r.get("node_dns_ms") for r in ok_f]) + fc_tls, fn_tls = _median([r.get("tls_ms") for r in ok_f]), _median([r.get("node_tls_ms") for r in ok_f]) + sub = lambda a, b: round(a - b, 1) if (a is not None and b is not None) else None + cell = lambda v: f"{v} ms" if v is not None else "—" + acc_dns, acc_tls = sub(fc_dns, fn_dns), sub(fc_tls, fn_tls) + if fn_tls is not None: + pct = f"接入段占 TLS 握手约 {round(acc_tls / fc_tls * 100)}%" if (acc_tls and fc_tls) else "" + breakdown_html = f"""

② 阶段拆解:接入段 vs 出海段(国外站 · 经隧道)

+
把端到端整轮(客户端测)拆成 出海段(节点→目标,控制面 /v1/diag/egress 实测) + 与 接入段(客户端→节点的隧道这一跳,= 整轮 − 出海段)—— 对应时序图的橙(接入)/绿(出海)两段。
+ + + +
阶段(时序步骤)整轮 · 客户端出海段 · 节点→目标(绿)接入段 · 隧道(橙,差值)
DNS 解析 ①②③{cell(fc_dns)}{cell(fn_dns)}{cell(acc_dns)}
TLS 握手 ④⑤⑥⑦{cell(fc_tls)}{cell(fn_tls)}{cell(acc_tls)}
+
📌 {pct} —— 延迟几乎全在「中国 → {NODE_IP}(LA)」这一跳,节点出海很快。治本 = 换近节点。
""" + else: + breakdown_html = ("""

② 阶段拆解:接入段 vs 出海段

+
未取到节点侧出海段数据(控制面 /v1/diag/egress 不可达或未部署)。""" + """部署诊断端点 / 设 PANGOLIN_APINODE_SSH 后重跑即可拆出接入/出海。
""") + + metajson = json.dumps(meta, ensure_ascii=False) + return f""" + +Pangolin 白盒下钻 {started} + +
+

Pangolin VPN 白盒下钻

+

{started} · 期望节点 {NODE_IP} · ← 返回索引

+ +

① 路由判定:国内是不是也走了代理?

+
+ 国外 echo 出口:{html.escape(str(fip or '取不到'))} + {'(=节点,走隧道 ✓)' if tf else '(≠节点)'} via {html.escape(str(fsrc or ''))}
+ 国内 echo 出口:{html.escape(str(cip or '取不到'))} + via {html.escape(str(csrc or ''))}
+
结论:{cn_verdict}
+
+
原理:国外 echo 反映隧道出口(应=节点);国内 echo 域名是国内站,分流正常时这次请求**直连**, + 反映本机真实 ISP 出口。若国内 echo 也 = 节点 IP,说明国内流量被隧道了(split_cn 没开或 + geosite-cn 规则未命中)。
+ +{breakdown_html} + +

③ 逐站五段拆解:慢在链路哪一段?

+
各段=该阶段独立耗时(非累计)。TLS 段高 = 链路 RTT 高(走隧道/出海远); + DNS 高 = 域名解析走了远端(经隧道到 8.8.8.8);TTFB 高 = 目标后端/出海段慢。 + 末两列(设 NODE_SSH 才有):节点→目标 TLS = 出海段;接入段 ≈ 客户端TLS − 出海段。
+

国外(经隧道)

+ +{ftab}
站点域名解析IPDNSTCPTLS(延迟)TTFB节点→目标接入段≈
+

国内(开分流应直连)

+ +{dtab}
站点域名解析IPDNSTCPTLS(延迟)TTFB节点→目标接入段≈
+

单位 ms。延迟口径见 docs/vpn-test-plan.md。生成工具:scripts/vpn_whitebox.py

+
""" + + +if __name__ == "__main__": + main() diff --git a/tests/vpn/.gitignore b/tests/vpn/.gitignore new file mode 100644 index 0000000..4ad2437 --- /dev/null +++ b/tests/vpn/.gitignore @@ -0,0 +1,3 @@ +# 测试产物为本地工件,不入 git(每次跑由 scripts/vpn_test.py 重新生成) +runs/ +index.html diff --git a/tests/vpn/README.md b/tests/vpn/README.md new file mode 100644 index 0000000..0f51425 --- /dev/null +++ b/tests/vpn/README.md @@ -0,0 +1,31 @@ +# tests/vpn —— VPN 测试产物目录 + +每次跑 `scripts/vpn_test.py`(黑盒)会: + +1. 把本次报告写到 `runs/<时间>-<主机>.html`; +2. 扫描 `runs/` 内嵌的 meta、**重建 `index.html` 索引页**(列出历次跑的时间/主机/出口/可达/延迟中位数/下载速度,点行进报告)。 + +`runs/` 与生成的 `index.html` 是**本地工件,不入 git**(见 `.gitignore`)——换机器跑会从零累积。 + +## 怎么跑 + +```bash +# 在「已连 VPN 的机器」上(本机或 ssh 到 cara) +python3 scripts/vpn_test.py # 落 tests/vpn/runs/ 并刷新 index.html +python3 scripts/vpn_test.py --no-download # 跳过吞吐(快) +NODE_IP=103.119.13.48 python3 scripts/vpn_test.py + +# 远程在 cara 上跑、把整个 runs 目录拉回本地看索引: +scp scripts/vpn_test.py cara:/tmp/ +ssh cara 'python3 /tmp/vpn_test.py --runs-dir /tmp/pg-runs --index /tmp/pg-runs/index.html' +scp -r cara:/tmp/pg-runs/. tests/vpn/runs/ # 再本地重建索引: +python3 -c "import importlib.util as u;s=u.spec_from_file_location('v','scripts/vpn_test.py');m=u.module_from_spec(s);s.loader.exec_module(m);m.regenerate_index('tests/vpn/runs','tests/vpn/index.html')" + +open tests/vpn/index.html # 看索引(可 file:// 直开,数据内嵌无需 server) +``` + +## 口径 + +延迟 = **TLS 握手耗时**(非 IP TTL,非 ping/tcp_connect)。详见 `docs/vpn-test-plan.md`、`docs/vpn-testing-research.md`。 + +白盒(下钻链路/出站/DNS 走向)见 `scripts/vpn_whitebox.sh`(待补)。