#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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -126,8 +126,13 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
|
|||||||
"tolerance": 50,
|
"tolerance": 50,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route: LAN direct;(可选)国内 IP/域名直连;其余 via auto。
|
// Route: DNS 劫持 → LAN direct →(可选)国内直连 → 其余 via auto。
|
||||||
routeRules := []any{
|
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{
|
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"},
|
"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",
|
"outbound": "direct",
|
||||||
@@ -142,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"},
|
||||||
@@ -170,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)已统一加时间戳,
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user