出口 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_API 或 NODE_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 − 出海段。
+
国外(经隧道)
+
| 站点 | 域名 | 解析IP | DNS | TCP | TLS(延迟) | TTFB | 总 | 节点→目标 | 接入段≈ |
+{ftab}
+
国内(开分流应直连)
+
| 站点 | 域名 | 解析IP | DNS | TCP | TLS(延迟) | TTFB | 总 | 节点→目标 | 接入段≈ |
+{dtab}
+
单位 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`(待补)。