Files
pangolin/server/internal/httpapi/diag.go
T
wangjia bc890974c0 fix(server): 补 #5 国内分流的 DNS 面 + 链路诊断端点(#12)
#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>
2026-06-22 14:12:31 +08:00

94 lines
3.2 KiB
Go

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
}