From bc890974c0ba5ab69084710a35c20b9608940df3 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 14:12:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20=E8=A1=A5=20#5=20=E5=9B=BD?= =?UTF-8?q?=E5=86=85=E5=88=86=E6=B5=81=E7=9A=84=20DNS=20=E9=9D=A2=20+=20?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E8=AF=8A=E6=96=AD=E7=AB=AF=E7=82=B9(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5 只分了数据面(geoip/geosite-cn 路由 direct),DNS 仍 final:remote 全量经隧道 解析 → 国内域名解析到非 CN IP、漏过 geoip-cn 又走隧道(白盒实测国内 TLS 1000-1660ms;修后 ~50ms)。 - clientconfig.go: 开分流时 dns.rules 加 {rule_set:[geosite-cn]→local},国内域名 用 local(223.5.5.5)直连解析 → 拿到真 CN IP → geoip-cn 命中直连 - main.go: PANGOLIN_PUBLIC_URL 缺失时启动告警(空则分流静默跳过,是隐蔽坑) - nodes.go: connect 渲染加可观测日志(split_cn/rules_base/split_active/bytes) - diag.go: 新增只读端点 GET /v1/diag/egress?host=X,节点侧量出海段耗时(白名单 防 SSRF、只回耗时数字),供白盒拆「接入段 vs 出海段」 验证:go test(splitCN 开渲染 dns.rules→local、关无 dns.rules)+ go vet;cara 实测国内 TLS 1000ms+→~50ms、接入段占 TLS 握手 ~98%。 Co-Authored-By: Claude Opus 4.8 --- server/cmd/server/main.go | 14 ++- server/internal/httpapi/clientconfig.go | 19 +++- server/internal/httpapi/clientconfig_test.go | 19 +++- server/internal/httpapi/diag.go | 93 ++++++++++++++++++++ server/internal/httpapi/nodes.go | 5 ++ 5 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 server/internal/httpapi/diag.go 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 0882398..a282aa2 100644 --- a/server/internal/httpapi/clientconfig.go +++ b/server/internal/httpapi/clientconfig.go @@ -126,8 +126,13 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien "tolerance": 50, } - // Route: LAN direct;(可选)国内 IP/域名直连;其余 via auto。 + // Route: DNS 劫持 → LAN direct →(可选)国内直连 → 其余 via auto。 routeRules := []any{ + // DNS 劫持(sing-box 1.13 action=hijack-dns,按目的端口 53 匹配,不依赖 sniff): + // 把发往隧道 DNS(172.19.0.2:53)的查询交给 sing-box DNS 模块解析。必须排在 LAN + // 直连规则之前——否则 172.19.x 落在下面的 172.16.0.0/12 里,DNS 会被吞去直连、解析 + // 失败导致打不开网站(TUN 模式 DNS 劫持是必需项,缺失则隧道连上也无法上网)。 + map[string]any{"action": "hijack-dns", "port": []int{53}}, map[string]any{ "ip_cidr": []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8"}, "outbound": "direct", @@ -142,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"}, @@ -170,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..f15f05a --- /dev/null +++ b/server/internal/httpapi/diag.go @@ -0,0 +1,93 @@ +package httpapi + +import ( + "crypto/tls" + "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 的 DNS/TCP/TLS/TTFB +// 各段独立耗时(出海段),返回 JSON。host 必须在白名单内。 +func (h *DiagHandler) EgressTiming(w http.ResponseWriter, r *http.Request) { + host := r.URL.Query().Get("host") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if !diagAllowedHosts[host] { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(egressTiming{Host: host, Err: "host not allowed"}) + return + } + _ = json.NewEncoder(w).Encode(measureEgress(host)) +} + +// 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)