Initialize Clash Web source and deployment documentation
This commit is contained in:
commit
8e0fa00a73
66 files changed
+8532
No files matched your search
@@ -0,0 +1,211 @@
|
||||
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 "连接失败,请检查当前节点、网络和网站访问策略"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user