Add User-Agent fallback for Clash subscription imports

This commit is contained in:
lux
2026-05-31 18:03:57 +08:00
parent 4b252ee697
commit d85e653307
2 changed files with 158 additions and 27 deletions
+57 -27
View File
@@ -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 == "" {
+101
View File
@@ -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, "<html><body>client not supported</body></html>")
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)
}
}
}