mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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
|
||||
|
||||
// 格式1:method:password@host:port(SIP002)
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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 配置
|
||||
}
|
||||
@@ -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 {
|
||||
// DirectProxy:http/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}
|
||||
}
|
||||
@@ -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 或 DoH(https://)地址,
|
||||
// 不支持 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)、
|
||||
// DoH(https://...)、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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user