publish: 1.0.0 snapshot (bad2ec1)

channel: master
version: 1.0.0
source-ref: master
published-at-utc: 2026-03-13T15:19:28Z
This commit is contained in:
Ant Browser Release Bot
2026-03-13 23:19:29 +08:00
commit 6f58a6c19a
230 changed files with 44624 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
package proxy
import (
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/logger"
"fmt"
"os"
"os/exec"
"strings"
)
// ClashManager Clash 进程管理器
type ClashManager struct {
Config *config.Config
AppRoot string // 应用根目录,所有相对路径基于此解析
Processes map[string]*exec.Cmd
}
// NewClashManager 创建 Clash 管理器
func NewClashManager(cfg *config.Config, appRoot string) *ClashManager {
return &ClashManager{
Config: cfg,
AppRoot: appRoot,
Processes: make(map[string]*exec.Cmd),
}
}
// ClashProfile Clash 配置接口
type ClashProfile interface {
GetProfileId() string
GetClashEnabled() bool
GetClashRunning() bool
GetClashConfigPath() string
GetClashProxyPort() int
SetClashRunning(bool)
SetClashPid(int)
SetClashProxyPort(int)
SetClashLastError(string)
}
// StartForProfile 为配置启动 Clash 进程
func (m *ClashManager) StartForProfile(profile ClashProfile, userDataDir string) error {
log := logger.New("Clash")
if !profile.GetClashEnabled() {
return nil
}
if profile.GetClashRunning() {
return nil
}
clashBinaryPath := strings.TrimSpace(m.Config.Browser.ClashBinaryPath)
if clashBinaryPath == "" {
err := fmt.Errorf("clash binary path not configured")
profile.SetClashLastError(err.Error())
log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
return err
}
if _, err := os.Stat(clashBinaryPath); err != nil {
profile.SetClashLastError(err.Error())
log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
return err
}
templatePath := strings.TrimSpace(profile.GetClashConfigPath())
if templatePath == "" {
err := fmt.Errorf("clash config path not configured")
profile.SetClashLastError(err.Error())
log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
return err
}
if _, err := os.Stat(templatePath); err != nil {
profile.SetClashLastError(err.Error())
log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
return err
}
port := profile.GetClashProxyPort()
if port == 0 {
p, err := nextAvailablePort()
if err != nil {
profile.SetClashLastError(err.Error())
log.Error("Clash 端口分配失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
return err
}
port = p
profile.SetClashProxyPort(port)
}
args := []string{
"-f", templatePath,
"-d", userDataDir,
}
cmd := exec.Command(clashBinaryPath, args...)
hideWindow(cmd)
if err := cmd.Start(); err != nil {
profile.SetClashLastError(err.Error())
log.Error("Clash 启动失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
return err
}
m.Processes[profile.GetProfileId()] = cmd
profile.SetClashRunning(true)
profile.SetClashPid(cmd.Process.Pid)
profile.SetClashLastError("")
log.Info("Clash 启动成功", logger.F("profile_id", profile.GetProfileId()), logger.F("pid", cmd.Process.Pid), logger.F("port", port))
return nil
}
// StopForProfile 停止配置的 Clash 进程
func (m *ClashManager) StopForProfile(profile ClashProfile) {
log := logger.New("Clash")
cmd := m.Processes[profile.GetProfileId()]
if cmd != nil && cmd.Process != nil {
if err := cmd.Process.Kill(); err != nil {
log.Error("Clash 停止失败", logger.F("profile_id", profile.GetProfileId()), logger.F("error", err))
}
}
delete(m.Processes, profile.GetProfileId())
profile.SetClashRunning(false)
profile.SetClashPid(0)
log.Info("Clash 已停止", logger.F("profile_id", profile.GetProfileId()))
}
// StopAll 停止所有 Clash 进程
func (m *ClashManager) StopAll() {
for profileID, cmd := range m.Processes {
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
delete(m.Processes, profileID)
}
}
+95
View File
@@ -0,0 +1,95 @@
package proxy
import (
"fmt"
"net/http"
"net/url"
"strings"
"time"
"ant-chrome/backend/internal/config"
xproxy "golang.org/x/net/proxy"
)
// buildProxyHTTPClient 根据代理配置构建 HTTP 客户端,统一用于测速/健康检测场景。
func buildProxyHTTPClient(
src string,
proxyId string,
proxies []config.BrowserProxy,
xrayMgr *XrayManager,
singboxMgr *SingBoxManager,
timeout time.Duration,
) (*http.Client, error) {
l := strings.ToLower(strings.TrimSpace(src))
if l == "" || l == "direct://" {
return &http.Client{Timeout: timeout}, nil
}
if IsSingBoxProtocol(src) {
if singboxMgr == nil {
return nil, fmt.Errorf("sing-box 管理器未初始化")
}
socks5Addr, err := singboxMgr.EnsureBridge(src, proxies, proxyId)
if err != nil {
return nil, fmt.Errorf("sing-box 桥接启动失败: %w", err)
}
return buildSocks5HTTPClient(strings.TrimPrefix(socks5Addr, "socks5://"), timeout)
}
if RequiresBridge(src, proxies, proxyId) {
if xrayMgr == nil {
return nil, fmt.Errorf("xray 管理器未初始化")
}
socks5Addr, err := xrayMgr.EnsureBridge(src, proxies, proxyId)
if err != nil {
return nil, fmt.Errorf("xray 桥接启动失败: %w", err)
}
return buildSocks5HTTPClient(strings.TrimPrefix(socks5Addr, "socks5://"), timeout)
}
if strings.HasPrefix(l, "socks5://") {
u, err := url.Parse(src)
if err != nil {
return nil, fmt.Errorf("SOCKS5 地址解析失败: %w", err)
}
var auth *xproxy.Auth
if u.User != nil {
pass, _ := u.User.Password()
auth = &xproxy.Auth{
User: u.User.Username(),
Password: pass,
}
}
dialer, err := xproxy.SOCKS5("tcp", u.Host, auth, xproxy.Direct)
if err != nil {
return nil, fmt.Errorf("SOCKS5 dialer 创建失败: %w", err)
}
contextDialer, ok := dialer.(xproxy.ContextDialer)
if !ok {
return nil, fmt.Errorf("SOCKS5 dialer 不支持 ContextDialer")
}
transport := &http.Transport{DialContext: contextDialer.DialContext}
return &http.Client{Transport: transport, Timeout: timeout}, nil
}
proxyURL, err := url.Parse(src)
if err != nil {
return nil, fmt.Errorf("代理地址解析失败: %w", err)
}
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
return &http.Client{Transport: transport, Timeout: timeout}, nil
}
func buildSocks5HTTPClient(socks5Host string, timeout time.Duration) (*http.Client, error) {
dialer, err := xproxy.SOCKS5("tcp", socks5Host, nil, xproxy.Direct)
if err != nil {
return nil, fmt.Errorf("SOCKS5 dialer 创建失败: %w", err)
}
contextDialer, ok := dialer.(xproxy.ContextDialer)
if !ok {
return nil, fmt.Errorf("SOCKS5 dialer 不支持 ContextDialer")
}
transport := &http.Transport{DialContext: contextDialer.DialContext}
return &http.Client{Transport: transport, Timeout: timeout}, nil
}
+82
View File
@@ -0,0 +1,82 @@
package proxy
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"ant-chrome/backend/internal/config"
)
const defaultIPPureInfoURL = "https://my.ippure.com/v1/info"
// FetchIPPureInfo 通过指定代理链路查询 IPPure 的出口 IP 健康信息。
// 返回值为第三方接口原始 JSON(map 形式),不做本地评分计算。
func FetchIPPureInfo(
proxyId string,
proxies []config.BrowserProxy,
xrayMgr *XrayManager,
singboxMgr *SingBoxManager,
) (map[string]interface{}, error) {
src := ""
for _, item := range proxies {
if strings.EqualFold(item.ProxyId, proxyId) {
src = strings.TrimSpace(item.ProxyConfig)
break
}
}
if src == "" {
return nil, fmt.Errorf("未找到代理配置")
}
client, err := buildIPPureHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, 20*time.Second)
if err != nil {
return nil, err
}
req, _ := http.NewRequest(http.MethodGet, defaultIPPureInfoURL, nil)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "AntChrome/1.0")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("调用 IPPure 接口失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 IPPure 响应失败: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("IPPure HTTP %d: %s", resp.StatusCode, bodySnippet(body, 180))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("IPPure JSON 解析失败: %w", err)
}
return result, nil
}
func buildIPPureHTTPClient(
src string,
proxyId string,
proxies []config.BrowserProxy,
xrayMgr *XrayManager,
singboxMgr *SingBoxManager,
timeout time.Duration,
) (*http.Client, error) {
return buildProxyHTTPClient(src, proxyId, proxies, xrayMgr, singboxMgr, timeout)
}
func bodySnippet(body []byte, max int) string {
s := strings.TrimSpace(string(body))
if len(s) <= max {
return s
}
return s[:max] + "..."
}
+685
View File
@@ -0,0 +1,685 @@
package proxy
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// ParseProxyNode 解析代理节点
func ParseProxyNode(node string) (string, map[string]interface{}, error) {
src := strings.TrimSpace(node)
if src == "" {
return "", nil, fmt.Errorf("代理节点为空")
}
l := strings.ToLower(src)
if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "socks5://") {
return src, nil, nil
}
if strings.HasPrefix(l, "clash://") || strings.Contains(l, "type:") || strings.Contains(l, "proxies:") {
outbound, standard, err := parseClashNode(src)
if err != nil {
return "", nil, err
}
if standard != "" {
return standard, nil, nil
}
if outbound != nil {
return "", outbound, nil
}
}
outbound, err := buildXrayOutbound(src)
if err != nil {
return "", nil, err
}
return "", outbound, nil
}
func parseClashNode(src string) (map[string]interface{}, string, error) {
data := strings.TrimSpace(src)
if strings.HasPrefix(strings.ToLower(data), "clash://") {
raw := strings.TrimPrefix(data, "clash://")
raw, _ = url.QueryUnescape(raw)
decoded, err := decodeBase64String(raw)
if err != nil {
return nil, "", err
}
data = string(decoded)
}
var payload interface{}
if err := yaml.Unmarshal([]byte(data), &payload); err != nil {
return nil, "", err
}
nodeMap := pickClashNode(payload)
if nodeMap == nil {
return nil, "", fmt.Errorf("clash 节点解析失败")
}
nodeType := strings.ToLower(getMapString(nodeMap, "type"))
switch nodeType {
case "socks5", "http", "https":
return nil, buildStandardProxyFromClash(nodeMap, nodeType), nil
case "vmess":
return buildOutboundFromClashVmess(nodeMap)
case "vless":
return buildOutboundFromClashVless(nodeMap)
case "trojan":
return buildOutboundFromClashTrojan(nodeMap)
case "ss", "shadowsocks":
return buildOutboundFromClashSS(nodeMap)
case "ssr":
return nil, "", fmt.Errorf("不支持 ShadowsocksR 协议,Xray 不支持 SSR,请使用 SS/vmess/vless/trojan")
case "hysteria2", "hysteria":
return buildOutboundFromClashHysteria2(nodeMap)
}
return nil, "", fmt.Errorf("不支持的节点类型")
}
func pickClashNode(payload interface{}) map[string]interface{} {
if m := toStringMap(payload); m != nil {
if proxies, ok := m["proxies"]; ok {
if arr, ok := proxies.([]interface{}); ok && len(arr) > 0 {
return toStringMap(arr[0])
}
}
if proxyItem, ok := m["proxy"]; ok {
if node := toStringMap(proxyItem); node != nil {
return node
}
}
return m
}
if arr, ok := payload.([]interface{}); ok && len(arr) > 0 {
return toStringMap(arr[0])
}
return nil
}
func buildStandardProxyFromClash(node map[string]interface{}, scheme string) string {
host := getMapString(node, "server")
port := getMapInt(node, "port")
username := getMapString(node, "username")
password := getMapString(node, "password")
if host == "" || port == 0 {
return ""
}
address := fmt.Sprintf("%s:%d", host, port)
if username != "" {
user := url.UserPassword(username, password)
return fmt.Sprintf("%s://%s@%s", scheme, user.String(), address)
}
return fmt.Sprintf("%s://%s", scheme, address)
}
func buildOutboundFromClashVless(node map[string]interface{}) (map[string]interface{}, string, error) {
host := getMapString(node, "server")
port := getMapInt(node, "port")
id := getMapString(node, "uuid")
flow := getMapString(node, "flow")
// sni 和 servername 都要读
sni := getMapString(node, "sni")
if sni == "" {
sni = getMapString(node, "servername")
}
network := getMapString(node, "network")
out := map[string]interface{}{
"protocol": "vless",
"tag": "proxy-out",
"settings": map[string]interface{}{
"vnext": []interface{}{
map[string]interface{}{
"address": host,
"port": port,
"users": []interface{}{
map[string]interface{}{
"id": id,
"flow": flow,
"encryption": "none",
},
},
},
},
},
}
stream := map[string]interface{}{}
tlsVal := strings.ToLower(getMapString(node, "tls"))
_, hasRealityOpts := node["reality-opts"]
if hasRealityOpts {
// Reality 模式:network 必须显式为 tcp,否则 xray 校验失败
stream["network"] = "tcp"
realityOpts := map[string]interface{}{
"spiderX": "",
}
if sni != "" {
realityOpts["serverName"] = sni
}
fingerprint := getMapString(node, "client-fingerprint")
if fingerprint == "" {
fingerprint = "chrome"
}
realityOpts["fingerprint"] = fingerprint
if rm := toStringMap(node["reality-opts"]); rm != nil {
if pbk := getMapString(rm, "public-key"); pbk != "" {
realityOpts["publicKey"] = pbk
}
if sid := getMapString(rm, "short-id"); sid != "" {
realityOpts["shortId"] = sid
}
}
stream["security"] = "reality"
stream["realitySettings"] = realityOpts
} else if getMapBool(node, "tls") || tlsVal == "true" || tlsVal == "tls" {
// 普通 TLS 模式
tlsSettings := map[string]interface{}{}
if sni != "" {
tlsSettings["serverName"] = sni
}
tlsSettings["allowInsecure"] = getMapBool(node, "skip-cert-verify")
stream["security"] = "tls"
stream["tlsSettings"] = tlsSettings
}
if network == "ws" {
stream["network"] = "ws"
ws := map[string]interface{}{}
if wsOpts, ok := node["ws-opts"]; ok {
if wsMap := toStringMap(wsOpts); wsMap != nil {
path := getMapString(wsMap, "path")
// path 为 "/" 也要设置
if path != "" {
ws["path"] = path
}
if headers, ok := wsMap["headers"]; ok {
if headerMap := toStringMap(headers); headerMap != nil {
if hostH := getMapString(headerMap, "Host"); hostH != "" {
ws["headers"] = map[string]interface{}{"Host": hostH}
}
}
}
}
}
stream["wsSettings"] = ws
}
if network == "grpc" {
stream["network"] = "grpc"
if grpcOpts, ok := node["grpc-opts"]; ok {
if grpcMap := toStringMap(grpcOpts); grpcMap != nil {
serviceName := getMapString(grpcMap, "grpc-service-name")
if serviceName != "" {
stream["grpcSettings"] = map[string]interface{}{"serviceName": serviceName}
}
}
}
}
if len(stream) > 0 {
out["streamSettings"] = stream
}
return out, "", nil
}
func buildOutboundFromClashVmess(node map[string]interface{}) (map[string]interface{}, string, error) {
host := getMapString(node, "server")
port := getMapInt(node, "port")
id := getMapString(node, "uuid")
cipher := getMapString(node, "cipher")
if cipher == "" {
cipher = "auto"
}
network := getMapString(node, "network")
// sni 和 servername 都要读
sni := getMapString(node, "sni")
if sni == "" {
sni = getMapString(node, "servername")
}
out := map[string]interface{}{
"protocol": "vmess",
"tag": "proxy-out",
"settings": map[string]interface{}{
"vnext": []interface{}{
map[string]interface{}{
"address": host,
"port": port,
"users": []interface{}{
map[string]interface{}{
"id": id,
"security": cipher,
},
},
},
},
},
}
stream := map[string]interface{}{}
if getMapBool(node, "tls") || strings.ToLower(getMapString(node, "tls")) == "true" {
tlsSettings := map[string]interface{}{}
if sni != "" {
tlsSettings["serverName"] = sni
}
skipVerify := getMapBool(node, "skip-cert-verify")
tlsSettings["allowInsecure"] = skipVerify
stream["security"] = "tls"
stream["tlsSettings"] = tlsSettings
}
if network == "ws" {
stream["network"] = "ws"
ws := map[string]interface{}{}
if wsOpts, ok := node["ws-opts"]; ok {
if wsMap := toStringMap(wsOpts); wsMap != nil {
path := getMapString(wsMap, "path")
// path 为 "/" 也要设置
if path != "" {
ws["path"] = path
}
if headers, ok := wsMap["headers"]; ok {
if headerMap := toStringMap(headers); headerMap != nil {
if hostH := getMapString(headerMap, "Host"); hostH != "" {
ws["headers"] = map[string]interface{}{"Host": hostH}
}
}
}
}
}
stream["wsSettings"] = ws
}
if network == "grpc" {
stream["network"] = "grpc"
if grpcOpts, ok := node["grpc-opts"]; ok {
if grpcMap := toStringMap(grpcOpts); grpcMap != nil {
serviceName := getMapString(grpcMap, "grpc-service-name")
if serviceName != "" {
stream["grpcSettings"] = map[string]interface{}{"serviceName": serviceName}
}
}
}
}
if len(stream) > 0 {
out["streamSettings"] = stream
}
return out, "", nil
}
func buildOutboundFromClashTrojan(node map[string]interface{}) (map[string]interface{}, string, error) {
host := getMapString(node, "server")
port := getMapInt(node, "port")
password := getMapString(node, "password")
sni := getMapString(node, "sni")
if sni == "" {
sni = getMapString(node, "servername")
}
network := getMapString(node, "network")
skipVerify := getMapBool(node, "skip-cert-verify")
out := map[string]interface{}{
"protocol": "trojan",
"tag": "proxy-out",
"settings": map[string]interface{}{
"address": host,
"port": port,
"password": password,
},
}
stream := map[string]interface{}{
"security": "tls",
"tlsSettings": map[string]interface{}{
"serverName": sni,
"allowInsecure": skipVerify,
},
}
if network == "ws" {
stream["network"] = "ws"
ws := map[string]interface{}{}
if wsOpts, ok := node["ws-opts"]; ok {
if wsMap := toStringMap(wsOpts); wsMap != nil {
if path := getMapString(wsMap, "path"); path != "" {
ws["path"] = path
}
if headers := toStringMap(wsMap["headers"]); headers != nil {
if h := getMapString(headers, "Host"); h != "" {
ws["headers"] = map[string]interface{}{"Host": h}
}
}
}
}
stream["wsSettings"] = ws
} else if network == "grpc" {
stream["network"] = "grpc"
if grpcOpts, ok := node["grpc-opts"]; ok {
if grpcMap := toStringMap(grpcOpts); grpcMap != nil {
if svcName := getMapString(grpcMap, "grpc-service-name"); svcName != "" {
stream["grpcSettings"] = map[string]interface{}{"serviceName": svcName}
}
}
}
}
out["streamSettings"] = stream
return out, "", nil
}
func buildOutboundFromClashHysteria2(node map[string]interface{}) (map[string]interface{}, string, error) {
// 支持的协议: vless, vmess, trojan, shadowsocks, socks, http, wireguard
// hysteria2 需要使用 Hysteria 客户端或 sing-box
return nil, "", fmt.Errorf("Xray 不支持 hysteria2 协议,请使用 vless/vmess/socks5/http 格式的代理")
}
func buildXrayOutbound(node string) (map[string]interface{}, error) {
l := strings.ToLower(node)
if strings.HasPrefix(l, "vmess://") {
return buildOutboundVmess(node)
}
if strings.HasPrefix(l, "vless://") {
return buildOutboundVless(node)
}
if strings.HasPrefix(l, "trojan://") {
return buildOutboundTrojan(node)
}
if strings.HasPrefix(l, "ss://") {
return buildOutboundSS(node)
}
if strings.HasPrefix(l, "ssr://") {
return nil, fmt.Errorf("不支持 ShadowsocksR 协议,Xray 不支持 SSR,请使用 SS/vmess/vless/trojan")
}
if strings.HasPrefix(l, "hysteria2://") || strings.HasPrefix(l, "hysteria://") {
return buildOutboundHysteria2(node)
}
return nil, fmt.Errorf("不支持的节点协议")
}
func buildOutboundVmess(node string) (map[string]interface{}, error) {
raw := strings.TrimPrefix(node, "vmess://")
decoded, err := decodeBase64String(strings.TrimSpace(raw))
if err != nil {
return nil, fmt.Errorf("vmess 解析失败: %v", err)
}
var v struct {
Add string `json:"add"`
Port string `json:"port"`
ID string `json:"id"`
Net string `json:"net"`
Type string `json:"type"`
Host string `json:"host"`
Path string `json:"path"`
TLS string `json:"tls"`
Sni string `json:"sni"`
Alpn string `json:"alpn"`
}
if err := json.Unmarshal(decoded, &v); err != nil {
return nil, fmt.Errorf("vmess 配置解析失败: %v", err)
}
p, _ := strconv.Atoi(v.Port)
out := map[string]interface{}{
"protocol": "vmess",
"tag": "proxy-out",
"settings": map[string]interface{}{
"vnext": []interface{}{
map[string]interface{}{
"address": v.Add,
"port": p,
"users": []interface{}{
map[string]interface{}{
"id": v.ID,
"security": "auto",
},
},
},
},
},
}
stream := map[string]interface{}{}
if v.TLS == "tls" {
stream["security"] = "tls"
if v.Sni != "" {
stream["tlsSettings"] = map[string]interface{}{"serverName": v.Sni}
}
}
if v.Net == "ws" {
stream["network"] = "ws"
ws := map[string]interface{}{}
if v.Path != "" {
ws["path"] = v.Path
}
if v.Host != "" {
ws["headers"] = map[string]interface{}{"Host": v.Host}
}
if len(ws) > 0 {
stream["wsSettings"] = ws
}
}
if len(stream) > 0 {
out["streamSettings"] = stream
}
return out, nil
}
func buildOutboundVless(node string) (map[string]interface{}, error) {
u, err := url.Parse(node)
if err != nil {
return nil, fmt.Errorf("vless 解析失败: %v", err)
}
host := u.Hostname()
portStr := u.Port()
p, _ := strconv.Atoi(portStr)
id := u.User.Username()
q := u.Query()
flow := q.Get("flow")
sec := strings.ToLower(q.Get("security"))
sni := q.Get("sni")
out := map[string]interface{}{
"protocol": "vless",
"tag": "proxy-out",
"settings": map[string]interface{}{
"vnext": []interface{}{
map[string]interface{}{
"address": host,
"port": p,
"users": []interface{}{
map[string]interface{}{
"id": id,
"flow": flow,
"encryption": "none",
},
},
},
},
},
}
stream := map[string]interface{}{}
if sec == "tls" || sec == "reality" {
stream["security"] = "tls"
if sni != "" {
stream["tlsSettings"] = map[string]interface{}{"serverName": sni}
}
}
network := q.Get("type")
if network == "" {
network = q.Get("network")
}
if network == "ws" {
stream["network"] = "ws"
ws := map[string]interface{}{}
if pth := q.Get("path"); pth != "" {
ws["path"] = pth
}
hostH := q.Get("host")
if hostH == "" {
hostH = u.Hostname()
}
if hostH != "" {
ws["headers"] = map[string]interface{}{"Host": hostH}
}
stream["wsSettings"] = ws
}
if len(stream) > 0 {
out["streamSettings"] = stream
}
return out, nil
}
func buildOutboundHysteria2(node string) (map[string]interface{}, error) {
// Xray 不支持 hysteria2 作为 outbound 协议
// 支持的协议: vless, vmess, trojan, shadowsocks, socks, http, wireguard
// hysteria2 需要使用 Hysteria 客户端或 sing-box
return nil, fmt.Errorf("Xray 不支持 hysteria2 协议,请使用 vless/vmess/socks5/http 格式的代理")
}
// buildOutboundTrojan 解析 trojan:// URI 格式
func buildOutboundTrojan(node string) (map[string]interface{}, error) {
u, err := url.Parse(node)
if err != nil {
return nil, fmt.Errorf("trojan 解析失败: %v", err)
}
host := u.Hostname()
portStr := u.Port()
p, _ := strconv.Atoi(portStr)
password := u.User.Username()
q := u.Query()
sni := q.Get("sni")
if sni == "" {
sni = q.Get("peer")
}
skipVerify := q.Get("allowInsecure") == "1" || strings.ToLower(q.Get("allowInsecure")) == "true"
network := q.Get("type")
out := map[string]interface{}{
"protocol": "trojan",
"tag": "proxy-out",
"settings": map[string]interface{}{
"address": host,
"port": p,
"password": password,
},
}
stream := map[string]interface{}{
"security": "tls",
"tlsSettings": map[string]interface{}{
"serverName": sni,
"allowInsecure": skipVerify,
},
}
if network == "ws" {
stream["network"] = "ws"
ws := map[string]interface{}{}
if pth := q.Get("path"); pth != "" {
ws["path"] = pth
}
if h := q.Get("host"); h != "" {
ws["headers"] = map[string]interface{}{"Host": h}
}
stream["wsSettings"] = ws
}
out["streamSettings"] = stream
return out, nil
}
// buildOutboundFromClashSS 从 Clash YAML 格式解析 Shadowsocks outbound
func buildOutboundFromClashSS(node map[string]interface{}) (map[string]interface{}, string, error) {
host := getMapString(node, "server")
port := getMapInt(node, "port")
password := getMapString(node, "password")
cipher := getMapString(node, "cipher")
if cipher == "" {
cipher = getMapString(node, "method")
}
if cipher == "" {
cipher = "aes-256-gcm"
}
out := map[string]interface{}{
"protocol": "shadowsocks",
"tag": "proxy-out",
"settings": map[string]interface{}{
"address": host,
"port": port,
"method": cipher,
"password": password,
},
}
// plugin 支持(obfs/v2ray-plugin
if plugin := getMapString(node, "plugin"); plugin != "" {
pluginOpts := getMapString(node, "plugin-opts")
_ = pluginOpts // xray 原生不支持 plugin,忽略
}
return out, "", nil
}
// buildOutboundSS 解析 ss:// URI 格式
// 支持两种格式:
// 1. ss://BASE64(method:password)@host:port
// 2. ss://BASE64(method:password@host:port)
func buildOutboundSS(node string) (map[string]interface{}, error) {
raw := strings.TrimPrefix(node, "ss://")
// 去掉 fragment#备注)
if idx := strings.Index(raw, "#"); idx >= 0 {
raw = raw[:idx]
}
raw = strings.TrimSpace(raw)
var host, method, password string
var port int
// 格式1method:password@host:portSIP002
if strings.Contains(raw, "@") {
u, err := url.Parse("ss://" + raw)
if err != nil {
return nil, fmt.Errorf("ss 解析失败: %v", err)
}
host = u.Hostname()
port, _ = strconv.Atoi(u.Port())
userInfo := u.User.String()
// userInfo 可能是 base64 编码的 method:password
if decoded, err := decodeBase64String(userInfo); err == nil {
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) == 2 {
method = parts[0]
password = parts[1]
}
} else {
// 明文 method:password
parts := strings.SplitN(userInfo, ":", 2)
if len(parts) == 2 {
method = parts[0]
password = parts[1]
}
}
} else {
// 格式2:整体 base64
decoded, err := decodeBase64String(raw)
if err != nil {
return nil, fmt.Errorf("ss base64 解析失败: %v", err)
}
// method:password@host:port
s := string(decoded)
atIdx := strings.LastIndex(s, "@")
if atIdx < 0 {
return nil, fmt.Errorf("ss 格式错误")
}
userPart := s[:atIdx]
hostPart := s[atIdx+1:]
parts := strings.SplitN(userPart, ":", 2)
if len(parts) == 2 {
method = parts[0]
password = parts[1]
}
hostPort := strings.Split(hostPart, ":")
if len(hostPort) == 2 {
host = hostPort[0]
port, _ = strconv.Atoi(hostPort[1])
}
}
if host == "" || port == 0 || method == "" {
return nil, fmt.Errorf("ss 节点信息不完整")
}
return map[string]interface{}{
"protocol": "shadowsocks",
"tag": "proxy-out",
"settings": map[string]interface{}{
"address": host,
"port": port,
"method": method,
"password": password,
},
}, nil
}
+281
View File
@@ -0,0 +1,281 @@
package proxy
import (
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/logger"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"strings"
"time"
)
// SingBoxBridge sing-box 桥接进程
type SingBoxBridge struct {
NodeKey string
Port int
Cmd *exec.Cmd
Pid int
Running bool
LastError string
}
// SingBoxManager sing-box 桥接管理器
type SingBoxManager struct {
Config *config.Config
AppRoot string // 应用根目录,所有相对路径基于此解析
Bridges map[string]*SingBoxBridge
OnBridgeDied func(key string, err error)
}
// NewSingBoxManager 创建 sing-box 管理器
func NewSingBoxManager(cfg *config.Config, appRoot string) *SingBoxManager {
return &SingBoxManager{
Config: cfg,
AppRoot: appRoot,
Bridges: make(map[string]*SingBoxBridge),
}
}
// EnsureBridge 确保 sing-box 桥接进程运行,返回 socks5://127.0.0.1:port
func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, error) {
log := logger.New("SingBox")
src := strings.TrimSpace(proxyConfig)
if proxyId != "" {
for _, item := range proxies {
if strings.EqualFold(item.ProxyId, proxyId) {
src = strings.TrimSpace(item.ProxyConfig)
break
}
}
}
if src == "" {
return "", fmt.Errorf("未找到代理节点")
}
src = normalizeNodeScheme(src)
outbound, err := BuildSingBoxOutbound(src)
if err != nil {
log.Error("节点解析失败", logger.F("error", err))
return "", err
}
key := computeNodeKey(src)
// 复用已有桥接
if bridge, ok := m.Bridges[key]; ok && bridge != nil && bridge.Running {
alive := bridge.Cmd != nil && bridge.Cmd.Process != nil && bridge.Cmd.ProcessState == nil
if alive {
if err := waitPortReady("127.0.0.1", bridge.Port, 800*time.Millisecond); err == nil {
log.Info("复用 sing-box 桥接", logger.F("key", key[:8]), logger.F("port", bridge.Port))
return fmt.Sprintf("socks5://127.0.0.1:%d", bridge.Port), nil
}
}
log.Info("sing-box 桥接已失效,重新启动", logger.F("key", key[:8]))
if bridge.Cmd != nil && bridge.Cmd.Process != nil {
_ = bridge.Cmd.Process.Kill()
}
bridge.Running = false
delete(m.Bridges, key)
}
binaryPath, err := m.resolveBinary()
if err != nil {
log.Error("sing-box 不可用", logger.F("error", err), logger.F("appRoot", m.AppRoot))
return "", err
}
log.Debug("sing-box binary", logger.F("path", binaryPath))
const maxRetries = 3
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
port, err := nextAvailablePort()
if err != nil {
lastErr = err
continue
}
cfgPath, err := m.buildConfig(key, outbound, port)
if err != nil {
return "", fmt.Errorf("sing-box 配置生成失败: %w", err)
}
cmd := exec.Command(binaryPath, "run", "-c", cfgPath)
hideWindow(cmd)
cmd.Dir = filepath.Dir(cfgPath)
stderrPath := filepath.Join(filepath.Dir(cfgPath), "singbox-stderr.log")
stderrFile, _ := os.Create(stderrPath)
if stderrFile != nil {
cmd.Stderr = stderrFile
}
if err := cmd.Start(); err != nil {
if stderrFile != nil {
stderrFile.Close()
}
log.Error("sing-box 启动失败", logger.F("error", err), logger.F("attempt", attempt))
lastErr = err
continue
}
bridge := &SingBoxBridge{
NodeKey: key,
Port: port,
Cmd: cmd,
Pid: cmd.Process.Pid,
Running: true,
}
m.Bridges[key] = bridge
log.Info("sing-box 启动", logger.F("key", key[:8]), logger.F("pid", bridge.Pid), logger.F("port", port))
if err := waitPortReady("127.0.0.1", port, 10*time.Second); err != nil {
if stderrFile != nil {
stderrFile.Close()
}
if content, readErr := os.ReadFile(stderrPath); readErr == nil && len(content) > 0 {
log.Error("sing-box stderr", logger.F("output", string(content)))
}
_ = cmd.Process.Kill()
bridge.Running = false
bridge.LastError = err.Error()
delete(m.Bridges, key)
log.Error("sing-box 端口不可用,重试", logger.F("error", err), logger.F("attempt", attempt))
lastErr = err
time.Sleep(200 * time.Millisecond)
continue
}
if stderrFile != nil {
stderrFile.Close()
}
go func(b *SingBoxBridge, nodeKey string) {
_ = b.Cmd.Wait()
b.Running = false
if m.OnBridgeDied != nil {
m.OnBridgeDied(nodeKey, fmt.Errorf("sing-box 桥接进程意外退出"))
}
}(bridge, key)
return fmt.Sprintf("socks5://127.0.0.1:%d", port), nil
}
return "", fmt.Errorf("sing-box 启动失败(已重试 %d 次): %w", maxRetries, lastErr)
}
// StopAll 关闭所有 sing-box 桥接进程
func (m *SingBoxManager) StopAll() {
for key, bridge := range m.Bridges {
if bridge != nil && bridge.Cmd != nil && bridge.Cmd.Process != nil {
_ = bridge.Cmd.Process.Kill()
}
delete(m.Bridges, key)
}
}
func (m *SingBoxManager) resolveBinary() (string, error) {
configPath := strings.TrimSpace(m.Config.Browser.SingBoxBinaryPath)
if configPath != "" {
resolved := resolveEnvPath(configPath, m.AppRoot)
if resolved != "" {
if _, err := os.Stat(resolved); err == nil {
return resolved, nil
}
}
}
if env := strings.TrimSpace(os.Getenv("SINGBOX_BINARY_PATH")); env != "" {
if _, err := os.Stat(env); err == nil {
return env, nil
}
}
// 优先基于 appRoot 查找 bin/sing-box.exe
if m.AppRoot != "" {
candidate := filepath.Join(m.AppRoot, "bin", "sing-box.exe")
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
// 兜底:exe 目录
if exePath, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(exePath), "bin", "sing-box.exe")
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
if path, err := exec.LookPath("sing-box"); err == nil {
return path, nil
}
if goruntime.GOOS == "windows" {
if path, err := exec.LookPath("sing-box.exe"); err == nil {
return path, nil
}
}
return "", fmt.Errorf("未找到 sing-box.exe。请将 sing-box.exe 放到 bin/ 目录,或在配置中设置 SingBoxBinaryPath")
}
func (m *SingBoxManager) buildConfig(key string, outbound map[string]interface{}, port int) (string, error) {
baseDir := m.resolveWorkdir(key)
if err := os.MkdirAll(baseDir, 0755); err != nil {
return "", err
}
cfg := map[string]interface{}{
"log": map[string]interface{}{
"level": "info",
"output": filepath.Join(baseDir, "singbox.log"),
"timestamp": true,
},
"inbounds": []interface{}{
map[string]interface{}{
"type": "socks",
"tag": "socks-in",
"listen": "127.0.0.1",
"listen_port": port,
},
},
"outbounds": []interface{}{
outbound,
map[string]interface{}{
"type": "direct",
"tag": "direct",
},
},
"route": map[string]interface{}{
"rules": []interface{}{
map[string]interface{}{
"inbound": []string{"socks-in"},
"outbound": "proxy-out",
},
},
},
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return "", err
}
cfgPath := filepath.Join(baseDir, "singbox-config.json")
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
return "", err
}
return cfgPath, nil
}
func (m *SingBoxManager) resolveWorkdir(key string) string {
root := strings.TrimSpace(m.Config.Browser.UserDataRoot)
if root == "" {
root = "data"
}
if !filepath.IsAbs(root) {
if m.AppRoot != "" {
root = filepath.Join(m.AppRoot, root)
} else if exePath, err := os.Executable(); err == nil {
root = filepath.Join(filepath.Dir(exePath), root)
}
}
return filepath.Join(root, "_singbox", key)
}
+248
View File
@@ -0,0 +1,248 @@
package proxy
import (
"fmt"
"net/url"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// IsSingBoxProtocol 判断是否为 sing-box 支持的协议(hysteria2/tuic
func IsSingBoxProtocol(proxyConfig string) bool {
l := strings.ToLower(strings.TrimSpace(proxyConfig))
if strings.HasPrefix(l, "hysteria2://") || strings.HasPrefix(l, "hysteria://") {
return true
}
// 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") {
return true
}
return false
}
// BuildSingBoxOutbound 解析节点配置,返回 sing-box outbound map
func BuildSingBoxOutbound(node string) (map[string]interface{}, error) {
src := strings.TrimSpace(node)
l := strings.ToLower(src)
if strings.HasPrefix(l, "hysteria2://") || strings.HasPrefix(l, "hysteria://") {
return parseHysteria2URI(src)
}
// Clash YAML 格式
if strings.Contains(l, "type:") || strings.Contains(l, "proxies:") {
return parseClashSingBoxNode(src)
}
return nil, fmt.Errorf("不支持的 sing-box 节点格式")
}
// parseHysteria2URI 解析 hysteria2:// URI
// 格式: hysteria2://password@host:port?sni=xxx&insecure=1
func parseHysteria2URI(node string) (map[string]interface{}, error) {
// 统一为 hysteria2://
if strings.HasPrefix(strings.ToLower(node), "hysteria://") {
node = "hysteria2://" + node[len("hysteria://"):]
}
u, err := url.Parse(node)
if err != nil {
return nil, fmt.Errorf("hysteria2 URI 解析失败: %v", err)
}
host := u.Hostname()
portStr := u.Port()
port, _ := strconv.Atoi(portStr)
password := u.User.Username()
if password == "" {
// 有些格式把密码放在 userinfo 里不带 @
password = strings.TrimPrefix(u.Host, "@")
}
q := u.Query()
sni := q.Get("sni")
if sni == "" {
sni = q.Get("peer")
}
insecure := q.Get("insecure") == "1" || strings.ToLower(q.Get("insecure")) == "true"
obfsPassword := q.Get("obfs-password")
if host == "" || port == 0 {
return nil, fmt.Errorf("hysteria2 节点信息不完整: host=%s port=%d", host, port)
}
out := map[string]interface{}{
"type": "hysteria2",
"tag": "proxy-out",
"server": host,
"server_port": port,
"password": password,
"tls": map[string]interface{}{
"enabled": true,
"insecure": insecure,
},
}
if sni != "" {
out["tls"].(map[string]interface{})["server_name"] = sni
}
if obfsPassword != "" {
out["obfs"] = map[string]interface{}{
"type": "salamander",
"password": obfsPassword,
}
}
return out, nil
}
// parseClashSingBoxNode 解析 Clash YAML 格式的 sing-box 节点
func parseClashSingBoxNode(src string) (map[string]interface{}, error) {
// 复用已有的 YAML 解析基础设施
var payload interface{}
if err := yaml.Unmarshal([]byte(src), &payload); err != nil {
return nil, fmt.Errorf("YAML 解析失败: %v", err)
}
nodeMap := pickClashNode(payload)
if nodeMap == nil {
return nil, fmt.Errorf("节点解析失败")
}
nodeType := strings.ToLower(getMapString(nodeMap, "type"))
switch nodeType {
case "hysteria2", "hysteria":
return buildSingBoxHysteria2FromClash(nodeMap)
case "tuic":
return buildSingBoxTUICFromClash(nodeMap)
default:
return nil, fmt.Errorf("不支持的 sing-box 节点类型: %s", nodeType)
}
}
func buildSingBoxHysteria2FromClash(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("hysteria2 节点信息不完整")
}
tls := map[string]interface{}{
"enabled": true,
"insecure": skipVerify,
}
if sni != "" {
tls["server_name"] = sni
}
out := map[string]interface{}{
"type": "hysteria2",
"tag": "proxy-out",
"server": host,
"server_port": port,
"password": password,
"tls": tls,
}
// 带宽限制(可选)
if up := getMapString(node, "up"); up != "" {
out["up_mbps"] = parseBandwidthMbps(up)
}
if down := getMapString(node, "down"); down != "" {
out["down_mbps"] = parseBandwidthMbps(down)
}
// obfs
if obfsPassword := getMapString(node, "obfs-password"); obfsPassword != "" {
out["obfs"] = map[string]interface{}{
"type": "salamander",
"password": obfsPassword,
}
}
return out, nil
}
func buildSingBoxTUICFromClash(node map[string]interface{}) (map[string]interface{}, error) {
host := getMapString(node, "server")
port := getMapInt(node, "port")
uuid := getMapString(node, "uuid")
password := getMapString(node, "password")
sni := getMapString(node, "sni")
skipVerify := getMapBool(node, "skip-cert-verify")
if host == "" || port == 0 {
return nil, fmt.Errorf("tuic 节点信息不完整")
}
tls := map[string]interface{}{
"enabled": true,
"insecure": skipVerify,
}
if sni != "" {
tls["server_name"] = sni
}
// alpn
if alpnRaw, ok := node["alpn"]; ok {
if alpnList := toStringSlice(alpnRaw); len(alpnList) > 0 {
tls["alpn"] = alpnList
}
}
return map[string]interface{}{
"type": "tuic",
"tag": "proxy-out",
"server": host,
"server_port": port,
"uuid": uuid,
"password": password,
"congestion_control": "bbr",
"tls": tls,
}, nil
}
// parseBandwidthMbps 解析带宽字符串,返回 Mbps 整数
// 支持: "100 Mbps", "100", "100M"
func parseBandwidthMbps(s string) int {
s = strings.TrimSpace(s)
s = strings.ToUpper(s)
s = strings.ReplaceAll(s, " ", "")
s = strings.TrimSuffix(s, "BPS")
s = strings.TrimSuffix(s, "B")
s = strings.TrimSuffix(s, "M")
n, _ := strconv.Atoi(s)
return n
}
// toStringSlice 将 interface{} 转为 []string
func toStringSlice(v interface{}) []string {
if v == nil {
return nil
}
if arr, ok := v.([]interface{}); ok {
result := make([]string, 0, len(arr))
for _, item := range arr {
if s, ok := item.(string); ok {
result = append(result, s)
}
}
return result
}
if s, ok := v.(string); ok && s != "" {
return []string{s}
}
return nil
}
+321
View File
@@ -0,0 +1,321 @@
package proxy
import (
"context"
"fmt"
"net"
"net/http"
"net/netip"
"strings"
"time"
"github.com/metacubex/mihomo/adapter"
C "github.com/metacubex/mihomo/constant"
"gopkg.in/yaml.v3"
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/logger"
)
// ─── Clash 标准测速 URL ───
// 使用 HTTP 与 Clash 客户端保持一致
const defaultTestURL = "http://www.gstatic.com/generate_204"
// SpeedTestConfig 测速参数
type SpeedTestConfig struct {
Timeout time.Duration
TCPTimeout time.Duration
URLs []string
}
var DefaultSpeedTestConfig = SpeedTestConfig{
Timeout: 10 * time.Second,
TCPTimeout: 5 * time.Second,
}
// ─── 对外入口 ───
// SpeedTest 使用 mihomo 代理适配器进行测速。
// 采用 unified-delay 策略:先建立连接(预热),再单独计时 HTTP 往返,
// 与 Clash 客户端 unified-delay: true 的延迟结果一致。
func SpeedTest(
proxyId string,
proxies []config.BrowserProxy,
xrayMgr *XrayManager,
singboxMgr *SingBoxManager,
cfg *SpeedTestConfig,
) TestResult {
log := logger.New("SpeedTest")
if cfg == nil {
c := DefaultSpeedTestConfig
cfg = &c
}
// 查找代理配置
src := ""
for _, item := range proxies {
if strings.EqualFold(item.ProxyId, proxyId) {
src = strings.TrimSpace(item.ProxyConfig)
break
}
}
if src == "" {
return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"}
}
if strings.ToLower(src) == "direct://" {
return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: 0}
}
testURL := defaultTestURL
if len(cfg.URLs) > 0 {
testURL = cfg.URLs[0]
}
// 将代理配置转换为 mihomo mapping
mapping, err := proxyConfigToMapping(src)
if err != nil {
log.Warn("代理配置解析失败,降级到 TCP ping",
logger.F("proxy_id", proxyId),
logger.F("error", err.Error()),
)
return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log)
}
// 使用 mihomo adapter.ParseProxy 创建代理实例
proxyInstance, err := adapter.ParseProxy(mapping)
if err != nil {
log.Warn("mihomo 代理创建失败,降级到 TCP ping",
logger.F("proxy_id", proxyId),
logger.F("error", err.Error()),
logger.F("type", mapping["type"]),
)
return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log)
}
// unified-delay 测速:分离连接建立和 HTTP 往返计时
return unifiedDelayTest(proxyId, proxyInstance, testURL, cfg.Timeout)
}
// unifiedDelayTest 模拟 Clash unified-delay 模式:
// 1. 通过代理建立到目标的 TCP 连接(预热,不计入延迟)
// 2. 发送第一次 HTTP 请求预热连接(不计入延迟)
// 3. 在已建立的连接上发送第二次 HTTP 请求,只计这次的 RTT
// 这样测出的延迟 = 纯 HTTP 往返时间,和 Clash unified-delay: true 一致。
func unifiedDelayTest(proxyId string, px C.Proxy, testURL string, timeout time.Duration) TestResult {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// 解析目标地址
addr, err := urlToMeta(testURL)
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("URL 解析失败: %v", err)}
}
// 步骤 1:通过代理 DialContext 建立连接(预热)
conn, err := px.DialContext(ctx, &addr)
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("代理连接失败: %v", err)}
}
defer conn.Close()
// 构造复用此连接的 HTTP client
transport := &http.Transport{
DialContext: func(context.Context, string, string) (net.Conn, error) {
return conn, nil
},
DisableKeepAlives: false,
}
client := &http.Client{
Transport: transport,
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
defer client.CloseIdleConnections()
// 步骤 2:第一次请求预热(不计时)
req1, _ := http.NewRequestWithContext(ctx, http.MethodHead, testURL, nil)
resp1, err := client.Do(req1)
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, Error: err.Error()}
}
resp1.Body.Close()
// 步骤 3:第二次请求计时(纯 HTTP RTT)
start := time.Now()
req2, _ := http.NewRequestWithContext(ctx, http.MethodHead, testURL, nil)
resp2, err := client.Do(req2)
latency := time.Since(start).Milliseconds()
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: err.Error()}
}
resp2.Body.Close()
if resp2.StatusCode != http.StatusOK && resp2.StatusCode != http.StatusNoContent {
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency,
Error: fmt.Sprintf("HTTP %d", resp2.StatusCode)}
}
return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency}
}
// urlToMeta 将 URL 转换为 mihomo Metadata
func urlToMeta(rawURL string) (C.Metadata, error) {
var host string
var portNum uint16
if strings.HasPrefix(rawURL, "https://") {
host = rawURL[len("https://"):]
portNum = 443
} else if strings.HasPrefix(rawURL, "http://") {
host = rawURL[len("http://"):]
portNum = 80
} else {
return C.Metadata{}, fmt.Errorf("不支持的 URL scheme")
}
// 去掉 path
if idx := strings.Index(host, "/"); idx >= 0 {
host = host[:idx]
}
// 检查是否有自定义端口
if h, p, err := net.SplitHostPort(host); err == nil {
host = h
fmt.Sscanf(p, "%d", &portNum)
}
meta := C.Metadata{
Host: host,
DstPort: portNum,
}
if addr, err := netip.ParseAddr(host); err == nil {
meta.DstIP = addr
}
return meta, nil
}
// ─── 代理配置转换为 mihomo mapping ───
func proxyConfigToMapping(src string) (map[string]any, error) {
src = strings.TrimSpace(src)
l := strings.ToLower(src)
// http/https 直连代理
if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") {
return parseStandardProxy(src, "http")
}
// socks5 直连代理
if strings.HasPrefix(l, "socks5://") {
return parseStandardProxy(src, "socks5")
}
// URI 格式(vmess:// vless:// 等)暂不支持直接转 mapping,降级
if strings.Contains(l, "://") && !strings.Contains(l, "type:") {
return nil, fmt.Errorf("URI 格式暂不支持: %s", l[:min(30, len(l))])
}
// Clash YAML 格式 → 直接解析
return parseClashYAMLToMapping(src)
}
func parseStandardProxy(src string, proxyType string) (map[string]any, error) {
rest := src[strings.Index(src, "://")+3:]
var username, password, hostport string
if atIdx := strings.LastIndex(rest, "@"); atIdx >= 0 {
userInfo := rest[:atIdx]
hostport = rest[atIdx+1:]
parts := strings.SplitN(userInfo, ":", 2)
username = parts[0]
if len(parts) > 1 {
password = parts[1]
}
} else {
hostport = rest
}
hostport = strings.SplitN(hostport, "/", 2)[0]
host, port := splitHostPort(hostport)
if host == "" || port == 0 {
return nil, fmt.Errorf("无法解析地址: %s", src)
}
mapping := map[string]any{
"name": "speedtest-proxy",
"type": proxyType,
"server": host,
"port": port,
}
if username != "" {
mapping["username"] = username
mapping["password"] = password
}
return mapping, nil
}
func parseClashYAMLToMapping(src string) (map[string]any, error) {
var payload interface{}
if err := yaml.Unmarshal([]byte(src), &payload); err != nil {
return nil, fmt.Errorf("YAML 解析失败: %v", err)
}
node := pickClashNode(payload)
if node == nil {
return nil, fmt.Errorf("无法提取 Clash 节点")
}
if _, ok := node["name"]; !ok {
node["name"] = "speedtest-proxy"
}
return node, nil
}
func splitHostPort(hostport string) (string, int) {
if strings.HasPrefix(hostport, "[") {
if idx := strings.LastIndex(hostport, "]:"); idx >= 0 {
host := hostport[1:idx]
port := 0
fmt.Sscanf(hostport[idx+2:], "%d", &port)
return host, port
}
return strings.Trim(hostport, "[]"), 0
}
idx := strings.LastIndex(hostport, ":")
if idx < 0 {
return hostport, 0
}
host := hostport[:idx]
port := 0
fmt.Sscanf(hostport[idx+1:], "%d", &port)
return host, port
}
// ─── TCP Ping 降级 ───
func tcpPingFallback(proxyId, src string, timeout time.Duration, log *logger.Logger) TestResult {
endpoint, err := proxyEndpoint(src)
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("无法解析代理地址: %v", err)}
}
start := time.Now()
conn, err := net.DialTimeout("tcp", endpoint, timeout)
latency := time.Since(start).Milliseconds()
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: fmt.Sprintf("TCP 连接失败: %v", err)}
}
conn.Close()
return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !windows
// +build !windows
package proxy
import "os/exec"
func hideWindow(cmd *exec.Cmd) {
// do nothing on non-windows platforms
}
+13
View File
@@ -0,0 +1,13 @@
//go:build windows
// +build windows
package proxy
import (
"os/exec"
"syscall"
)
func hideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
}
+25
View File
@@ -0,0 +1,25 @@
package proxy
import (
"os/exec"
"time"
)
// XrayBridge Xray 桥接进程
type XrayBridge struct {
NodeKey string
Port int
Cmd *exec.Cmd
Pid int
Running bool
LastError string
RefCount int
LastUsedAt time.Time
Stopping bool
}
// ProxyResult 代理解析结果
type ProxyResult struct {
StandardProxy string // 标准代理 URL (http/socks5)
Outbound map[string]interface{} // Xray outbound 配置
}
+309
View File
@@ -0,0 +1,309 @@
package proxy
import (
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"ant-chrome/backend/internal/config"
xproxy "golang.org/x/net/proxy"
"gopkg.in/yaml.v3"
)
// TestResult 代理测试结果
type TestResult struct {
ProxyId string
Ok bool
LatencyMs int64
Error string
}
// proxyEndpoint 从代理配置中提取 server:port,用于 TCP ping
func proxyEndpoint(src string) (string, error) {
src = strings.TrimSpace(src)
l := strings.ToLower(src)
// 标准 URL 格式: socks5://host:port, http://host:port
if strings.HasPrefix(l, "socks5://") || strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") {
hostport := src[strings.Index(src, "//")+2:]
hostport = strings.SplitN(hostport, "/", 2)[0]
return hostport, nil
}
// vmess:// URL (base64 encoded JSON)
if strings.HasPrefix(l, "vmess://") {
raw := strings.TrimPrefix(src, "vmess://")
decoded, err := decodeBase64String(strings.TrimSpace(raw))
if err == nil {
var v struct {
Add string `json:"add"`
Port interface{} `json:"port"`
}
if jsonErr := json.Unmarshal(decoded, &v); jsonErr == nil && v.Add != "" {
return fmt.Sprintf("%s:%v", v.Add, v.Port), nil
}
}
}
// vless:// URL: vless://uuid@host:port?...
if strings.HasPrefix(l, "vless://") {
rest := src[len("vless://"):]
if at := strings.LastIndex(rest, "@"); at >= 0 {
hostport := strings.SplitN(rest[at+1:], "?", 2)[0]
hostport = strings.SplitN(hostport, "#", 2)[0]
return hostport, nil
}
}
// Clash YAML 格式
var payload interface{}
if err := yaml.Unmarshal([]byte(src), &payload); err == nil {
node := pickClashNode(payload)
if node != nil {
server := getMapString(node, "server")
port := getMapInt(node, "port")
if server != "" && port > 0 {
return fmt.Sprintf("%s:%d", server, port), nil
}
}
}
return "", fmt.Errorf("无法解析代理地址")
}
// TestConnectivity 通过 TCP 握手测试代理服务器的可达性和延迟
// 直接对 server:port 建立 TCP 连接测量 RTT,无需启动外部进程
func TestConnectivity(proxyId string, proxyConfig string, proxies []config.BrowserProxy, _ interface{}) TestResult {
src := strings.TrimSpace(proxyConfig)
if proxyId != "" {
for _, item := range proxies {
if strings.EqualFold(item.ProxyId, proxyId) {
src = strings.TrimSpace(item.ProxyConfig)
break
}
}
}
if src == "" {
return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"}
}
endpoint, err := proxyEndpoint(src)
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("地址解析失败: %v", err)}
}
start := time.Now()
conn, err := net.DialTimeout("tcp", endpoint, 10*time.Second)
latency := time.Since(start).Milliseconds()
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: err.Error()}
}
conn.Close()
return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency}
}
func toStringMap(input interface{}) map[string]interface{} {
switch v := input.(type) {
case map[string]interface{}:
return v
case map[interface{}]interface{}:
out := map[string]interface{}{}
for k, val := range v {
out[fmt.Sprint(k)] = val
}
return out
}
return nil
}
func getMapString(m map[string]interface{}, key string) string {
v, ok := m[key]
if !ok {
return ""
}
switch s := v.(type) {
case string:
return strings.TrimSpace(s)
case int:
return strconv.Itoa(s)
case int64:
return strconv.FormatInt(s, 10)
case float64:
return strconv.Itoa(int(s))
case bool:
if s {
return "true"
}
return "false"
}
return strings.TrimSpace(fmt.Sprint(v))
}
func getMapInt(m map[string]interface{}, key string) int {
v, ok := m[key]
if !ok {
return 0
}
switch s := v.(type) {
case int:
return s
case int64:
return int(s)
case float64:
return int(s)
case string:
value, _ := strconv.Atoi(s)
return value
}
return 0
}
func getMapBool(m map[string]interface{}, key string) bool {
v, ok := m[key]
if !ok {
return false
}
switch s := v.(type) {
case bool:
return s
case string:
return strings.ToLower(s) == "true"
case int:
return s != 0
case float64:
return int(s) != 0
}
return false
}
func decodeBase64String(raw string) ([]byte, error) {
if raw == "" {
return nil, fmt.Errorf("base64 内容为空")
}
if data, err := base64.StdEncoding.DecodeString(raw); err == nil {
return data, nil
}
if data, err := base64.RawStdEncoding.DecodeString(raw); err == nil {
return data, nil
}
if data, err := base64.URLEncoding.DecodeString(raw); err == nil {
return data, nil
}
if data, err := base64.RawURLEncoding.DecodeString(raw); err == nil {
return data, nil
}
return nil, fmt.Errorf("base64 解析失败")
}
// isUnsupportedProtocol 判断是否为不支持的协议(hysteria/hysteria2
func isUnsupportedProtocol(src string) bool {
l := strings.ToLower(strings.TrimSpace(src))
return strings.HasPrefix(l, "hysteria://") || strings.HasPrefix(l, "hysteria2://")
}
// TestRealConnectivity 通过代理链路发起真实 HTTP 请求测量端到端延迟。
// - DirectProxy (http/https/socks5):直接通过该代理发送请求
// - BridgeProxy (vmess/vless/Clash):调用 EnsureBridge 获取 socks5 地址后发送请求
// - SingBoxProxy (hysteria2/tuic):调用 SingBoxManager.EnsureBridge 后发送请求
func TestRealConnectivity(
proxyId string,
proxies []config.BrowserProxy,
xrayMgr *XrayManager,
) TestResult {
return TestRealConnectivityWithSingBox(proxyId, proxies, xrayMgr, nil)
}
// TestRealConnectivityWithSingBox 支持 sing-box 的真实连通性测试
func TestRealConnectivityWithSingBox(
proxyId string,
proxies []config.BrowserProxy,
xrayMgr *XrayManager,
singboxMgr *SingBoxManager,
) TestResult {
src := ""
for _, item := range proxies {
if strings.EqualFold(item.ProxyId, proxyId) {
src = strings.TrimSpace(item.ProxyConfig)
break
}
}
if src == "" {
return TestResult{ProxyId: proxyId, Ok: false, Error: "代理配置为空"}
}
const targetURL = "http://www.gstatic.com/generate_204"
const timeout = 15 * time.Second
var client *http.Client
if IsSingBoxProtocol(src) {
// hysteria2/tuic → sing-box 桥接
if singboxMgr == nil {
return TestResult{ProxyId: proxyId, Ok: false, Error: "sing-box 管理器未初始化,无法测试 hysteria2"}
}
socks5Addr, err := singboxMgr.EnsureBridge(src, proxies, proxyId)
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("sing-box 桥接启动失败: %v", err)}
}
socks5Host := strings.TrimPrefix(socks5Addr, "socks5://")
dialer, err := xproxy.SOCKS5("tcp", socks5Host, nil, xproxy.Direct)
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("SOCKS5 dialer 创建失败: %v", err)}
}
contextDialer, ok := dialer.(xproxy.ContextDialer)
if !ok {
return TestResult{ProxyId: proxyId, Ok: false, Error: "SOCKS5 dialer 不支持 ContextDialer"}
}
transport := &http.Transport{DialContext: contextDialer.DialContext}
client = &http.Client{Transport: transport, Timeout: timeout}
} else if RequiresBridge(src, proxies, proxyId) {
// BridgeProxy:通过 xray socks5 桥接
if xrayMgr == nil {
return TestResult{ProxyId: proxyId, Ok: false, Error: "xray 管理器未初始化"}
}
socks5Addr, err := xrayMgr.EnsureBridge(src, proxies, proxyId)
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("桥接启动失败: %v", err)}
}
// 解析 socks5://127.0.0.1:port
socks5Host := strings.TrimPrefix(socks5Addr, "socks5://")
dialer, err := xproxy.SOCKS5("tcp", socks5Host, nil, xproxy.Direct)
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("SOCKS5 dialer 创建失败: %v", err)}
}
contextDialer, ok := dialer.(xproxy.ContextDialer)
if !ok {
return TestResult{ProxyId: proxyId, Ok: false, Error: "SOCKS5 dialer 不支持 ContextDialer"}
}
transport := &http.Transport{DialContext: contextDialer.DialContext}
client = &http.Client{Transport: transport, Timeout: timeout}
} else {
// DirectProxyhttp/https/socks5 直接代理
proxyURL, err := url.Parse(src)
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, Error: fmt.Sprintf("代理地址解析失败: %v", err)}
}
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
client = &http.Client{Transport: transport, Timeout: timeout}
}
start := time.Now()
resp, err := client.Get(targetURL)
latency := time.Since(start).Milliseconds()
if err != nil {
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: err.Error()}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
return TestResult{ProxyId: proxyId, Ok: false, LatencyMs: latency, Error: fmt.Sprintf("HTTP %d", resp.StatusCode)}
}
return TestResult{ProxyId: proxyId, Ok: true, LatencyMs: latency}
}
+725
View File
@@ -0,0 +1,725 @@
package proxy
import (
"ant-chrome/backend/internal/config"
"ant-chrome/backend/internal/logger"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"strconv"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
const (
xrayBridgeIdleTTL = 45 * time.Second
xrayBridgeCleanupInterval = 15 * time.Second
)
// XrayManager Xray 桥接管理器
type XrayManager struct {
Config *config.Config
AppRoot string // 应用根目录,所有相对路径基于此解析
Bridges map[string]*XrayBridge
OnBridgeDied func(key string, err error) // 桥接进程意外退出回调
mu sync.Mutex
stopCh chan struct{}
stopOnce sync.Once
}
// NewXrayManager 创建 Xray 管理器
func NewXrayManager(cfg *config.Config, appRoot string) *XrayManager {
manager := &XrayManager{
Config: cfg,
AppRoot: appRoot,
Bridges: make(map[string]*XrayBridge),
stopCh: make(chan struct{}),
}
go manager.cleanupLoop()
return manager
}
// ValidateProxyConfig 验证代理配置是否支持
// 返回: supported bool, errorMsg string
func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (bool, string) {
src := strings.TrimSpace(proxyConfig)
found := false
if proxyId != "" {
for _, item := range proxies {
if strings.EqualFold(item.ProxyId, proxyId) {
src = strings.TrimSpace(item.ProxyConfig)
found = true
break
}
}
if !found {
return false, fmt.Sprintf("代理链路不可用:代理池节点已不存在(proxyId=%s)。可能因订阅刷新后节点下线或被删除,请重新选择代理后再启动。", proxyId)
}
}
if src == "" {
return true, "" // 无代理配置,允许启动
}
if strings.EqualFold(src, "direct://") {
return true, ""
}
l := strings.ToLower(src)
// 标准代理格式,支持
if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "socks5://") {
return true, ""
}
// hysteria2/tuic 通过 sing-box 支持,先做可解析性校验
if IsSingBoxProtocol(src) {
if _, err := BuildSingBoxOutbound(src); err != nil {
return false, fmt.Sprintf("代理配置解析失败: %v", err)
}
return true, ""
}
// 其余协议交给统一解析器校验,防止无效字符串被当成代理参数透传给 Chrome
standardProxy, outbound, err := ParseProxyNode(src)
if err != nil {
return false, fmt.Sprintf("代理配置解析失败: %v", err)
}
if strings.TrimSpace(standardProxy) == "" && outbound == nil {
return false, "代理配置无效"
}
return true, ""
}
// RequiresBridge 判断是否需要 Xray 桥接
// 注意: Xray 仅支持 vless/vmess/trojan/shadowsocks 等协议
// hysteria2 不支持,需要使用 Hysteria 客户端或 sing-box
func RequiresBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) bool {
src := strings.TrimSpace(proxyConfig)
if proxyId != "" {
for _, item := range proxies {
if strings.EqualFold(item.ProxyId, proxyId) {
src = strings.TrimSpace(item.ProxyConfig)
break
}
}
}
if src == "" {
return false
}
l := strings.ToLower(src)
// 标准代理格式,不需要桥接
if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "socks5://") {
return false
}
// hysteria2 Xray 不支持,不触发桥接
if strings.HasPrefix(l, "hysteria://") || strings.HasPrefix(l, "hysteria2://") {
return false
}
// Xray 支持的协议
if strings.HasPrefix(l, "vmess://") || strings.HasPrefix(l, "vless://") || strings.HasPrefix(l, "trojan://") || strings.HasPrefix(l, "ss://") {
return true
}
// Clash 格式需要进一步检查类型
if strings.HasPrefix(l, "clash://") || strings.Contains(l, "type:") || strings.Contains(l, "proxies:") {
// 排除 hysteria 类型
if strings.Contains(l, "type: hysteria") || strings.Contains(l, "type:hysteria") {
return false
}
return true
}
return false
}
// EnsureBridge 确保 Xray 桥接进程运行,用于临时请求场景。
func (m *XrayManager) EnsureBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, error) {
socksURL, _, err := m.ensureBridge(proxyConfig, proxies, proxyId, false)
return socksURL, err
}
// AcquireBridge 获取一个带引用计数的 Xray 桥接,用于浏览器实例等长生命周期场景。
func (m *XrayManager) AcquireBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string) (string, string, error) {
return m.ensureBridge(proxyConfig, proxies, proxyId, true)
}
// ReleaseBridge 释放一个已占用的桥接引用;空闲桥接会由后台回收协程延迟清理。
func (m *XrayManager) ReleaseBridge(key string) {
key = strings.TrimSpace(key)
if key == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
bridge, ok := m.Bridges[key]
if !ok || bridge == nil {
return
}
if bridge.RefCount > 0 {
bridge.RefCount--
}
bridge.LastUsedAt = time.Now()
}
// StopAll 关闭所有 xray 桥接进程。
func (m *XrayManager) StopAll() {
m.stopOnce.Do(func() {
close(m.stopCh)
})
m.mu.Lock()
bridges := make([]*XrayBridge, 0, len(m.Bridges))
for key, bridge := range m.Bridges {
if bridge != nil {
bridge.Stopping = true
bridges = append(bridges, bridge)
}
delete(m.Bridges, key)
}
m.mu.Unlock()
for _, bridge := range bridges {
m.stopBridgeProcess(bridge)
}
}
func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId string, pin bool) (string, string, error) {
log := logger.New("Xray")
src := strings.TrimSpace(proxyConfig)
dnsServers := ""
if proxyId != "" {
for _, item := range proxies {
if strings.EqualFold(item.ProxyId, proxyId) {
src = strings.TrimSpace(item.ProxyConfig)
dnsServers = item.DnsServers
break
}
}
}
if src == "" {
return "", "", fmt.Errorf("未找到代理节点")
}
src = normalizeNodeScheme(src)
standardProxy, outbound, err := ParseProxyNode(src)
if err != nil {
log.Error("节点解析失败", logger.F("error", err))
return "", "", err
}
if standardProxy != "" {
return standardProxy, "", nil
}
if outbound == nil {
return "", "", fmt.Errorf("节点解析失败")
}
key := computeNodeKey(src + "\x00" + dnsServers)
if socksURL, reused := m.tryReuseBridge(key, pin); reused {
log.Info("复用桥接进程", logger.F("key", key), logger.F("socks_url", socksURL))
return socksURL, key, nil
}
binaryPath, err := m.resolveBinary()
if err != nil {
log.Error("xray 不可用", logger.F("error", err))
return "", "", err
}
// 最多重试 3 次,解决端口分配后被抢占的 TOCTOU 竞争问题
const maxLaunchRetries = 3
var lastErr error
for attempt := 1; attempt <= maxLaunchRetries; attempt++ {
port, err := nextAvailablePort()
if err != nil {
log.Error("端口分配失败", logger.F("error", err), logger.F("attempt", attempt))
lastErr = err
continue
}
cfgPath, err := m.buildRuntimeConfig(key, outbound, port, dnsServers)
if err != nil {
log.Error("xray 配置生成失败", logger.F("error", err))
return "", "", err
}
cmd := exec.Command(binaryPath, "run", "-c", cfgPath)
hideWindow(cmd)
cmd.Dir = filepath.Dir(cfgPath)
stderrPath := filepath.Join(filepath.Dir(cfgPath), "xray-stderr.log")
stderrFile, _ := os.Create(stderrPath)
if stderrFile != nil {
cmd.Stderr = stderrFile
}
if err := cmd.Start(); err != nil {
if stderrFile != nil {
stderrFile.Close()
}
log.Error("xray 启动失败", logger.F("error", err), logger.F("attempt", attempt))
lastErr = err
continue
}
bridge := &XrayBridge{
NodeKey: key,
Port: port,
Cmd: cmd,
Pid: cmd.Process.Pid,
Running: true,
RefCount: 0,
LastUsedAt: time.Now(),
}
log.Info("xray 启动", logger.F("key", key), logger.F("pid", bridge.Pid), logger.F("port", bridge.Port), logger.F("attempt", attempt))
if err := waitPortReady("127.0.0.1", port, 10*time.Second); err != nil {
if stderrFile != nil {
stderrFile.Close()
}
// 优先读 stderr,再读 xray-error.log
if stderrContent, readErr := os.ReadFile(stderrPath); readErr == nil && len(stderrContent) > 0 {
log.Error("xray stderr", logger.F("output", string(stderrContent)))
} else {
errLogPath := filepath.Join(filepath.Dir(cfgPath), "xray-error.log")
if errContent, readErr := os.ReadFile(errLogPath); readErr == nil && len(errContent) > 0 {
log.Error("xray error.log", logger.F("output", string(errContent)))
}
}
bridge.Stopping = true
m.stopBridgeProcess(bridge)
bridge.Running = false
bridge.Pid = 0
bridge.LastError = err.Error()
log.Error("xray 端口不可用,重试", logger.F("key", key), logger.F("error", err), logger.F("port", port), logger.F("attempt", attempt))
lastErr = err
// 等待一下再重试,给 OS 时间回收端口
time.Sleep(200 * time.Millisecond)
continue
}
if stderrFile != nil {
stderrFile.Close()
}
if socksURL, reused := m.registerBridge(key, bridge, pin); reused {
log.Info("复用已就绪桥接进程", logger.F("key", key), logger.F("socks_url", socksURL))
bridge.Stopping = true
m.stopBridgeProcess(bridge)
return socksURL, key, nil
}
go m.watchBridge(bridge, key)
return fmt.Sprintf("socks5://127.0.0.1:%d", port), key, nil
}
return "", "", fmt.Errorf("xray 启动失败(已重试 %d 次): %w", maxLaunchRetries, lastErr)
}
func (m *XrayManager) tryReuseBridge(key string, pin bool) (string, bool) {
var stale *XrayBridge
m.mu.Lock()
if bridge, ok := m.Bridges[key]; ok && bridge != nil {
alive := bridge.Running && bridge.Cmd != nil && bridge.Cmd.Process != nil && bridge.Cmd.ProcessState == nil
if alive && waitPortReady("127.0.0.1", bridge.Port, 800*time.Millisecond) == nil {
if pin {
bridge.RefCount++
}
bridge.LastUsedAt = time.Now()
socksURL := fmt.Sprintf("socks5://127.0.0.1:%d", bridge.Port)
m.mu.Unlock()
return socksURL, true
}
bridge.Stopping = true
stale = bridge
delete(m.Bridges, key)
}
m.mu.Unlock()
if stale != nil {
m.stopBridgeProcess(stale)
}
return "", false
}
func (m *XrayManager) registerBridge(key string, bridge *XrayBridge, pin bool) (string, bool) {
var duplicate *XrayBridge
m.mu.Lock()
if existing, ok := m.Bridges[key]; ok && existing != nil {
alive := existing.Running && existing.Cmd != nil && existing.Cmd.Process != nil && existing.Cmd.ProcessState == nil
if alive && waitPortReady("127.0.0.1", existing.Port, 800*time.Millisecond) == nil {
if pin {
existing.RefCount++
}
existing.LastUsedAt = time.Now()
duplicate = bridge
socksURL := fmt.Sprintf("socks5://127.0.0.1:%d", existing.Port)
m.mu.Unlock()
if duplicate != nil {
duplicate.Stopping = true
m.stopBridgeProcess(duplicate)
}
return socksURL, true
}
existing.Stopping = true
delete(m.Bridges, key)
duplicate = existing
}
if pin {
bridge.RefCount = 1
}
bridge.LastUsedAt = time.Now()
m.Bridges[key] = bridge
m.mu.Unlock()
if duplicate != nil {
m.stopBridgeProcess(duplicate)
}
return "", false
}
func (m *XrayManager) watchBridge(bridge *XrayBridge, key string) {
if bridge == nil || bridge.Cmd == nil {
return
}
_ = bridge.Cmd.Wait()
m.mu.Lock()
if current, ok := m.Bridges[key]; ok && current == bridge {
delete(m.Bridges, key)
}
bridge.Running = false
stopping := bridge.Stopping
m.mu.Unlock()
if !stopping && m.OnBridgeDied != nil {
m.OnBridgeDied(key, fmt.Errorf("xray 桥接进程意外退出"))
}
}
func (m *XrayManager) cleanupLoop() {
ticker := time.NewTicker(xrayBridgeCleanupInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.recycleIdleBridges()
case <-m.stopCh:
return
}
}
}
func (m *XrayManager) recycleIdleBridges() {
now := time.Now()
var stale []*XrayBridge
m.mu.Lock()
for key, bridge := range m.Bridges {
if bridge == nil {
delete(m.Bridges, key)
continue
}
if bridge.RefCount > 0 {
continue
}
if now.Sub(bridge.LastUsedAt) < xrayBridgeIdleTTL {
continue
}
bridge.Stopping = true
stale = append(stale, bridge)
delete(m.Bridges, key)
}
m.mu.Unlock()
if len(stale) == 0 {
return
}
log := logger.New("Xray")
for _, bridge := range stale {
log.Info("回收空闲桥接进程", logger.F("key", bridge.NodeKey), logger.F("pid", bridge.Pid))
m.stopBridgeProcess(bridge)
}
}
func (m *XrayManager) stopBridgeProcess(bridge *XrayBridge) {
if bridge == nil || bridge.Cmd == nil || bridge.Cmd.Process == nil {
return
}
_ = bridge.Cmd.Process.Kill()
}
func (m *XrayManager) resolveBinary() (string, error) {
configPath := strings.TrimSpace(m.Config.Browser.XrayBinaryPath)
if configPath != "" {
resolved := resolveEnvPath(configPath, m.AppRoot)
if resolved != "" {
if _, err := os.Stat(resolved); err == nil {
return resolved, nil
}
}
}
env := strings.TrimSpace(os.Getenv("XRAY_BINARY_PATH"))
if env != "" {
if _, err := os.Stat(env); err == nil {
return env, nil
}
}
// 优先基于 appRoot 查找 bin/xray.exe
if m.AppRoot != "" {
candidate := filepath.Join(m.AppRoot, "bin", "xray.exe")
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
// 兜底:exe 目录
if exePath, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(exePath), "bin", "xray.exe")
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
if path, err := exec.LookPath("xray"); err == nil {
return path, nil
}
if goruntime.GOOS == "windows" {
if path, err := exec.LookPath("xray.exe"); err == nil {
return path, nil
}
}
return "", fmt.Errorf("未找到 xray.exe。请将 xray.exe 放到 bin/ 目录,或在配置中设置 XrayBinaryPath")
}
// parseDnsConfig 解析 DNS 配置,支持两种格式:
// 1. Clash dns: YAML 块(含 nameserver/fallback 等字段)
// 2. 逗号分隔的 IP 列表(兼容旧格式)
// 返回 xray dns 配置 map,若无有效配置则返回 nil
//
// 注意:xray dns.servers 只支持纯 IP 或 DoHhttps://)地址,
// 不支持 Clash 的 tls:// 格式(DoT),会被自动过滤。
func parseDnsConfig(raw string) map[string]interface{} {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
// 尝试解析 Clash dns: YAML 块
type clashDns struct {
Enable bool `yaml:"enable"`
Nameserver []string `yaml:"nameserver"`
Fallback []string `yaml:"fallback"`
}
type clashDnsWrapper struct {
Dns clashDns `yaml:"dns"`
}
var wrapper clashDnsWrapper
if err := yaml.Unmarshal([]byte(raw), &wrapper); err == nil && len(wrapper.Dns.Nameserver) > 0 {
servers := make([]interface{}, 0)
for _, s := range wrapper.Dns.Nameserver {
if s = strings.TrimSpace(s); s != "" && isXrayDnsAddr(s) {
servers = append(servers, s)
}
}
for _, s := range wrapper.Dns.Fallback {
if s = strings.TrimSpace(s); s != "" && isXrayDnsAddr(s) {
servers = append(servers, s)
}
}
if len(servers) > 0 {
return map[string]interface{}{"servers": servers}
}
}
// 兼容旧格式:逗号分隔的 IP 列表
var result []string
for _, s := range strings.Split(raw, ",") {
if s = strings.TrimSpace(s); s != "" && isXrayDnsAddr(s) {
result = append(result, s)
}
}
if len(result) > 0 {
servers := make([]interface{}, len(result))
for i, s := range result {
servers[i] = s
}
return map[string]interface{}{"servers": servers}
}
return nil
}
// isXrayDnsAddr 判断 DNS 地址是否为 xray 支持的格式。
// xray 支持:纯 IP(如 8.8.8.8)、IP:port(如 8.8.8.8:53)、
// DoHhttps://...)、localhost。
// 不支持:Clash 的 tls:// 格式(DoT)。
func isXrayDnsAddr(s string) bool {
l := strings.ToLower(s)
if strings.HasPrefix(l, "tls://") {
return false
}
return true
}
func (m *XrayManager) buildRuntimeConfig(key string, outbound map[string]interface{}, port int, dnsServers string) (string, error) {
baseDir := m.resolveWorkdir(key)
if err := os.MkdirAll(baseDir, 0755); err != nil {
return "", err
}
cfgPath := filepath.Join(baseDir, "xray-config.json")
cfg := map[string]interface{}{
"log": map[string]interface{}{
"loglevel": "info",
"error": filepath.Join(baseDir, "xray-error.log"),
},
"inbounds": []interface{}{
map[string]interface{}{
"tag": "socks-in",
"port": port,
"listen": "127.0.0.1",
"protocol": "socks",
"settings": map[string]interface{}{
"udp": true,
},
"sniffing": map[string]interface{}{
"enabled": false,
},
},
},
"outbounds": []interface{}{
outbound,
map[string]interface{}{
"protocol": "direct",
"tag": "direct",
},
map[string]interface{}{
"protocol": "blackhole",
"tag": "block",
},
},
"routing": map[string]interface{}{
"rules": []interface{}{
map[string]interface{}{
"type": "field",
"inboundTag": []string{"socks-in"},
"outboundTag": "proxy-out",
},
},
},
}
if dnsCfg := parseDnsConfig(dnsServers); dnsCfg != nil {
cfg["dns"] = dnsCfg
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return "", err
}
if err := os.WriteFile(cfgPath, data, 0644); err != nil {
return "", err
}
return cfgPath, nil
}
func (m *XrayManager) resolveWorkdir(key string) string {
root := strings.TrimSpace(m.Config.Browser.UserDataRoot)
if root == "" {
root = "data"
}
if !filepath.IsAbs(root) {
if m.AppRoot != "" {
root = filepath.Join(m.AppRoot, root)
} else if exePath, err := os.Executable(); err == nil {
root = filepath.Join(filepath.Dir(exePath), root)
}
}
return filepath.Join(root, "_xray", key)
}
func computeNodeKey(src string) string {
h := sha256.Sum256([]byte(strings.TrimSpace(src)))
return hex.EncodeToString(h[:])
}
func normalizeNodeScheme(src string) string {
s := strings.TrimSpace(src)
if strings.HasPrefix(strings.ToLower(s), "hysteria://") {
return "hysteria2://" + strings.TrimPrefix(s, "hysteria://")
}
return s
}
func resolveEnvPath(path string, appRoot string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if filepath.IsAbs(path) {
return path
}
// 优先基于 appRoot 解析
if appRoot != "" {
candidate := filepath.Join(appRoot, path)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
// 兜底:exe 目录
if exePath, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(exePath), path)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
// 兜底:CWD
if cwd, err := os.Getwd(); err == nil {
candidate := filepath.Join(cwd, path)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return path
}
func waitPortReady(host string, port int, timeout time.Duration) error {
addr := net.JoinHostPort(host, strconv.Itoa(port))
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("端口 %d 不可用", port)
}
// nextAvailablePort 分配一个可用端口。
// 采用二次验证策略:分配后立即再次绑定确认未被其他进程抢占,
// 并在 EnsureBridge 层面加重试,彻底消除 TOCTOU 竞争窗口。
func nextAvailablePort() (int, error) {
return nextAvailablePortWithRetry(10)
}
func nextAvailablePortWithRetry(maxRetries int) (int, error) {
for i := 0; i < maxRetries; i++ {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
continue
}
port := listener.Addr().(*net.TCPAddr).Port
listener.Close()
// 短暂等待确保 OS 释放端口
time.Sleep(10 * time.Millisecond)
// 二次验证端口确实可用(没有被其他进程抢占)
verifyListener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
// 端口被抢占,重试
continue
}
verifyListener.Close()
return port, nil
}
return 0, fmt.Errorf("无法分配可用端口,已重试 %d 次", maxRetries)
}
@@ -0,0 +1,36 @@
package proxy
import (
"ant-chrome/backend/internal/config"
"strings"
"testing"
)
func TestValidateProxyConfigInvalidRawString(t *testing.T) {
ok, msg := ValidateProxyConfig("not-a-proxy-config", nil, "")
if ok {
t.Fatalf("expected invalid raw string to fail validation")
}
if !strings.Contains(msg, "解析失败") {
t.Fatalf("unexpected message: %s", msg)
}
}
func TestValidateProxyConfigMissingProxyId(t *testing.T) {
ok, msg := ValidateProxyConfig("", []config.BrowserProxy{
{ProxyId: "p1", ProxyConfig: "http://127.0.0.1:7890"},
}, "missing-proxy")
if ok {
t.Fatalf("expected missing proxyId to fail validation")
}
if !strings.Contains(msg, "不存在") {
t.Fatalf("unexpected message: %s", msg)
}
}
func TestValidateProxyConfigStandardProxy(t *testing.T) {
ok, msg := ValidateProxyConfig("socks5://127.0.0.1:1080", nil, "")
if !ok {
t.Fatalf("expected standard proxy to pass: %s", msg)
}
}