mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
channel: master version: 1.1.0 source-ref: master published-at-utc: 2026-03-20T14:14:07Z
755 lines
21 KiB
Go
755 lines
21 KiB
Go
package proxy
|
||
|
||
import (
|
||
"ant-chrome/backend/internal/apppath"
|
||
"ant-chrome/backend/internal/config"
|
||
"ant-chrome/backend/internal/fsutil"
|
||
"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 {
|
||
// 兼容模式:如果 profile 内仍保留了可解析的 proxyConfig,则允许回退使用。
|
||
// 这样可兼容历史版本中 proxyId 失效后的启动流程,避免升级后强制手工重绑。
|
||
if src == "" {
|
||
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 {
|
||
if err := fsutil.EnsureExecutable(resolved); err != nil {
|
||
return "", fmt.Errorf("xray 文件不可执行: %s: %w", resolved, err)
|
||
}
|
||
return resolved, nil
|
||
}
|
||
}
|
||
}
|
||
env := strings.TrimSpace(os.Getenv("XRAY_BINARY_PATH"))
|
||
if env != "" {
|
||
if _, err := os.Stat(env); err == nil {
|
||
if err := fsutil.EnsureExecutable(env); err != nil {
|
||
return "", fmt.Errorf("xray 文件不可执行: %s: %w", env, err)
|
||
}
|
||
return env, nil
|
||
}
|
||
}
|
||
|
||
binaryNames := []string{"xray"}
|
||
if goruntime.GOOS == "windows" {
|
||
binaryNames = []string{"xray.exe", "xray"}
|
||
}
|
||
platformDir := fmt.Sprintf("%s-%s", goruntime.GOOS, goruntime.GOARCH)
|
||
|
||
searchDirs := make([]string, 0, 4)
|
||
if m.AppRoot != "" {
|
||
searchDirs = append(searchDirs,
|
||
filepath.Join(m.AppRoot, "bin", platformDir),
|
||
filepath.Join(m.AppRoot, "bin"),
|
||
)
|
||
}
|
||
if exePath, err := os.Executable(); err == nil {
|
||
exeDir := filepath.Dir(exePath)
|
||
searchDirs = append(searchDirs,
|
||
filepath.Join(exeDir, "bin", platformDir),
|
||
filepath.Join(exeDir, "bin"),
|
||
)
|
||
}
|
||
|
||
for _, dir := range searchDirs {
|
||
for _, name := range binaryNames {
|
||
candidate := filepath.Join(dir, name)
|
||
if _, err := os.Stat(candidate); err == nil {
|
||
if err := fsutil.EnsureExecutable(candidate); err != nil {
|
||
return "", fmt.Errorf("xray 文件不可执行: %s: %w", candidate, err)
|
||
}
|
||
return candidate, nil
|
||
}
|
||
}
|
||
}
|
||
|
||
for _, name := range binaryNames {
|
||
if path, err := exec.LookPath(name); err == nil {
|
||
if err := fsutil.EnsureExecutable(path); err != nil {
|
||
return "", fmt.Errorf("xray 文件不可执行: %s: %w", path, err)
|
||
}
|
||
return path, nil
|
||
}
|
||
}
|
||
|
||
return "", fmt.Errorf("未找到 xray 可执行文件。请将 xray 放到 bin/%s/ 或 bin/ 目录,或在配置中设置 XrayBinaryPath", platformDir)
|
||
}
|
||
|
||
// 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) {
|
||
root = apppath.Resolve(m.AppRoot, 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 = fsutil.NormalizePathInput(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)
|
||
}
|