package app import ( "context" "crypto/x509" "errors" "io" "net" "net/http" "net/url" "strconv" "strings" "syscall" "time" ) type connectivitySite struct { ID string `json:"id"` Name string `json:"name"` URL string `json:"url"` Category string `json:"category"` } var connectivityCatalog = []connectivitySite{ {"apple", "Apple", "https://www.apple.com/", "官方网站"}, {"github", "GitHub", "https://github.com/", "开发工具"}, {"google", "Google", "https://www.google.com/", "搜索"}, {"netflix", "Netflix", "https://www.netflix.com/", "流媒体"}, {"youtube", "YouTube", "https://www.youtube.com/", "流媒体"}, {"chatgpt", "ChatGPT", "https://chatgpt.com/", "AI 服务"}, {"claude", "Claude", "https://claude.ai/", "AI 服务"}, {"grok", "Grok", "https://grok.com/", "AI 服务"}, {"dockerhub", "Docker Hub", "https://hub.docker.com/", "开发工具"}, } type connectivityResult struct { SiteID string `json:"siteId"` Status string `json:"status"` Detail string `json:"detail"` HTTPStatus int `json:"httpStatus"` DurationMS int64 `json:"durationMS"` TLSVerified bool `json:"tlsVerified"` CheckedAt time.Time `json:"checkedAt"` } func (a *App) connectivitySites(w http.ResponseWriter, r *http.Request) { jsonResponse(w, 200, connectivityCatalog) } func (a *App) testConnectivity(w http.ResponseWriter, r *http.Request) { var input struct { SiteID string `json:"siteId"` } if err := decode(w, r, &input); err != nil { writeError(w, 400, err.Error()) return } var site *connectivitySite for i := range connectivityCatalog { if connectivityCatalog[i].ID == input.SiteID { site = &connectivityCatalog[i] break } } if site == nil { writeError(w, 400, "请选择列表中的测试网站") return } select { case a.connectivitySlots <- struct{}{}: defer func() { <-a.connectivitySlots }() default: writeError(w, 429, "最多同时测试 3 个网站,请稍后重试") return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() var version map[string]any coreCtx, coreCancel := context.WithTimeout(ctx, 2*time.Second) err := a.core.Request(coreCtx, "GET", "/version", nil, &version) coreCancel() if err != nil { jsonResponse(w, 200, connectivityResult{SiteID: site.ID, Status: "failed", Detail: "Mihomo 内核未连接,请先启动内核", CheckedAt: time.Now().UTC()}) return } a.mu.RLock() settings := a.state.Settings a.mu.RUnlock() client := connectivityClient(settings) defer client.CloseIdleConnections() jsonResponse(w, 200, probeConnectivity(ctx, client, *site)) } func connectivityClient(settings Settings) *http.Client { host := settings.LANIP if host == "" { host = "127.0.0.1" } proxy := &url.URL{Scheme: "http", Host: net.JoinHostPort(host, strconv.Itoa(settings.MixedPort))} transport := http.DefaultTransport.(*http.Transport).Clone() // Always use this VM's Mihomo, even when the service has HTTP_PROXY/NO_PROXY. transport.Proxy = http.ProxyURL(proxy) transport.TLSHandshakeTimeout = 8 * time.Second return &http.Client{Transport: transport, Timeout: 12 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 5 { return errors.New("网站重定向次数过多") } if req.URL.Scheme != "https" || req.URL.User != nil { return errors.New("网站重定向到非 HTTPS 地址") } if !connectivityRedirectAllowed(req.URL.Hostname(), via[0].URL.Hostname()) { return errors.New("网站重定向到测试范围外的域名") } return nil }} } func connectivityRedirectAllowed(host, original string) bool { // Only follow the selected site's domains, never a redirect into the LAN. host, original = strings.ToLower(host), strings.ToLower(original) for _, domain := range []string{"apple.com", "github.com", "google.com", "netflix.com", "youtube.com", "chatgpt.com", "claude.ai", "grok.com", "docker.com"} { if original == domain || strings.HasSuffix(original, "."+domain) { allowed := []string{domain} if domain == "claude.ai" { allowed = append(allowed, "claude.com") } if domain == "chatgpt.com" { allowed = append(allowed, "openai.com") } for _, candidate := range allowed { if host == candidate || strings.HasSuffix(host, "."+candidate) { return true } } return false } } return false } func probeConnectivity(ctx context.Context, client *http.Client, site connectivitySite) (result connectivityResult) { start := time.Now() result = connectivityResult{SiteID: site.ID, Status: "failed"} defer func() { result.DurationMS = time.Since(start).Milliseconds(); result.CheckedAt = time.Now().UTC() }() req, err := http.NewRequestWithContext(ctx, "GET", site.URL, nil) if err != nil { result.Detail = "测试地址无效" return } req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; ClashWeb/1.0; connectivity-check)") req.Header.Set("Accept", "text/html,application/xhtml+xml") resp, err := client.Do(req) if err != nil { if resp != nil { // net/http retains the last response when a redirect is refused. result.HTTPStatus = resp.StatusCode result.TLSVerified = resp.TLS != nil && len(resp.TLS.VerifiedChains) > 0 result.Status, result.Detail = "http_error", "网站已响应,但重定向超出测试范围或次数限制" return } result.Detail = connectivityError(err) return } defer resp.Body.Close() result.HTTPStatus = resp.StatusCode result.TLSVerified = resp.TLS != nil && len(resp.TLS.VerifiedChains) > 0 body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) if err != nil { result.Detail = "网站已响应,但读取响应失败或超时" return } sample := strings.ToLower(string(body)) challenge := resp.Header.Get("Cf-Mitigated") == "challenge" || strings.Contains(sample, "cf-chl-") || (strings.Contains(sample, "just a moment") && strings.Contains(sample, "cloudflare")) switch { case challenge: result.Status, result.Detail = "challenge", "已连接网站,网站要求浏览器安全验证" case resp.StatusCode >= 200 && resp.StatusCode < 300: result.Status, result.Detail = "reachable", "HTTPS 主页响应正常" case resp.StatusCode == 403 || resp.StatusCode == 451: result.Status, result.Detail = "restricted", "网站拒绝访问,可能有出口地区或访问策略限制" case resp.StatusCode == 429: result.Status, result.Detail = "restricted", "网站请求频率受限,请稍后重试" default: result.Status, result.Detail = "http_error", "已连接网站,但返回异常 HTTP 状态" } return } func connectivityError(err error) string { var authority x509.UnknownAuthorityError var certificate x509.CertificateInvalidError var hostname x509.HostnameError var dns *net.DNSError var network net.Error switch { case errors.Is(err, context.Canceled): return "测试已取消" case errors.Is(err, context.DeadlineExceeded), errors.As(err, &network) && network.Timeout(): return "连接超时,请检查当前节点和网络" case errors.As(err, &authority): return "TLS 证书不受信任,请检查 VM 的 CA 证书包" case errors.As(err, &certificate), errors.As(err, &hostname): return "TLS 证书校验失败,请检查 VM 时间及网站证书" case errors.As(err, &dns): return "域名解析失败,请检查 VM 或 Mihomo 的 DNS" case errors.Is(err, syscall.ECONNREFUSED): return "连接被拒绝,请检查 Mihomo 代理端口与网站状态" default: return "连接失败,请检查当前节点、网络和网站访问策略" } }