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 }