Files
Ant-Browser/backend/app_proxy_import.go

450 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package backend
import (
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/proxy"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const (
maxClashSubscriptionBytes = 8 * 1024 * 1024
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) {
return a.browserProxyFetchClashByURL(rawURL, "")
}
// BrowserProxyFetchClashByURLWithProxy 按当前连接栈使用指定代理拉取 Clash 订阅。
func (a *App) BrowserProxyFetchClashByURLWithProxy(rawURL string, proxyID string) (map[string]interface{}, error) {
return a.browserProxyFetchClashByURL(rawURL, proxyID)
}
func (a *App) browserProxyFetchClashByURL(rawURL string, proxyID string) (map[string]interface{}, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil, fmt.Errorf("订阅 URL 不能为空")
}
proxyID = strings.TrimSpace(proxyID)
parsedURL, err := url.Parse(rawURL)
if err != nil || parsedURL.Host == "" {
return nil, fmt.Errorf("URL 格式无效")
}
scheme := strings.ToLower(strings.TrimSpace(parsedURL.Scheme))
if scheme != "http" && scheme != "https" {
return nil, fmt.Errorf("仅支持 http/https URL")
}
client := &http.Client{Timeout: clashSubscriptionTimeout}
if proxyID != "" {
proxyClient, err := a.clashSubscriptionProxyClient(proxyID)
if err != nil {
return nil, err
}
client = proxyClient
}
content, payload, err := fetchClashSubscriptionWithFallback(client, parsedURL.String())
if err != nil {
return nil, err
}
proxyCount := clashProxyCount(payload)
if proxyCount <= 0 {
return nil, fmt.Errorf("未检测到可导入的 proxies 节点")
}
dnsYAML := extractClashDNSYAML(payload)
suggestedGroup := suggestClashGroupName(payload, parsedURL.Hostname())
return map[string]interface{}{
"url": parsedURL.String(),
"content": content,
"proxyCount": proxyCount,
"dnsServers": dnsYAML,
"suggestedGroup": suggestedGroup,
}, nil
}
func (a *App) clashSubscriptionProxyClient(proxyID string) (*http.Client, error) {
if a == nil || a.config == nil {
return nil, fmt.Errorf("代理拉取需要应用配置已初始化")
}
proxies := a.getLatestProxies()
found := false
for _, item := range proxies {
if strings.EqualFold(strings.TrimSpace(item.ProxyId), proxyID) && strings.TrimSpace(item.ProxyConfig) != "" {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("拉取代理不存在或配置为空")
}
connectorType := config.NormalizeBrowserConnectorType(a.config.Browser.DefaultConnectorType)
return proxy.BuildProxyHTTPClient("", proxyID, proxies, a.xrayMgr, a.singboxMgr, a.clashMgr, connectorType, clashSubscriptionTimeout)
}
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 == "" {
return "", nil, fmt.Errorf("订阅内容为空")
}
tryTexts := make([]string, 0, 4)
tryTexts = append(tryTexts, baseText)
if unescaped, err := url.QueryUnescape(baseText); err == nil {
unescaped = strings.TrimSpace(strings.ReplaceAll(unescaped, "\r\n", "\n"))
if unescaped != "" && unescaped != baseText {
tryTexts = append(tryTexts, unescaped)
}
}
if decoded, ok := decodeBase64Text(baseText); ok {
tryTexts = append(tryTexts, decoded)
}
for _, text := range tryTexts {
payload, ok := parseClashPayload(text)
if !ok {
continue
}
if clashProxyCount(payload) > 0 {
return text, payload, nil
}
}
for _, text := range tryTexts {
converted, ok := convertProxyURIListToClashYAML(text)
if !ok {
continue
}
payload, parsed := parseClashPayload(converted)
if parsed && clashProxyCount(payload) > 0 {
return converted, payload, nil
}
}
return "", nil, fmt.Errorf("URL 内容不是有效 Clash YAML 或 URI 订阅(需包含 proxies 或支持的代理 URI")
}
func convertProxyURIListToClashYAML(text string) (string, bool) {
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
proxies := make([]map[string]interface{}, 0)
for index, line := range lines {
raw := strings.TrimSpace(line)
if raw == "" {
continue
}
node, ok := proxyURIToClashNode(raw, index)
if ok {
proxies = append(proxies, node)
}
}
if len(proxies) == 0 {
return "", false
}
payload := map[string]interface{}{"proxies": proxies}
data, err := yaml.Marshal(payload)
if err != nil {
return "", false
}
return strings.TrimSpace(string(data)), true
}
func proxyURIToClashNode(raw string, index int) (map[string]interface{}, bool) {
u, err := url.Parse(raw)
if err != nil || strings.TrimSpace(u.Scheme) == "" || strings.TrimSpace(u.Hostname()) == "" {
return nil, false
}
scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
port := 0
if portText := strings.TrimSpace(u.Port()); portText != "" {
if parsedPort, err := strconv.Atoi(portText); err == nil {
port = parsedPort
}
}
if port == 0 {
return nil, false
}
name := proxyURIName(u, index)
switch scheme {
case "anytls":
password := u.User.Username()
if password == "" {
return nil, false
}
node := map[string]interface{}{
"name": name,
"type": "anytls",
"server": u.Hostname(),
"port": port,
"password": password,
}
q := u.Query()
if sni := firstNonEmptyQueryParam(q, "sni", "peer", "servername"); sni != "" {
node["sni"] = sni
}
if uriBoolParam(q, "insecure", "allowInsecure", "skip-cert-verify") {
node["skip-cert-verify"] = true
}
if fp := firstNonEmptyQueryParam(q, "client-fingerprint", "fingerprint", "fp"); fp != "" {
node["client-fingerprint"] = fp
}
return node, true
case "trojan":
password := u.User.Username()
if password == "" {
return nil, false
}
node := map[string]interface{}{
"name": name,
"type": "trojan",
"server": u.Hostname(),
"port": port,
"password": password,
}
q := u.Query()
if sni := firstNonEmptyQueryParam(q, "sni", "peer", "servername"); sni != "" {
node["sni"] = sni
}
if network := firstNonEmptyQueryParam(q, "type", "network"); network != "" {
node["network"] = network
}
if uriBoolParam(q, "insecure", "allowInsecure", "skip-cert-verify") {
node["skip-cert-verify"] = true
}
return node, true
default:
return nil, false
}
}
func proxyURIName(u *url.URL, index int) string {
if u.Fragment != "" {
if name, err := url.QueryUnescape(u.Fragment); err == nil && strings.TrimSpace(name) != "" {
return strings.TrimSpace(name)
}
if strings.TrimSpace(u.Fragment) != "" {
return strings.TrimSpace(u.Fragment)
}
}
return fmt.Sprintf("导入代理 %d", index+1)
}
func firstNonEmptyQueryParam(q url.Values, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(q.Get(key)); value != "" {
return value
}
}
return ""
}
func uriBoolParam(q url.Values, keys ...string) bool {
for _, key := range keys {
value := strings.ToLower(strings.TrimSpace(q.Get(key)))
if value == "1" || value == "true" || value == "yes" {
return true
}
}
return false
}
func decodeBase64Text(raw string) (string, bool) {
candidate := strings.TrimSpace(raw)
if candidate == "" {
return "", false
}
// 一些订阅会返回 URL-safe base64 或缺少 padding,这里都尝试一遍。
padded := candidate
if mod := len(padded) % 4; mod != 0 {
padded += strings.Repeat("=", 4-mod)
}
encoders := []*base64.Encoding{
base64.StdEncoding,
base64.RawStdEncoding,
base64.URLEncoding,
base64.RawURLEncoding,
}
for _, enc := range encoders {
if data, err := enc.DecodeString(candidate); err == nil {
decoded := strings.TrimSpace(strings.ReplaceAll(string(data), "\r\n", "\n"))
if decoded != "" {
return decoded, true
}
}
if data, err := enc.DecodeString(padded); err == nil {
decoded := strings.TrimSpace(strings.ReplaceAll(string(data), "\r\n", "\n"))
if decoded != "" {
return decoded, true
}
}
}
return "", false
}
func parseClashPayload(text string) (interface{}, bool) {
var payload interface{}
if err := yaml.Unmarshal([]byte(text), &payload); err != nil {
return nil, false
}
return payload, true
}
func clashProxyCount(payload interface{}) int {
if m := toStringMap(payload); m != nil {
if arr, ok := m["proxies"].([]interface{}); ok {
return len(arr)
}
if arr, ok := m["proxy"].([]interface{}); ok {
return len(arr)
}
if arr, ok := m["Proxy"].([]interface{}); ok {
return len(arr)
}
}
if arr, ok := payload.([]interface{}); ok {
return len(arr)
}
return 0
}
func extractClashDNSYAML(payload interface{}) string {
m := toStringMap(payload)
if m == nil {
return ""
}
dnsRaw, exists := m["dns"]
if !exists || dnsRaw == nil {
return ""
}
data, err := yaml.Marshal(map[string]interface{}{
"dns": dnsRaw,
})
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func suggestClashGroupName(payload interface{}, fallbackHost string) string {
fallbackHost = strings.TrimSpace(fallbackHost)
m := toStringMap(payload)
if m != nil {
if groups, ok := m["proxy-groups"].([]interface{}); ok {
for _, item := range groups {
if groupMap := toStringMap(item); groupMap != nil {
if name := strings.TrimSpace(getMapString(groupMap, "name")); name != "" {
return name
}
}
}
}
}
if strings.HasPrefix(strings.ToLower(fallbackHost), "www.") {
fallbackHost = fallbackHost[4:]
}
return fallbackHost
}
func toStringMap(value interface{}) map[string]interface{} {
switch m := value.(type) {
case map[string]interface{}:
return m
case map[interface{}]interface{}:
out := make(map[string]interface{}, len(m))
for k, v := range m {
key := fmt.Sprint(k)
out[key] = v
}
return out
default:
return nil
}
}
func getMapString(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
value, ok := m[key]
if !ok || value == nil {
return ""
}
switch v := value.(type) {
case string:
return v
default:
return fmt.Sprint(v)
}
}