Files
clash-web/internal/app/connectivity.go
T

212 lines
7.4 KiB
Go

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 "连接失败,请检查当前节点、网络和网站访问策略"
}
}