mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
Merge remote-tracking branch 'origin/master'
This commit is contained in:
+57
-27
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user