diff --git a/scripts/vpn_test.py b/scripts/vpn_test.py old mode 100755 new mode 100644 index 2159cd0..a239180 --- a/scripts/vpn_test.py +++ b/scripts/vpn_test.py @@ -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'', + 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" @@ -237,6 +438,7 @@ def render_html(started, eip, egeo, tunneled, f_rows, d_rows, dls, conn=None): return f""" Pangolin VPN 测试报告 {started} +

Pangolin VPN 测试报告

-

{started} · 期望节点出口 {NODE_IP}

+

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

出口 IP:{html.escape(eip or '取不到')} {html.escape(egeo)} diff --git a/scripts/vpn_whitebox.py b/scripts/vpn_whitebox.py new file mode 100644 index 0000000..0f3d0b2 --- /dev/null +++ b/scripts/vpn_whitebox.py @@ -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"{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/server/cmd/server/main.go b/server/cmd/server/main.go index 3ecf589..536221e 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -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) diff --git a/server/internal/httpapi/clientconfig.go b/server/internal/httpapi/clientconfig.go index 246ce13..a282aa2 100644 --- a/server/internal/httpapi/clientconfig.go +++ b/server/internal/httpapi/clientconfig.go @@ -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)已统一加时间戳, diff --git a/server/internal/httpapi/clientconfig_test.go b/server/internal/httpapi/clientconfig_test.go index 6f862a4..df0985c 100644 --- a/server/internal/httpapi/clientconfig_test.go +++ b/server/internal/httpapi/clientconfig_test.go @@ -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}) diff --git a/server/internal/httpapi/diag.go b/server/internal/httpapi/diag.go new file mode 100644 index 0000000..223732b --- /dev/null +++ b/server/internal/httpapi/diag.go @@ -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=: +// 量 节点→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 +} diff --git a/server/internal/httpapi/nodes.go b/server/internal/httpapi/nodes.go index 17a739d..4e1c942 100644 --- a/server/internal/httpapi/nodes.go +++ b/server/internal/httpapi/nodes.go @@ -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) 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`(待补)。