Merge worktree-monitor-debounce: server #5 国内分流 DNS 面 + #12 链路诊断端点 + VPN 测试工具
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled

- clientconfig.go(#5):开分流时 geosite-cn 域名走 local(223.5.5.5)直连解析,
  避免国内 DNS 绕道出海多一个 RTT(splitActive 门控,不动既有 hijack-dns)
- diag.go(#12):新增公开诊断端点 /v1/diag/egress,白名单限定(anti-SSRF)、
  只返回节点→目标出海段 TCP-443 耗时,供白盒拆接入段/出海段
- main.go:PANGOLIN_PUBLIC_URL 未设时告警(否则 split_cn 静默失效)
- scripts/vpn_test.py + vpn_whitebox.py + tests/vpn:黑/白盒测试工具

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-23 10:55:47 +08:00
9 changed files with 730 additions and 10 deletions
Executable → Regular
+209 -7
View File
@@ -17,12 +17,16 @@ vpn_test.py —— Pangolin VPN 黑盒测试(纯标准库,零依赖)
远程: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, urllib.request
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"),
@@ -162,7 +166,13 @@ def verdict_latency(tls_ms):
# ── 主流程 ───────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser()
ap.add_argument("-o", "--out", default="vpn_report.html")
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()
@@ -203,17 +213,208 @@ def main():
dls.append((label, mbps, n, dt))
print(f" {label}: {mbps if mbps is not None else 'FAIL'} Mbps ({n}B/{dt}s)")
html_out = render_html(started, eip, egeo, tunneled, f_rows, d_rows, dls, conn)
with open(args.out, "w", encoding="utf-8") as fp:
# 输出路径:默认落 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(args.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 render_html(started, eip, egeo, tunneled, f_rows, d_rows, dls, conn=None):
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'<script type="application/json" id="pangolin-run-meta">(.*?)</script>',
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 '<td class=mono>—</td>'
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'<td class="mono {c}">{v}{unit}</td>'
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"<tr onclick=\"location.href='{html.escape(d['_file'])}'\" style=cursor:pointer>"
f"<td class=mono>{html.escape(d.get('started',''))}</td>"
f"<td class='{kind_c}'>{kind}</td>"
f"<td>{html.escape(d.get('host',''))}</td>"
f"<td class='mono {eg_c}'>{eg}</td>"
f"<td class=mono>{d.get('foreign_ok','?')}/{d.get('foreign_n','?')}</td>"
f"<td class=mono>{d.get('domestic_ok','?')}/{d.get('domestic_n','?')}</td>"
+ 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"<td><a href='{html.escape(d['_file'])}'>查看 →</a></td></tr>")
body = "\n".join(rows) or "<tr><td colspan=11>暂无测试记录,跑一次 vpn_test.py 即可。</td></tr>"
# 架构图:三节点(cara / LA / Google)+ 编号箭头标流向 + 下方步骤表。内联 SVG,离线可看。
flow_svg = """
<h2 style="font-size:18px;margin:34px 0 10px;border-bottom:2px solid #e5e7eb;padding-bottom:6px">时序图:cara 访问 Google 的流程(时间从上往下)</h2>
<div style="background:#fff;border-radius:10px;padding:18px 16px;box-shadow:0 1px 3px rgba(0,0,0,.06);overflow-x:auto">
<svg viewBox="0 0 1040 400" width="100%" style="min-width:760px;font-family:-apple-system,'PingFang SC',sans-serif">
<defs>
<marker id="mo" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#c2700c"/></marker>
<marker id="mg" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#16a34a"/></marker>
<marker id="mr" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#dc2626"/></marker>
</defs>
<!-- 生命线(虚线) -->
<line x1="160" y1="54" x2="160" y2="384" stroke="#cbd5e1" stroke-width="1.5" stroke-dasharray="4,4"/>
<line x1="540" y1="54" x2="540" y2="384" stroke="#cbd5e1" stroke-width="1.5" stroke-dasharray="4,4"/>
<line x1="900" y1="54" x2="900" y2="384" stroke="#cbd5e1" stroke-width="1.5" stroke-dasharray="4,4"/>
<!-- 三个角色表头 -->
<rect x="85" y="8" width="150" height="46" rx="10" fill="#eef4ff" stroke="#c7d8f5"/>
<text x="160" y="32" text-anchor="middle" font-size="15" font-weight="700" fill="#1c2330">cara</text>
<text x="160" y="47" text-anchor="middle" font-size="11" fill="#6b7280">中国 · 浏览器</text>
<rect x="448" y="8" width="184" height="46" rx="10" fill="#fff1e6" stroke="#eab676"/>
<text x="540" y="30" text-anchor="middle" font-size="15" font-weight="700" fill="#1c2330">LA 节点</text>
<text x="540" y="46" text-anchor="middle" font-size="10.5" fill="#6b7280">103.119.13.48 · sing-box</text>
<rect x="826" y="8" width="148" height="46" rx="10" fill="#fff" stroke="#d1d5db"/>
<text x="900" y="32" text-anchor="middle" font-size="15" font-weight="700" fill="#1c2330">Google</text>
<text x="900" y="47" text-anchor="middle" font-size="11" fill="#6b7280">8.8.8.8 / 142.251.x</text>
<!-- 1 cara→LA(隧道) -->
<text x="350" y="68" text-anchor="middle" font-size="12" fill="#b45309">DNS 查询(经隧道)</text>
<line x1="160" y1="84" x2="534" y2="84" stroke="#c2700c" stroke-width="2" marker-end="url(#mo)"/>
<circle cx="350" cy="84" r="12" fill="#c2700c"/><text x="350" y="88.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#fff">1</text>
<!-- 2 LA→google(出海) -->
<text x="720" y="112" text-anchor="middle" font-size="12" fill="#15803d">向 8.8.8.8 解析域名</text>
<line x1="540" y1="128" x2="894" y2="128" stroke="#16a34a" stroke-width="2" marker-end="url(#mg)"/>
<circle cx="720" cy="128" r="12" fill="#16a34a"/><text x="720" y="132.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#fff">2</text>
<!-- 3 LA→cara(隧道) -->
<text x="350" y="156" text-anchor="middle" font-size="12" fill="#b45309">返回 google IP(经隧道)</text>
<line x1="540" y1="172" x2="166" y2="172" stroke="#c2700c" stroke-width="2" marker-end="url(#mo)"/>
<circle cx="350" cy="172" r="12" fill="#c2700c"/><text x="350" y="176.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#fff">3</text>
<!-- 4 cara→LA(隧道·瓶颈) -->
<text x="350" y="200" text-anchor="middle" font-size="12" font-weight="700" fill="#dc2626">HTTPS 数据 · 接入段 · 瓶颈 ⚠️</text>
<line x1="160" y1="216" x2="534" y2="216" stroke="#dc2626" stroke-width="2.5" marker-end="url(#mr)"/>
<circle cx="350" cy="216" r="12" fill="#dc2626"/><text x="350" y="220.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#fff">4</text>
<!-- 5 LA→google(出海) -->
<text x="720" y="244" text-anchor="middle" font-size="12" fill="#15803d">节点出海连 google(~43ms)</text>
<line x1="540" y1="260" x2="894" y2="260" stroke="#16a34a" stroke-width="2" marker-end="url(#mg)"/>
<circle cx="720" cy="260" r="12" fill="#16a34a"/><text x="720" y="264.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#fff">5</text>
<!-- 6 google→LA(出海) -->
<text x="720" y="288" text-anchor="middle" font-size="12" fill="#15803d">google 响应</text>
<line x1="900" y1="304" x2="546" y2="304" stroke="#16a34a" stroke-width="2" marker-end="url(#mg)"/>
<circle cx="720" cy="304" r="12" fill="#16a34a"/><text x="720" y="308.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#fff">6</text>
<!-- 7 LA→cara(隧道) -->
<text x="350" y="332" text-anchor="middle" font-size="12" fill="#b45309">响应回 cara(经隧道),网页打开</text>
<line x1="540" y1="348" x2="166" y2="348" stroke="#c2700c" stroke-width="2" marker-end="url(#mo)"/>
<circle cx="350" cy="348" r="12" fill="#c2700c"/><text x="350" y="352.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#fff">7</text>
</svg>
<div style="margin-top:6px;font-size:12px;color:#6b7280">图例:时间从上往下;<b style="color:#c2700c">橙=经 REALITY 隧道(接入段,慢)</b> · <b style="color:#16a34a">绿=节点出海(快)</b> · <b style="color:#dc2626">④红=延迟瓶颈</b></div>
<table style="margin-top:16px">
<tr><th style="width:54px">步骤</th><th style="width:150px">路段</th><th>做什么</th></tr>
<tr><td class=mono>① 1</td><td class=mono>cara → LA</td><td><b>DNS 查询走隧道</b>:google 是国外域名(不命中 geosite-cn),DNS 请求经 REALITY 隧道发往 LA 节点。</td></tr>
<tr><td class=mono>② 2</td><td class=mono>LA → Google</td><td>节点把 DNS 查询转给 <code>8.8.8.8</code> 解析,得到 google 的 IP。</td></tr>
<tr><td class=mono>③ 3</td><td class=mono>LA → cara</td><td>解析出的 google IP 经隧道回传给 cara。</td></tr>
<tr><td class=mono>④ 4</td><td class=mono>cara → LA</td><td>cara 用该 IP 发起 HTTPS,数据经 REALITY 隧道到节点 —— <b class=warn>接入段,~10002700ms,延迟瓶颈</b>(IP 也不命中 geoip-cn,故走隧道)。</td></tr>
<tr><td class=mono>⑤ 5</td><td class=mono>LA → Google</td><td>节点代 cara 连接 google —— <b class=pass>出海段,~43ms</b>(LA 离 Google 很近)。</td></tr>
<tr><td class=mono>⑥ 6</td><td class=mono>Google → LA</td><td>google 把响应数据返回给节点。</td></tr>
<tr><td class=mono>⑦ 7</td><td class=mono>LA → cara</td><td>响应经隧道回到 cara,网页打开。</td></tr>
</table>
<div style="margin-top:12px;background:#e9f9ef;border:1px solid #bce8cd;border-radius:10px;padding:12px 16px;font-size:13px;color:#15603a;line-height:1.8">
📌 <b>对比 · 国内站(如 baidu)不走这条路</b>:域名命中 geosite-cn → 用 <b>local(223.5.5.5)国内 DNS 直连</b>解析 → 拿到真 CN IP → 命中 geoip-cn → 走 <b>direct 直连</b>,<b>全程不经 LA 节点 / 不进隧道</b> → TLS ~50ms。本次修复正是补上了 DNS 面的分流。
</div>
</div>
"""
page = f"""<!DOCTYPE html><html lang=zh-CN><head><meta charset=UTF-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Pangolin VPN 测试索引</title>
<style>
body{{font-family:-apple-system,"PingFang SC",Arial,sans-serif;margin:0;background:#f6f7f9;color:#1c2330;line-height:1.6}}
.wrap{{max-width:1100px;margin:0 auto;padding:32px 20px 80px}}
h1{{font-size:24px;margin:0 0 4px}} .sub{{color:#6b7280;margin:0 0 20px}}
table{{width:100%;border-collapse:collapse;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.06);font-size:14px}}
th,td{{text-align:left;padding:9px 12px;border-bottom:1px solid #eef0f3}}
th{{background:#fafbfc;color:#6b7280;font-weight:600;font-size:13px}}
tr:hover td{{background:#fafcff}}
.mono{{font-family:"SF Mono",Menlo,monospace;font-size:13px}}
.pass{{color:#16a34a;font-weight:600}} .warn{{color:#d97706;font-weight:600}} .fail{{color:#dc2626;font-weight:600}}
a{{color:#2563eb;text-decoration:none}} a:hover{{text-decoration:underline}}
.note{{background:#fff8ee;border:1px solid #f5e3c4;border-radius:10px;padding:12px 16px;margin:16px 0;font-size:13px;color:#7a5b25}}
</style></head><body><div class=wrap>
<h1>Pangolin VPN 测试索引</h1>
<p class=sub>共 {len(metas)} 次记录 · 最近更新 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} · 点行进报告</p>
<div class=note>延迟列 = <b>TLS 握手中位数</b>(&lt;{LAT_GOOD}ms 绿 / &lt;{LAT_WARN}ms 橙 / 更高红)。
下载列 = Mbps(&gt;20 绿 / &gt;5 橙)。出口绿=走隧道(=节点),橙=非节点。口径见 docs/vpn-test-plan.md。</div>
{flow_svg}
<h2 style="font-size:18px;margin:34px 0 10px;border-bottom:2px solid #e5e7eb;padding-bottom:6px">历次测试记录</h2>
<table>
<tr><th>时间</th><th>类型</th><th>主机</th><th>出口IP</th><th>国外可达</th><th>国内可达</th>
<th>国外延迟</th><th>国内延迟</th><th>国外下载</th><th>国内下载</th><th></th></tr>
{body}
</table>
</div></body></html>"""
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"
@@ -237,6 +438,7 @@ def render_html(started, eip, egeo, tunneled, f_rows, d_rows, dls, conn=None):
return f"""<!DOCTYPE html><html lang=zh-CN><head><meta charset=UTF-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Pangolin VPN 测试报告 {started}</title>
<script type="application/json" id="pangolin-run-meta">{json.dumps(meta or {}, ensure_ascii=False)}</script>
<style>
body{{font-family:-apple-system,"PingFang SC",Arial,sans-serif;margin:0;background:#f6f7f9;color:#1c2330;line-height:1.6}}
.wrap{{max-width:960px;margin:0 auto;padding:32px 20px 80px}}
@@ -253,7 +455,7 @@ def render_html(started, eip, egeo, tunneled, f_rows, d_rows, dls, conn=None):
.big{{font-size:15px}}
</style></head><body><div class=wrap>
<h1>Pangolin VPN 测试报告</h1>
<p class=sub>{started} · 期望节点出口 {NODE_IP}</p>
<p class=sub>{started} · 期望节点出口 {NODE_IP} · <a href="../index.html" style="color:#2563eb">← 返回索引</a></p>
<div class=card big>
出口 IP:<b class="mono {egress_cls}">{html.escape(eip or '取不到')}</b> {html.escape(egeo)}
+331
View File
@@ -0,0 +1,331 @@
#!/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(节点本机量出海段)。
# host 用 base64url 传(host_b64):控制面是国外 IP 上的明文 HTTP,URL 里出现
# google/youtube 等敏感词会被 GFW 重置;base64 后明文不含敏感词,避免诊断被拦。
import base64
hb = base64.urlsafe_b64encode(host.encode()).rstrip(b"=").decode()
try:
u = CONTROL_PLANE.rstrip("/") + "/v1/diag/egress?host_b64=" + hb
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"<tr><td>{html.escape(r['name'])}</td><td class=mono>{html.escape(r['host'])}</td>"
f"<td class=fail colspan=7>FAIL {html.escape(r['err'][:40])}</td></tr>")
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"<tr><td>{html.escape(r['name'])}</td><td class=mono>{html.escape(r['host'])}</td>"
f"<td class=mono>{ip}</td>"
f"<td class=mono>{r['dns_ms']}</td><td class=mono>{r['tcp_ms']}</td>"
f"<td class='mono'><b>{r['tls_ms']}</b></td><td class=mono>{r['ttfb_ms']}</td>"
f"<td class=mono>{r['total_ms']}</td>"
f"<td class=mono>{node}</td><td class=mono>{access}</td></tr>")
ftab = "\n".join(seg_row(r) for r in f_rows)
dtab = "\n".join(seg_row(r) for r in d_rows)
cn_verdict = ('<b class=fail>国内也被隧道(分流失效/未开)</b>' if cnt
else ('<b class=pass>国内直连(出口≠节点),分流生效</b>' if cip and cip != fip
else '<b class=warn>数据不足,需重试</b>'))
# 阶段拆解:把整轮(客户端)拆成 出海段(节点侧实测)+ 接入段(差值)。用国外站(经隧道)。
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"""<h2>② 阶段拆解:接入段 vs 出海段(国外站 · 经隧道)</h2>
<div class=note>把端到端整轮(客户端测)拆成 <b>出海段</b>(节点→目标,控制面 <code>/v1/diag/egress</code> 实测)
与 <b>接入段</b>(客户端→节点的隧道这一跳,= 整轮 − 出海段)—— 对应时序图的橙(接入)/绿(出海)两段。</div>
<table><tr><th>阶段(时序步骤)</th><th>整轮 · 客户端</th><th>出海段 · 节点→目标(绿)</th><th>接入段 · 隧道(橙,差值)</th></tr>
<tr><td>DNS 解析 ①②③</td><td class=mono>{cell(fc_dns)}</td><td class=mono>{cell(fn_dns)}</td><td class='mono warn'>{cell(acc_dns)}</td></tr>
<tr><td>TLS 握手 ④⑤⑥⑦</td><td class=mono>{cell(fc_tls)}</td><td class=mono>{cell(fn_tls)}</td><td class='mono fail'>{cell(acc_tls)}</td></tr>
</table>
<div class=note style="background:#fff1f1;border-color:#f3c7c7;color:#9b2c2c">📌 <b>{pct}</b> —— 延迟几乎全在「中国 → {NODE_IP}(LA)」这一跳,节点出海很快。治本 = 换近节点。</div>"""
else:
breakdown_html = ("""<h2>② 阶段拆解:接入段 vs 出海段</h2>
<div class=note>未取到节点侧出海段数据(控制面 <code>/v1/diag/egress</code> 不可达或未部署)。"""
"""部署诊断端点 / 设 <code>PANGOLIN_API</code> 或 <code>NODE_SSH</code> 后重跑即可拆出接入/出海。</div>""")
metajson = json.dumps(meta, ensure_ascii=False)
return f"""<!DOCTYPE html><html lang=zh-CN><head><meta charset=UTF-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Pangolin 白盒下钻 {started}</title>
<script type="application/json" id="pangolin-run-meta">{metajson}</script>
<style>
body{{font-family:-apple-system,"PingFang SC",Arial,sans-serif;margin:0;background:#f6f7f9;color:#1c2330;line-height:1.6}}
.wrap{{max-width:1100px;margin:0 auto;padding:32px 20px 80px}}
h1{{font-size:24px;margin:0 0 4px}} .sub{{color:#6b7280;margin:0 0 20px}}
h2{{font-size:18px;margin:30px 0 10px;border-bottom:2px solid #e5e7eb;padding-bottom:6px}}
table{{width:100%;border-collapse:collapse;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.06);font-size:13px}}
th,td{{text-align:left;padding:8px 10px;border-bottom:1px solid #eef0f3}}
th{{background:#fafbfc;color:#6b7280;font-weight:600;font-size:12px}}
.mono{{font-family:"SF Mono",Menlo,monospace;font-size:12px}}
.pass{{color:#16a34a;font-weight:600}} .warn{{color:#d97706;font-weight:600}} .fail{{color:#dc2626;font-weight:600}}
.card{{background:#fff;border-radius:10px;padding:14px 18px;margin:12px 0;box-shadow:0 1px 3px rgba(0,0,0,.06)}}
.note{{background:#fff8ee;border:1px solid #f5e3c4;border-radius:10px;padding:12px 16px;margin:16px 0;font-size:13px;color:#7a5b25}}
code{{background:#eef0f3;padding:1px 5px;border-radius:4px;font-family:"SF Mono",monospace}}
a{{color:#2563eb;text-decoration:none}}
</style></head><body><div class=wrap>
<h1>Pangolin VPN 白盒下钻</h1>
<p class=sub>{started} · 期望节点 {NODE_IP} · <a href="../index.html">← 返回索引</a></p>
<h2>① 路由判定:国内是不是也走了代理?</h2>
<div class=card>
国外 echo 出口:<b class="mono {'pass' if tf else 'warn'}">{html.escape(str(fip or '取不到'))}</b>
{'(=节点,走隧道 ✓)' if tf else '(≠节点)'} <span class=sub>via {html.escape(str(fsrc or ''))}</span><br>
国内 echo 出口:<b class="mono {'fail' if cnt else 'pass'}">{html.escape(str(cip or '取不到'))}</b>
<span class=sub>via {html.escape(str(csrc or ''))}</span><br>
<div style=margin-top:8px>结论:{cn_verdict}</div>
</div>
<div class=note>原理:国外 echo 反映隧道出口(应=节点);国内 echo 域名是国内站,分流正常时这次请求**直连**,
反映本机真实 ISP 出口。若国内 echo 也 = 节点 IP,说明国内流量被隧道了(<code>split_cn</code> 没开或
<code>geosite-cn</code> 规则未命中)。</div>
{breakdown_html}
<h2>③ 逐站五段拆解:慢在链路哪一段?</h2>
<div class=note>各段=该阶段独立耗时(非累计)。<b>TLS</b> 段高 = 链路 RTT 高(走隧道/出海远);
<b>DNS</b> 高 = 域名解析走了远端(经隧道到 8.8.8.8);<b>TTFB</b> 高 = 目标后端/出海段慢。
末两列(设 <code>NODE_SSH</code> 才有):节点→目标 TLS = <b>出海段</b>;接入段 ≈ 客户端TLS 出海段。</div>
<h3 style=font-size:15px>国外(经隧道)</h3>
<table><tr><th>站点</th><th>域名</th><th>解析IP</th><th>DNS</th><th>TCP</th><th>TLS(延迟)</th><th>TTFB</th><th>总</th><th>节点→目标</th><th>接入段≈</th></tr>
{ftab}</table>
<h3 style=font-size:15px>国内(开分流应直连)</h3>
<table><tr><th>站点</th><th>域名</th><th>解析IP</th><th>DNS</th><th>TCP</th><th>TLS(延迟)</th><th>TTFB</th><th>总</th><th>节点→目标</th><th>接入段≈</th></tr>
{dtab}</table>
<p class=sub style=margin-top:24px>单位 ms。延迟口径见 docs/vpn-test-plan.md。生成工具:scripts/vpn_whitebox.py</p>
</div></body></html>"""
if __name__ == "__main__":
main()
+13 -1
View File
@@ -296,7 +296,15 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
var nodeStore nodes.NodeStore
if nodeSvc != nil {
nodeStore = nodeSvc.Store()
nodeAPI = httpapi.NewNodeAPI(nodeStore, nodeSvc.Hub(), os.Getenv("NODE_DERIVE_KEY"), os.Getenv("PANGOLIN_PUBLIC_URL"))
publicURL := os.Getenv("PANGOLIN_PUBLIC_URL")
if publicURL == "" {
// 没设 PANGOLIN_PUBLIC_URL → BuildClientConfig 会静默跳过国内分流
// (rule_set 没有公网基址可下载),客户端 split_cn=1 也无效、全量走隧道。
// 这是个隐蔽坑(国内流量绕道出海、变慢),启动期显式告警。
slog.Warn("PANGOLIN_PUBLIC_URL 未设置:国内分流(split_cn)将被静默跳过,客户端全量走隧道。" +
"如需国内直连,设为控制面对外公网基址(如 http://<公网IP>:8080)")
}
nodeAPI = httpapi.NewNodeAPI(nodeStore, nodeSvc.Hub(), os.Getenv("NODE_DERIVE_KEY"), publicURL)
}
// 国内分流(#5)的 rule-set 静态服务:GET /v1/rules/{name}.srs(自托管,
@@ -317,6 +325,10 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
// Public (no auth): 国内分流 rule-set 下载(sing-box 不带 token)。
v1.Get("/rules/{name}", rulesHandler.Serve)
// Public (no auth): 诊断端点,量节点→目标出海段耗时(白名单限定,防 SSRF),
// 供白盒拆「接入段 vs 出海段」。只返回耗时数字,不传业务数据。
v1.Get("/diag/egress", httpapi.NewDiagHandler().EgressTiming)
// Public (no auth): auth endpoints.
if authHandler != nil {
v1.Post("/auth/code", authHandler.SendCode)
+11 -1
View File
@@ -147,7 +147,8 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
// 国内分流(#5):命中 geoip-cn / geosite-cn → 直连(不走隧道),省流量 + 国内快。
// rule_set 走控制面自托管(国内可达,客户端反正连控制面);download_detour:direct
// 让 sing-box 直连下载 .srs(不经隧道,启动期隧道还没起)。
if opts.SplitCN && opts.RulesBaseURL != "" {
splitActive := opts.SplitCN && opts.RulesBaseURL != ""
if splitActive {
base := strings.TrimRight(opts.RulesBaseURL, "/")
routeRules = append(routeRules, map[string]any{
"rule_set": []string{"geoip-cn", "geosite-cn"},
@@ -175,6 +176,15 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
"final": "remote",
"strategy": "ipv4_only",
}
// 国内分流的 DNS 面(补 #5 数据面之外的 DNS 面):开分流时,命中 geosite-cn 的
// 国内域名用 local(223.5.5.5)直连解析,不走 remote(8.8.8.8 经隧道)。否则即便数据
// 直连,域名解析仍绕道出海(实测国内 DNS 段 200-600ms),首连凭空多一个出海 RTT。
// 复用 route.rule_set 里已定义的 geosite-cn 标签。
if splitActive {
dns["rules"] = []any{
map[string]any{"rule_set": []string{"geosite-cn"}, "server": "local"},
}
}
cfg := map[string]any{
// timestamp=false:客户端日志出口(logLine)已统一加时间戳,
+18 -1
View File
@@ -61,11 +61,28 @@ func TestBuildClientConfigSplitCN(t *testing.T) {
t.Error("missing cn-direct route rule (rule_set→direct)")
}
// 关闭分流 → 无 rule_set
// DNS 面分流:开分流时国内域名(geosite-cn)用 local 解析,不走 remote(隧道)
var m map[string]any
_ = json.Unmarshal(cfg, &m)
dnsRules, ok := m["dns"].(map[string]any)["rules"].([]any)
if !ok || len(dnsRules) == 0 {
t.Fatalf("split on should have dns.rules (geosite-cn→local), got %v", m["dns"])
}
dr := dnsRules[0].(map[string]any)
if dr["server"] != "local" || dr["rule_set"] == nil {
t.Errorf("dns rule should route geosite-cn → local, got %v", dr)
}
// 关闭分流 → 无 rule_set,且 DNS 无分流规则(全量 remote)。
cfg2, _ := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{})
if _, ok := routeOf(t, cfg2)["rule_set"]; ok {
t.Error("split off should have no rule_set")
}
var m2 map[string]any
_ = json.Unmarshal(cfg2, &m2)
if _, ok := m2["dns"].(map[string]any)["rules"]; ok {
t.Error("split off should have no dns.rules")
}
// 开启但缺 base → 静默不分流(避免渲染出无效 rule_set URL)。
cfg3, _ := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{SplitCN: true})
+109
View File
@@ -0,0 +1,109 @@
package httpapi
import (
"crypto/tls"
"encoding/base64"
"encoding/json"
"net"
"net/http"
"time"
)
// diagAllowedHosts 限定可测目标(防 SSRF):仅白盒测试用的公共站点。
// 端点只返回耗时数字、不返回响应体,且目标受限,信息泄露面极小。
var diagAllowedHosts = map[string]bool{
"www.google.com": true, "github.com": true, "www.cloudflare.com": true,
"www.youtube.com": true, "x.com": true, "www.facebook.com": true,
"en.wikipedia.org": true, "api.openai.com": true, "www.reddit.com": true,
"www.instagram.com": true,
"www.baidu.com": true, "www.qq.com": true, "www.taobao.com": true,
"www.jd.com": true, "www.bilibili.com": true, "weibo.com": true,
"www.163.com": true, "www.zhihu.com": true, "www.aliyun.com": true,
}
// DiagHandler 暴露只读诊断端点:在节点上量「节点→目标」的出海段耗时,
// 供白盒把端到端延迟拆成「接入段(客户端→节点) vs 出海段(节点→目标)」。
type DiagHandler struct{}
// NewDiagHandler 构造 DiagHandler。
func NewDiagHandler() *DiagHandler { return &DiagHandler{} }
type egressTiming struct {
Host string `json:"host"`
DNSMs *float64 `json:"dns_ms"`
TCPMs *float64 `json:"tcp_ms"`
TLSMs *float64 `json:"tls_ms"`
TTFBMs *float64 `json:"ttfb_ms"`
Err string `json:"err,omitempty"`
}
// EgressTiming 处理 GET /v1/diag/egress?host=X 或 ?host_b64=<base64url(host)>:
// 量 节点→host 的 DNS/TCP/TLS/TTFB 各段独立耗时(出海段),返回 JSON。
// host_b64 用途:控制面跑明文 HTTP 在国外 IP 上,URL/响应里若出现 google/youtube
// 等 GFW 敏感词会被墙重置;客户端改传 base64、服务端解码并在响应里回 base64(echo),
// 明文里不出现敏感词,避免诊断请求被 GFW 拦(不影响真实加密隧道流量)。
func (h *DiagHandler) EgressTiming(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
host := r.URL.Query().Get("host")
echo := host // 响应里回显的标识;用 b64 时回 b64(不回明文敏感词)
if b := r.URL.Query().Get("host_b64"); b != "" {
dec, err := base64.RawURLEncoding.DecodeString(b)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(egressTiming{Err: "bad host_b64"})
return
}
host, echo = string(dec), b
}
if !diagAllowedHosts[host] {
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(egressTiming{Host: echo, Err: "host not allowed"})
return
}
res := measureEgress(host)
res.Host = echo // 不在响应里回明文敏感词
_ = json.NewEncoder(w).Encode(res)
}
// measureEgress 在本机(节点)上测到 host:443 的分段耗时。各段为该阶段独立耗时。
func measureEgress(host string) egressTiming {
res := egressTiming{Host: host}
ms := func(d time.Duration) *float64 { v := float64(d.Microseconds()) / 1000.0; return &v }
t0 := time.Now()
ips, err := net.LookupHost(host)
if err != nil || len(ips) == 0 {
res.Err = "dns"
return res
}
tDNS := time.Now()
res.DNSMs = ms(tDNS.Sub(t0))
conn, err := (&net.Dialer{Timeout: 12 * time.Second}).Dial("tcp", net.JoinHostPort(ips[0], "443"))
if err != nil {
res.Err = "tcp"
return res
}
defer func() { _ = conn.Close() }()
tTCP := time.Now()
res.TCPMs = ms(tTCP.Sub(tDNS))
// 只测出海段链路握手耗时,不校验证书(InsecureSkipVerify:诊断用途,不传数据)。
tlsConn := tls.Client(conn, &tls.Config{ServerName: host, InsecureSkipVerify: true}) //nolint:gosec
_ = tlsConn.SetDeadline(time.Now().Add(12 * time.Second))
if err := tlsConn.Handshake(); err != nil {
res.Err = "tls"
return res
}
tTLS := time.Now()
res.TLSMs = ms(tTLS.Sub(tTCP))
if _, err := tlsConn.Write([]byte("GET / HTTP/1.1\r\nHost: " + host +
"\r\nUser-Agent: pangolin-diag\r\nConnection: close\r\n\r\n")); err == nil {
buf := make([]byte, 64)
if _, err := tlsConn.Read(buf); err == nil {
res.TTFBMs = ms(time.Since(tTLS))
}
}
return res
}
+5
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"strings"
@@ -186,6 +187,10 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return
}
// 可观测:国内分流是否真正生效(split_active=两个条件都满足才渲染 rule_set)。
slog.Info("client config rendered", "node", nodeUUID, "split_cn", splitCN,
"rules_base_set", a.rulesBaseURL != "",
"split_active", splitCN && a.rulesBaseURL != "", "bytes", len(cfgJSON))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(cfgJSON)
+3
View File
@@ -0,0 +1,3 @@
# 测试产物为本地工件,不入 git(每次跑由 scripts/vpn_test.py 重新生成)
runs/
index.html
+31
View File
@@ -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`(待补)。