From 900c3d6a4f52e97b4a213c460ffccefd2efbf7b4 Mon Sep 17 00:00:00 2001 From: lux Date: Sat, 30 May 2026 21:12:25 +0800 Subject: [PATCH 1/2] Add AnyTLS Clash YAML support for sing-box --- backend/internal/proxy/singbox_anytls_test.go | 142 ++++++++++++++++++ backend/internal/proxy/singbox_parser.go | 93 +++++++++++- backend/internal/proxy/xray.go | 3 + 3 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 backend/internal/proxy/singbox_anytls_test.go diff --git a/backend/internal/proxy/singbox_anytls_test.go b/backend/internal/proxy/singbox_anytls_test.go new file mode 100644 index 00000000..19af1afb --- /dev/null +++ b/backend/internal/proxy/singbox_anytls_test.go @@ -0,0 +1,142 @@ +package proxy + +import "testing" + +func TestBuildSingBoxAnyTLSFromClash(t *testing.T) { + src := ` +proxies: + - name: anytls-main + type: anytls + server: anytls.example.com + port: 443 + password: test-password + sni: sni.example.com + servername: fallback.example.com + skip-cert-verify: true + alpn: + - h2 + - http/1.1 + client-fingerprint: chrome + idle-session-check-interval: 30 + idle-session-timeout: 45 + min-idle-session: 5 +` + + if !IsSingBoxProtocol(src) { + t.Fatalf("expected anytls Clash YAML to be treated as sing-box protocol") + } + if RequiresBridge(src, nil, "") { + t.Fatalf("anytls Clash YAML must not require Xray bridge") + } + + out, err := BuildSingBoxOutbound(src) + if err != nil { + t.Fatalf("BuildSingBoxOutbound returned error: %v", err) + } + if got := out["type"]; got != "anytls" { + t.Fatalf("type = %v, want anytls", got) + } + if got := out["tag"]; got != "proxy-out" { + t.Fatalf("tag = %v, want proxy-out", got) + } + if got := out["server"]; got != "anytls.example.com" { + t.Fatalf("server = %v, want anytls.example.com", got) + } + if got := out["server_port"]; got != 443 { + t.Fatalf("server_port = %v, want 443", got) + } + if got := out["password"]; got != "test-password" { + t.Fatalf("password = %v, want test-password", got) + } + if got := out["idle_session_check_interval"]; got != "30s" { + t.Fatalf("idle_session_check_interval = %v, want 30s", got) + } + if got := out["idle_session_timeout"]; got != "45s" { + t.Fatalf("idle_session_timeout = %v, want 45s", got) + } + if got := out["min_idle_session"]; got != 5 { + t.Fatalf("min_idle_session = %v, want 5", got) + } + + tls, ok := out["tls"].(map[string]interface{}) + if !ok { + t.Fatalf("tls is %T, want map[string]interface{}", out["tls"]) + } + if got := tls["enabled"]; got != true { + t.Fatalf("tls.enabled = %v, want true", got) + } + if got := tls["insecure"]; got != true { + t.Fatalf("tls.insecure = %v, want true", got) + } + if got := tls["server_name"]; got != "sni.example.com" { + t.Fatalf("tls.server_name = %v, want sni.example.com", got) + } + alpn, ok := tls["alpn"].([]string) + if !ok { + t.Fatalf("tls.alpn is %T, want []string", tls["alpn"]) + } + if len(alpn) != 2 || alpn[0] != "h2" || alpn[1] != "http/1.1" { + t.Fatalf("tls.alpn = %#v, want [h2 http/1.1]", alpn) + } + utls, ok := tls["utls"].(map[string]interface{}) + if !ok { + t.Fatalf("tls.utls is %T, want map[string]interface{}", tls["utls"]) + } + if got := utls["enabled"]; got != true { + t.Fatalf("tls.utls.enabled = %v, want true", got) + } + if got := utls["fingerprint"]; got != "chrome" { + t.Fatalf("tls.utls.fingerprint = %v, want chrome", got) + } +} + +func TestBuildSingBoxAnyTLSServernameFallbackAndDurationStrings(t *testing.T) { + src := ` +type: anytls +server: anytls.example.com +port: 8443 +password: test-password +servername: fallback.example.com +idle-session-check-interval: 1m +idle-session-timeout: "30" +min-idle-session: 0 +` + + out, err := BuildSingBoxOutbound(src) + if err != nil { + t.Fatalf("BuildSingBoxOutbound returned error: %v", err) + } + if got := out["idle_session_check_interval"]; got != "1m" { + t.Fatalf("idle_session_check_interval = %v, want 1m", got) + } + if got := out["idle_session_timeout"]; got != "30s" { + t.Fatalf("idle_session_timeout = %v, want 30s", got) + } + if _, ok := out["min_idle_session"]; ok { + t.Fatalf("min_idle_session should be omitted when Clash value is 0") + } + tls, ok := out["tls"].(map[string]interface{}) + if !ok { + t.Fatalf("tls is %T, want map[string]interface{}", out["tls"]) + } + if got := tls["server_name"]; got != "fallback.example.com" { + t.Fatalf("tls.server_name = %v, want fallback.example.com", got) + } + if got := tls["insecure"]; got != false { + t.Fatalf("tls.insecure = %v, want false", got) + } + if _, ok := tls["utls"]; ok { + t.Fatalf("tls.utls should be omitted without client-fingerprint") + } +} + +func TestBuildSingBoxAnyTLSRequiresServerAndPort(t *testing.T) { + src := ` +type: anytls +password: test-password +` + + if _, err := BuildSingBoxOutbound(src); err == nil { + t.Fatalf("expected missing server and port to fail") + } +} diff --git a/backend/internal/proxy/singbox_parser.go b/backend/internal/proxy/singbox_parser.go index 471edac7..e48355d3 100644 --- a/backend/internal/proxy/singbox_parser.go +++ b/backend/internal/proxy/singbox_parser.go @@ -18,7 +18,8 @@ func IsSingBoxProtocol(proxyConfig string) bool { // Clash YAML 格式 if strings.Contains(l, "type: hysteria2") || strings.Contains(l, "type:hysteria2") || strings.Contains(l, "type: hysteria") || strings.Contains(l, "type:hysteria") || - strings.Contains(l, "type: tuic") || strings.Contains(l, "type:tuic") { + strings.Contains(l, "type: tuic") || strings.Contains(l, "type:tuic") || + strings.Contains(l, "type: anytls") || strings.Contains(l, "type:anytls") { return true } return false @@ -120,11 +121,67 @@ func parseClashSingBoxNode(src string) (map[string]interface{}, error) { return buildSingBoxHysteria2FromClash(nodeMap) case "tuic": return buildSingBoxTUICFromClash(nodeMap) + case "anytls": + return buildSingBoxAnyTLSFromClash(nodeMap) default: return nil, fmt.Errorf("不支持的 sing-box 节点类型: %s", nodeType) } } +func buildSingBoxAnyTLSFromClash(node map[string]interface{}) (map[string]interface{}, error) { + host := getMapString(node, "server") + port := getMapInt(node, "port") + password := getMapString(node, "password") + sni := getMapString(node, "sni") + if sni == "" { + sni = getMapString(node, "servername") + } + skipVerify := getMapBool(node, "skip-cert-verify") + + if host == "" || port == 0 { + return nil, fmt.Errorf("anytls node info incomplete") + } + + tls := map[string]interface{}{ + "enabled": true, + "insecure": skipVerify, + } + if sni != "" { + tls["server_name"] = sni + } + if alpnRaw, ok := node["alpn"]; ok { + if alpnList := toStringSlice(alpnRaw); len(alpnList) > 0 { + tls["alpn"] = alpnList + } + } + if fingerprint := getMapString(node, "client-fingerprint"); fingerprint != "" { + tls["utls"] = map[string]interface{}{ + "enabled": true, + "fingerprint": fingerprint, + } + } + + out := map[string]interface{}{ + "type": "anytls", + "tag": "proxy-out", + "server": host, + "server_port": port, + "password": password, + "tls": tls, + } + if interval := clashDurationSecondsString(node, "idle-session-check-interval"); interval != "" { + out["idle_session_check_interval"] = interval + } + if timeout := clashDurationSecondsString(node, "idle-session-timeout"); timeout != "" { + out["idle_session_timeout"] = timeout + } + if minIdleSession := getMapInt(node, "min-idle-session"); minIdleSession != 0 { + out["min_idle_session"] = minIdleSession + } + + return out, nil +} + func buildSingBoxHysteria2FromClash(node map[string]interface{}) (map[string]interface{}, error) { host := getMapString(node, "server") port := getMapInt(node, "port") @@ -227,6 +284,40 @@ func parseBandwidthMbps(s string) int { return n } +func clashDurationSecondsString(node map[string]interface{}, key string) string { + v, ok := node[key] + if !ok { + return "" + } + switch value := v.(type) { + case int: + if value <= 0 { + return "" + } + return fmt.Sprintf("%ds", value) + case int64: + if value <= 0 { + return "" + } + return fmt.Sprintf("%ds", value) + case float64: + if value <= 0 { + return "" + } + return fmt.Sprintf("%ds", int(value)) + case string: + s := strings.TrimSpace(value) + if s == "" || s == "0" { + return "" + } + if _, err := strconv.Atoi(s); err == nil { + return s + "s" + } + return s + } + return "" +} + // toStringSlice 将 interface{} 转为 []string func toStringSlice(v interface{}) []string { if v == nil { diff --git a/backend/internal/proxy/xray.go b/backend/internal/proxy/xray.go index 249c5329..8999a0cb 100644 --- a/backend/internal/proxy/xray.go +++ b/backend/internal/proxy/xray.go @@ -104,6 +104,9 @@ func RequiresBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId s if IsChainSocks5Proxy(src) { return true } + if IsSingBoxProtocol(src) { + return false + } if strings.HasPrefix(l, "hysteria://") || strings.HasPrefix(l, "hysteria2://") { return false } From d85e65330724b7682a5bebb7c22ec3ef91fee480 Mon Sep 17 00:00:00 2001 From: lux Date: Sun, 31 May 2026 18:03:57 +0800 Subject: [PATCH 2/2] Add User-Agent fallback for Clash subscription imports --- backend/app_proxy_import.go | 84 ++++++++++++++++--------- backend/app_proxy_import_test.go | 101 +++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 27 deletions(-) create mode 100644 backend/app_proxy_import_test.go diff --git a/backend/app_proxy_import.go b/backend/app_proxy_import.go index 7bf92143..329efd00 100644 --- a/backend/app_proxy_import.go +++ b/backend/app_proxy_import.go @@ -17,6 +17,13 @@ const ( clashSubscriptionTimeout = 25 * time.Second ) +var clashSubscriptionUserAgents = []string{ + "clash-verge/2.0 ant-chrome/1.0", + "FlClash/v0.8.92 clash-verge Platform/windows", + "clash-verge/v2.4.2", + "ClashforWindows/0.19.23", +} + // BrowserProxyFetchClashByURL 拉取 Clash 订阅 URL,并返回可直接导入的 YAML 文本与建议配置。 func (a *App) BrowserProxyFetchClashByURL(rawURL string) (map[string]interface{}, error) { rawURL = strings.TrimSpace(rawURL) @@ -33,36 +40,10 @@ func (a *App) BrowserProxyFetchClashByURL(rawURL string) (map[string]interface{} return nil, fmt.Errorf("仅支持 http/https URL") } - req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil) - if err != nil { - return nil, fmt.Errorf("创建请求失败: %w", err) - } - req.Header.Set("User-Agent", "clash-verge/2.0 ant-chrome/1.0") - req.Header.Set("Accept", "application/yaml,text/yaml,text/plain,*/*") - req.Header.Set("Cache-Control", "no-cache") - client := &http.Client{ Timeout: clashSubscriptionTimeout, } - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("拉取订阅失败: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("拉取订阅失败: HTTP %d", resp.StatusCode) - } - - body, err := io.ReadAll(io.LimitReader(resp.Body, maxClashSubscriptionBytes+1)) - if err != nil { - return nil, fmt.Errorf("读取订阅内容失败: %w", err) - } - if len(body) > maxClashSubscriptionBytes { - return nil, fmt.Errorf("订阅内容过大(超过 8MB)") - } - - content, payload, err := normalizeClashSubscriptionContent(body) + content, payload, err := fetchClashSubscriptionWithFallback(client, parsedURL.String()) if err != nil { return nil, err } @@ -84,6 +65,55 @@ func (a *App) BrowserProxyFetchClashByURL(rawURL string) (map[string]interface{} }, nil } +func fetchClashSubscriptionWithFallback(client *http.Client, targetURL string) (string, interface{}, error) { + var lastErr error + for _, userAgent := range clashSubscriptionUserAgents { + content, payload, err := fetchClashSubscriptionWithUserAgent(client, targetURL, userAgent) + if err == nil { + return content, payload, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = fmt.Errorf("未配置可用的 User-Agent") + } + return "", nil, fmt.Errorf("拉取订阅失败: %w", lastErr) +} + +func fetchClashSubscriptionWithUserAgent(client *http.Client, targetURL string, userAgent string) (string, interface{}, error) { + req, err := http.NewRequest(http.MethodGet, targetURL, nil) + if err != nil { + return "", nil, fmt.Errorf("创建请求失败") + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/yaml,text/yaml,text/plain,*/*") + req.Header.Set("Cache-Control", "no-cache") + + resp, err := client.Do(req) + if err != nil { + return "", nil, fmt.Errorf("网络请求失败") + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxClashSubscriptionBytes+1)) + if err != nil { + return "", nil, fmt.Errorf("读取订阅内容失败") + } + if len(body) > maxClashSubscriptionBytes { + return "", nil, fmt.Errorf("订阅内容过大(超过 8MB)") + } + + content, payload, err := normalizeClashSubscriptionContent(body) + if err != nil { + return "", nil, err + } + return content, payload, nil +} + func normalizeClashSubscriptionContent(body []byte) (string, interface{}, error) { baseText := strings.TrimSpace(strings.ReplaceAll(string(body), "\r\n", "\n")) if baseText == "" { diff --git a/backend/app_proxy_import_test.go b/backend/app_proxy_import_test.go new file mode 100644 index 00000000..ed26e3ca --- /dev/null +++ b/backend/app_proxy_import_test.go @@ -0,0 +1,101 @@ +package backend + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const testClashSubscriptionYAML = ` +proxies: + - name: test-node + type: http + server: example.com + port: 8080 +` + +func TestBrowserProxyFetchClashByURLFallbackAfterHTTPStatus(t *testing.T) { + var seenUserAgents []string + var seenAccept string + var seenCacheControl string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenUserAgents = append(seenUserAgents, r.Header.Get("User-Agent")) + if len(seenUserAgents) == 1 { + seenAccept = r.Header.Get("Accept") + seenCacheControl = r.Header.Get("Cache-Control") + http.Error(w, "forbidden", http.StatusForbidden) + return + } + fmt.Fprint(w, testClashSubscriptionYAML) + })) + defer server.Close() + + result, err := (&App{}).BrowserProxyFetchClashByURL(server.URL + "/sub?token=test-token") + if err != nil { + t.Fatalf("BrowserProxyFetchClashByURL returned error: %v", err) + } + if got := result["proxyCount"]; got != 1 { + t.Fatalf("proxyCount = %v, want 1", got) + } + if len(seenUserAgents) != 2 { + t.Fatalf("request count = %d, want 2", len(seenUserAgents)) + } + if seenUserAgents[0] != clashSubscriptionUserAgents[0] { + t.Fatalf("first User-Agent = %q, want %q", seenUserAgents[0], clashSubscriptionUserAgents[0]) + } + if seenUserAgents[1] != clashSubscriptionUserAgents[1] { + t.Fatalf("second User-Agent = %q, want %q", seenUserAgents[1], clashSubscriptionUserAgents[1]) + } + if seenAccept != "application/yaml,text/yaml,text/plain,*/*" { + t.Fatalf("Accept = %q", seenAccept) + } + if seenCacheControl != "no-cache" { + t.Fatalf("Cache-Control = %q", seenCacheControl) + } +} + +func TestBrowserProxyFetchClashByURLFallbackAfterHTMLContent(t *testing.T) { + var requestCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + if requestCount == 1 { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, "client not supported") + return + } + fmt.Fprint(w, testClashSubscriptionYAML) + })) + defer server.Close() + + result, err := (&App{}).BrowserProxyFetchClashByURL(server.URL) + if err != nil { + t.Fatalf("BrowserProxyFetchClashByURL returned error: %v", err) + } + if got := result["proxyCount"]; got != 1 { + t.Fatalf("proxyCount = %v, want 1", got) + } + if requestCount != 2 { + t.Fatalf("request count = %d, want 2", requestCount) + } +} + +func TestBrowserProxyFetchClashByURLAllFallbackErrorsHideURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "forbidden", http.StatusForbidden) + })) + defer server.Close() + + rawURL := server.URL + "/sub/path?token=secret-token" + _, err := (&App{}).BrowserProxyFetchClashByURL(rawURL) + if err == nil { + t.Fatal("BrowserProxyFetchClashByURL returned nil error, want failure") + } + errText := err.Error() + for _, forbidden := range []string{rawURL, "secret-token", "token=", "/sub/path"} { + if strings.Contains(errText, forbidden) { + t.Fatalf("error %q leaked %q", errText, forbidden) + } + } +}