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