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 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 . && 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 from datetime import datetime
NODE_IP = os.environ.get("NODE_IP", "103.119.13.48") NODE_IP = os.environ.get("NODE_IP", "103.119.13.48")
TIMEOUT = float(os.environ.get("TIMEOUT", "15")) 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)。国外应经隧道,国内开分流应直连。 # 站点矩阵(name, host)。国外应经隧道,国内开分流应直连。
FOREIGN = [ FOREIGN = [
("Google", "www.google.com"), ("YouTube", "www.youtube.com"), ("Google", "www.google.com"), ("YouTube", "www.youtube.com"),
@@ -162,7 +166,13 @@ def verdict_latency(tls_ms):
# ── 主流程 ─────────────────────────────────────────────────────────── # ── 主流程 ───────────────────────────────────────────────────────────
def main(): def main():
ap = argparse.ArgumentParser() 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="跳过吞吐测试") ap.add_argument("--no-download", action="store_true", help="跳过吞吐测试")
args = ap.parse_args() args = ap.parse_args()
@@ -203,17 +213,208 @@ def main():
dls.append((label, mbps, n, dt)) dls.append((label, mbps, n, dt))
print(f" {label}: {mbps if mbps is not None else 'FAIL'} Mbps ({n}B/{dt}s)") 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) # 输出路径:默认落 runs/<时间>-<主机>.html
with open(args.out, "w", encoding="utf-8") as fp: 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) 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"]) 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 '≠节点!'}") 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): # 延迟着色 def cls(v): # 延迟着色
if v is None: if v is None:
return "fail" 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> return f"""<!DOCTYPE html><html lang=zh-CN><head><meta charset=UTF-8>
<meta name=viewport content="width=device-width,initial-scale=1"> <meta name=viewport content="width=device-width,initial-scale=1">
<title>Pangolin VPN 测试报告 {started}</title> <title>Pangolin VPN 测试报告 {started}</title>
<script type="application/json" id="pangolin-run-meta">{json.dumps(meta or {}, ensure_ascii=False)}</script>
<style> <style>
body{{font-family:-apple-system,"PingFang SC",Arial,sans-serif;margin:0;background:#f6f7f9;color:#1c2330;line-height:1.6}} 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}} .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}} .big{{font-size:15px}}
</style></head><body><div class=wrap> </style></head><body><div class=wrap>
<h1>Pangolin VPN 测试报告</h1> <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> <div class=card big>
出口 IP:<b class="mono {egress_cls}">{html.escape(eip or '取不到')}</b> {html.escape(egeo)} 出口 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 var nodeStore nodes.NodeStore
if nodeSvc != nil { if nodeSvc != nil {
nodeStore = nodeSvc.Store() 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(自托管, // 国内分流(#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)。 // Public (no auth): 国内分流 rule-set 下载(sing-box 不带 token)。
v1.Get("/rules/{name}", rulesHandler.Serve) v1.Get("/rules/{name}", rulesHandler.Serve)
// Public (no auth): 诊断端点,量节点→目标出海段耗时(白名单限定,防 SSRF),
// 供白盒拆「接入段 vs 出海段」。只返回耗时数字,不传业务数据。
v1.Get("/diag/egress", httpapi.NewDiagHandler().EgressTiming)
// Public (no auth): auth endpoints. // Public (no auth): auth endpoints.
if authHandler != nil { if authHandler != nil {
v1.Post("/auth/code", authHandler.SendCode) 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 → 直连(不走隧道),省流量 + 国内快。 // 国内分流(#5):命中 geoip-cn / geosite-cn → 直连(不走隧道),省流量 + 国内快。
// rule_set 走控制面自托管(国内可达,客户端反正连控制面);download_detour:direct // rule_set 走控制面自托管(国内可达,客户端反正连控制面);download_detour:direct
// 让 sing-box 直连下载 .srs(不经隧道,启动期隧道还没起)。 // 让 sing-box 直连下载 .srs(不经隧道,启动期隧道还没起)。
if opts.SplitCN && opts.RulesBaseURL != "" { splitActive := opts.SplitCN && opts.RulesBaseURL != ""
if splitActive {
base := strings.TrimRight(opts.RulesBaseURL, "/") base := strings.TrimRight(opts.RulesBaseURL, "/")
routeRules = append(routeRules, map[string]any{ routeRules = append(routeRules, map[string]any{
"rule_set": []string{"geoip-cn", "geosite-cn"}, "rule_set": []string{"geoip-cn", "geosite-cn"},
@@ -175,6 +176,15 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
"final": "remote", "final": "remote",
"strategy": "ipv4_only", "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{ cfg := map[string]any{
// timestamp=false:客户端日志出口(logLine)已统一加时间戳, // 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)") 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{}) cfg2, _ := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{})
if _, ok := routeOf(t, cfg2)["rule_set"]; ok { if _, ok := routeOf(t, cfg2)["rule_set"]; ok {
t.Error("split off should have no rule_set") 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)。 // 开启但缺 base → 静默不分流(避免渲染出无效 rule_set URL)。
cfg3, _ := BuildClientConfig(testNode(), "uuid-1", "k", ClientConfigOpts{SplitCN: true}) 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 ( import (
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -186,6 +187,10 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
return 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.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(cfgJSON) _, _ = 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`(待补)。