diff --git a/backend/app_proxy_location.go b/backend/app_proxy_location.go new file mode 100644 index 00000000..3391b68e --- /dev/null +++ b/backend/app_proxy_location.go @@ -0,0 +1,193 @@ +package backend + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +type ProxyLocationResolveResult struct { + ProxyId string `json:"proxyId"` + Ok bool `json:"ok"` + Auto bool `json:"auto"` + Source string `json:"source"` + Error string `json:"error"` + IP string `json:"ip"` + Country string `json:"country"` + Region string `json:"region"` + City string `json:"city"` + Timezone string `json:"timezone"` + Lang string `json:"lang"` + Health *ProxyIPHealthResult `json:"health,omitempty"` + Alternates []ProxyLocationOption `json:"alternates,omitempty"` + ResolvedAt string `json:"resolvedAt"` +} + +type ProxyLocationOption struct { + Label string `json:"label"` + Timezone string `json:"timezone"` + Lang string `json:"lang"` +} + +var countryLocaleDefaults = map[string]ProxyLocationOption{ + "CN": {Label: "中国", Timezone: "Asia/Shanghai", Lang: "zh-CN"}, + "HK": {Label: "中国香港", Timezone: "Asia/Hong_Kong", Lang: "zh-HK"}, + "TW": {Label: "中国台湾", Timezone: "Asia/Taipei", Lang: "zh-TW"}, + "US": {Label: "美国", Timezone: "America/New_York", Lang: "en-US"}, + "GB": {Label: "英国", Timezone: "Europe/London", Lang: "en-GB"}, + "JP": {Label: "日本", Timezone: "Asia/Tokyo", Lang: "ja-JP"}, + "KR": {Label: "韩国", Timezone: "Asia/Seoul", Lang: "ko-KR"}, + "SG": {Label: "新加坡", Timezone: "Asia/Singapore", Lang: "en-SG"}, + "DE": {Label: "德国", Timezone: "Europe/Berlin", Lang: "de-DE"}, + "FR": {Label: "法国", Timezone: "Europe/Paris", Lang: "fr-FR"}, + "NL": {Label: "荷兰", Timezone: "Europe/Amsterdam", Lang: "nl-NL"}, + "CA": {Label: "加拿大", Timezone: "America/Toronto", Lang: "en-CA"}, + "AU": {Label: "澳大利亚", Timezone: "Australia/Sydney", Lang: "en-AU"}, + "RU": {Label: "俄罗斯", Timezone: "Europe/Moscow", Lang: "ru-RU"}, + "BR": {Label: "巴西", Timezone: "America/Sao_Paulo", Lang: "pt-BR"}, + "IN": {Label: "印度", Timezone: "Asia/Kolkata", Lang: "en-IN"}, +} + +var cityTimezoneDefaults = map[string]string{ + "US|new york": "America/New_York", + "US|los angeles": "America/Los_Angeles", + "US|san francisco": "America/Los_Angeles", + "US|chicago": "America/Chicago", + "US|denver": "America/Denver", + "US|phoenix": "America/Phoenix", + "CA|toronto": "America/Toronto", + "CA|vancouver": "America/Vancouver", + "AU|sydney": "Australia/Sydney", + "AU|melbourne": "Australia/Melbourne", + "AU|perth": "Australia/Perth", +} + +func (a *App) BrowserProxyResolveLocation(proxyId string) ProxyLocationResolveResult { + proxyId = strings.TrimSpace(proxyId) + resolvedAt := time.Now().Format(time.RFC3339) + if proxyId == "" || strings.EqualFold(proxyId, "__direct__") { + return ProxyLocationResolveResult{ProxyId: proxyId, Ok: false, Auto: false, Source: "manual", Error: "直连或未选择代理,请手动选择定位", ResolvedAt: resolvedAt} + } + + if cached, ok := a.cachedProxyIPHealthResult(proxyId); ok && cached.Ok { + return buildProxyLocationResolveResult(proxyId, cached, "cache", resolvedAt) + } + + health := a.BrowserProxyCheckIPHealth(proxyId) + if !health.Ok { + return ProxyLocationResolveResult{ProxyId: proxyId, Ok: false, Auto: false, Source: health.Source, Error: health.Error, Health: &health, ResolvedAt: resolvedAt} + } + return buildProxyLocationResolveResult(proxyId, health, "ip_health", resolvedAt) +} + +func (a *App) cachedProxyIPHealthResult(proxyId string) (ProxyIPHealthResult, bool) { + if a == nil || a.browserMgr == nil || a.browserMgr.ProxyDAO == nil { + return ProxyIPHealthResult{}, false + } + proxies, err := a.browserMgr.ProxyDAO.List() + if err != nil { + return ProxyIPHealthResult{}, false + } + for _, item := range proxies { + if !strings.EqualFold(strings.TrimSpace(item.ProxyId), proxyId) || strings.TrimSpace(item.LastIPHealthJSON) == "" { + continue + } + var result ProxyIPHealthResult + if err := json.Unmarshal([]byte(item.LastIPHealthJSON), &result); err == nil && result.ProxyId != "" { + return result, true + } + } + return ProxyIPHealthResult{}, false +} + +func buildProxyLocationResolveResult(proxyId string, health ProxyIPHealthResult, source string, resolvedAt string) ProxyLocationResolveResult { + option := resolveProxyLocationOption(health.Country, health.City) + ok := health.Ok && option.Timezone != "" && option.Lang != "" + result := ProxyLocationResolveResult{ + ProxyId: proxyId, + Ok: ok, + Auto: ok, + Source: source, + IP: health.IP, + Country: health.Country, + Region: health.Region, + City: health.City, + Timezone: option.Timezone, + Lang: option.Lang, + Health: &health, + ResolvedAt: resolvedAt, + } + if !ok { + result.Error = fmt.Sprintf("无法根据地区自动匹配定位:%s %s", strings.TrimSpace(health.Country), strings.TrimSpace(health.City)) + result.Alternates = defaultProxyLocationOptions() + } + return result +} + +func resolveProxyLocationOption(country string, city string) ProxyLocationOption { + countryCode := normalizeCountryCode(country) + option := countryLocaleDefaults[countryCode] + if option.Timezone == "" { + return ProxyLocationOption{} + } + cityKey := countryCode + "|" + strings.ToLower(strings.TrimSpace(city)) + if timezone := cityTimezoneDefaults[cityKey]; timezone != "" { + option.Timezone = timezone + } + return option +} + +func normalizeCountryCode(country string) string { + value := strings.TrimSpace(country) + upper := strings.ToUpper(value) + if len(upper) == 2 { + return upper + } + switch strings.ToLower(value) { + case "china", "中国", "mainland china": + return "CN" + case "hong kong", "香港": + return "HK" + case "taiwan", "台湾": + return "TW" + case "united states", "usa", "us", "美国": + return "US" + case "united kingdom", "uk", "great britain", "英国": + return "GB" + case "japan", "日本": + return "JP" + case "south korea", "korea", "韩国": + return "KR" + case "singapore", "新加坡": + return "SG" + case "germany", "德国": + return "DE" + case "france", "法国": + return "FR" + case "netherlands", "荷兰": + return "NL" + case "canada", "加拿大": + return "CA" + case "australia", "澳大利亚": + return "AU" + case "russia", "俄罗斯": + return "RU" + case "brazil", "巴西": + return "BR" + case "india", "印度": + return "IN" + default: + return upper + } +} + +func defaultProxyLocationOptions() []ProxyLocationOption { + return []ProxyLocationOption{ + countryLocaleDefaults["US"], + countryLocaleDefaults["GB"], + countryLocaleDefaults["JP"], + countryLocaleDefaults["SG"], + countryLocaleDefaults["CN"], + } +} diff --git a/backend/app_proxy_location_test.go b/backend/app_proxy_location_test.go new file mode 100644 index 00000000..83e20248 --- /dev/null +++ b/backend/app_proxy_location_test.go @@ -0,0 +1,38 @@ +package backend + +import "testing" + +func TestResolveProxyLocationOptionUsesCityTimezone(t *testing.T) { + option := resolveProxyLocationOption("US", "Los Angeles") + if option.Timezone != "America/Los_Angeles" { + t.Fatalf("timezone = %q, want America/Los_Angeles", option.Timezone) + } + if option.Lang != "en-US" { + t.Fatalf("lang = %q, want en-US", option.Lang) + } +} + +func TestResolveProxyLocationOptionNormalizesCountryName(t *testing.T) { + option := resolveProxyLocationOption("Japan", "Tokyo") + if option.Timezone != "Asia/Tokyo" { + t.Fatalf("timezone = %q, want Asia/Tokyo", option.Timezone) + } + if option.Lang != "ja-JP" { + t.Fatalf("lang = %q, want ja-JP", option.Lang) + } +} + +func TestBuildProxyLocationResolveResultUnknownCountryFallsBackToManual(t *testing.T) { + result := buildProxyLocationResolveResult("proxy-1", ProxyIPHealthResult{ + ProxyId: "proxy-1", + Ok: true, + Country: "Unknownland", + City: "Nowhere", + }, "cache", "2026-06-09T00:00:00Z") + if result.Ok { + t.Fatalf("expected unknown country to fail automatic resolution") + } + if len(result.Alternates) == 0 { + t.Fatalf("expected manual alternates") + } +} diff --git a/backend/app_proxy_query.go b/backend/app_proxy_query.go index 24a7c427..3b12198c 100644 --- a/backend/app_proxy_query.go +++ b/backend/app_proxy_query.go @@ -3,6 +3,9 @@ package backend import ( "ant-chrome/backend/internal/browser" "ant-chrome/backend/internal/proxy" + "strings" + "sync" + "time" ) func (a *App) BrowserProxyList() []BrowserProxy { @@ -44,6 +47,118 @@ func (a *App) TestProxyRealConnectivity(proxyId string) ProxyTestResult { return ProxyTestResult{ProxyId: result.ProxyId, Ok: result.Ok, LatencyMs: result.LatencyMs, Error: result.Error} } +// BrowserProxyWarmupBridge 只预热本地代理桥接,不执行外网测速。 +func (a *App) BrowserProxyWarmupBridge(proxyId string) ProxyBridgeWarmupResult { + proxies := a.getLatestProxies() + return a.warmupProxyBridge(proxyId, "", proxies) +} + +// BrowserProxyWarmupBridgeWithConfig 预热指定代理配置,proxyConfig 仅本次预热生效。 +func (a *App) BrowserProxyWarmupBridgeWithConfig(proxyId string, proxyConfig string) ProxyBridgeWarmupResult { + proxies := a.getLatestProxies() + return a.warmupProxyBridge(proxyId, proxyConfig, proxies) +} + +// BrowserProxyBatchWarmupBridge 批量预热代理桥接,concurrency 控制并发数(默认 5)。 +func (a *App) BrowserProxyBatchWarmupBridge(proxyIds []string, concurrency int) []ProxyBridgeWarmupResult { + if len(proxyIds) == 0 { + return []ProxyBridgeWarmupResult{} + } + if concurrency <= 0 { + concurrency = 5 + } + if concurrency > len(proxyIds) { + concurrency = len(proxyIds) + } + + proxies := a.getLatestProxies() + results := make([]ProxyBridgeWarmupResult, len(proxyIds)) + type warmupJob struct { + idx int + proxyId string + } + jobs := make(chan warmupJob, len(proxyIds)) + var wg sync.WaitGroup + for worker := 0; worker < concurrency; worker++ { + wg.Add(1) + go func() { + defer wg.Done() + for job := range jobs { + results[job.idx] = a.warmupProxyBridge(job.proxyId, "", proxies) + } + }() + } + for i, proxyID := range proxyIds { + jobs <- warmupJob{idx: i, proxyId: proxyID} + } + close(jobs) + wg.Wait() + return results +} + +func (a *App) warmupProxyBridge(proxyId string, proxyConfig string, proxies []BrowserProxy) ProxyBridgeWarmupResult { + startedAt := time.Now() + proxyId = strings.TrimSpace(proxyId) + result := ProxyBridgeWarmupResult{ProxyId: proxyId} + src := strings.TrimSpace(resolveProxyConfigForApp(proxyConfig, proxies, proxyId)) + if src == "" { + result.Error = "代理配置为空" + return result + } + if strings.EqualFold(src, "direct://") { + result.Ok = true + result.Engine = "direct" + result.LatencyMs = time.Since(startedAt).Milliseconds() + return result + } + if !proxy.RequiresBridge(src, proxies, proxyId) && !proxy.RequiresLocalProxyBridgeForBrowser(src) && !proxy.IsSingBoxProtocol(src) { + result.Ok = true + result.Engine = "none" + result.LatencyMs = time.Since(startedAt).Milliseconds() + return result + } + + var socksURL string + var err error + if proxy.IsSingBoxProtocol(src) { + result.Engine = "sing-box" + if a.singboxMgr == nil { + result.Error = "sing-box 管理器不可用" + return result + } + socksURL, err = a.singboxMgr.EnsureBridge(src, proxies, proxyId) + } else { + result.Engine = "xray" + if a.xrayMgr == nil { + result.Error = "xray 管理器不可用" + return result + } + socksURL, err = a.xrayMgr.EnsureBridge(src, proxies, proxyId) + } + result.LatencyMs = time.Since(startedAt).Milliseconds() + if err != nil { + result.Error = err.Error() + return result + } + result.Ok = true + result.SocksURL = socksURL + return result +} + +func resolveProxyConfigForApp(proxyConfig string, proxies []BrowserProxy, proxyId string) string { + proxyConfig = strings.TrimSpace(proxyConfig) + proxyId = strings.TrimSpace(proxyId) + if proxyId == "" { + return proxyConfig + } + for _, item := range proxies { + if strings.EqualFold(item.ProxyId, proxyId) { + return strings.TrimSpace(item.ProxyConfig) + } + } + return proxyConfig +} + // getLatestProxies 获取最新的代理列表,优先从数据库读取 func (a *App) getLatestProxies() []BrowserProxy { return browser.LatestProxiesWithFallback(a.browserMgr.ProxyDAO, a.config.Browser.Proxies) diff --git a/backend/app_proxy_types.go b/backend/app_proxy_types.go index c6c4fdc0..274962f4 100644 --- a/backend/app_proxy_types.go +++ b/backend/app_proxy_types.go @@ -14,6 +14,16 @@ type ProxyTestResult struct { Error string `json:"error"` } +// ProxyBridgeWarmupResult 代理桥接预热结果。 +type ProxyBridgeWarmupResult struct { + ProxyId string `json:"proxyId"` + Ok bool `json:"ok"` + Engine string `json:"engine"` + SocksURL string `json:"socksUrl"` + LatencyMs int64 `json:"latencyMs"` + Error string `json:"error"` +} + // ProxyIPHealthResult 代理出口 IP 健康信息(透传第三方接口结果) type ProxyIPHealthResult struct { ProxyId string `json:"proxyId"` diff --git a/backend/app_proxy_warmup_test.go b/backend/app_proxy_warmup_test.go new file mode 100644 index 00000000..9e267531 --- /dev/null +++ b/backend/app_proxy_warmup_test.go @@ -0,0 +1,60 @@ +package backend + +import ( + "ant-chrome/backend/internal/config" + "testing" +) + +func TestWarmupProxyBridgeDirectProxy(t *testing.T) { + t.Parallel() + + app := &App{} + result := app.warmupProxyBridge("direct", "", []BrowserProxy{{ProxyId: "direct", ProxyConfig: "direct://"}}) + if !result.Ok { + t.Fatalf("direct warmup failed: %s", result.Error) + } + if result.Engine != "direct" { + t.Fatalf("engine = %q, want direct", result.Engine) + } + if result.SocksURL != "" { + t.Fatalf("direct warmup socks url = %q, want empty", result.SocksURL) + } +} + +func TestWarmupProxyBridgeStandardProxyDoesNotRequireBridge(t *testing.T) { + t.Parallel() + + app := &App{} + result := app.warmupProxyBridge("http", "", []BrowserProxy{{ProxyId: "http", ProxyConfig: "http://127.0.0.1:8080"}}) + if !result.Ok { + t.Fatalf("standard proxy warmup failed: %s", result.Error) + } + if result.Engine != "none" { + t.Fatalf("engine = %q, want none", result.Engine) + } +} + +func TestWarmupProxyBridgeMissingProxyConfig(t *testing.T) { + t.Parallel() + + app := &App{} + result := app.warmupProxyBridge("missing", "", []config.BrowserProxy{{ProxyId: "other", ProxyConfig: "direct://"}}) + if result.Ok { + t.Fatalf("missing proxy config unexpectedly succeeded") + } + if result.Error == "" { + t.Fatalf("missing proxy config should return an error") + } +} + +func TestResolveProxyConfigForApp(t *testing.T) { + t.Parallel() + + proxies := []BrowserProxy{{ProxyId: "p1", ProxyConfig: "direct://"}} + if got := resolveProxyConfigForApp("", proxies, "p1"); got != "direct://" { + t.Fatalf("resolveProxyConfigForApp() = %q", got) + } + if got := resolveProxyConfigForApp("http://127.0.0.1:8080", proxies, "missing"); got != "http://127.0.0.1:8080" { + t.Fatalf("fallback config = %q", got) + } +} diff --git a/backend/internal/proxy/runtime_bridge_helpers.go b/backend/internal/proxy/runtime_bridge_helpers.go index 6c6726f7..bc2d28e7 100644 --- a/backend/internal/proxy/runtime_bridge_helpers.go +++ b/backend/internal/proxy/runtime_bridge_helpers.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "io" "net" "os" "path/filepath" @@ -58,17 +59,60 @@ func resolveEnvPath(path string, appRoot string) string { func waitPortReady(host string, port int, timeout time.Duration) error { addr := net.JoinHostPort(host, strconv.Itoa(port)) deadline := time.Now().Add(timeout) + var lastErr error for time.Now().Before(deadline) { conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) if err == nil { conn.Close() return nil } + lastErr = err time.Sleep(100 * time.Millisecond) } + if lastErr != nil { + return fmt.Errorf("端口 %d 不可用: %w", port, lastErr) + } return fmt.Errorf("端口 %d 不可用", port) } +func waitSocks5Ready(host string, port int, timeout time.Duration) error { + addr := net.JoinHostPort(host, strconv.Itoa(port)) + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + if err := checkSocks5Handshake(addr, 300*time.Millisecond); err == nil { + return nil + } else { + lastErr = err + } + time.Sleep(100 * time.Millisecond) + } + if lastErr != nil { + return fmt.Errorf("socks5 端口 %d 未就绪: %w", port, lastErr) + } + return fmt.Errorf("socks5 端口 %d 未就绪", port) +} + +func checkSocks5Handshake(addr string, timeout time.Duration) error { + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + return err + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + if _, err := conn.Write([]byte{0x05, 0x01, 0x00}); err != nil { + return err + } + buf := make([]byte, 2) + if _, err := io.ReadFull(conn, buf); err != nil { + return err + } + if buf[0] != 0x05 || buf[1] != 0x00 { + return fmt.Errorf("socks5 握手响应异常: %v", buf) + } + return nil +} + // nextAvailablePort 分配一个可用端口。 // 采用二次验证策略:分配后立即再次绑定确认未被其他进程抢占, // 并在 EnsureBridge 层面加重试,彻底消除 TOCTOU 竞争窗口。 diff --git a/backend/internal/proxy/singbox_anytls_test.go b/backend/internal/proxy/singbox_anytls_test.go index 19af1afb..744513f9 100644 --- a/backend/internal/proxy/singbox_anytls_test.go +++ b/backend/internal/proxy/singbox_anytls_test.go @@ -140,3 +140,15 @@ password: test-password t.Fatalf("expected missing server and port to fail") } } + +func TestBuildSingBoxAnyTLSRequiresPassword(t *testing.T) { + src := ` +type: anytls +server: anytls.example.com +port: 443 +` + + if _, err := BuildSingBoxOutbound(src); err == nil { + t.Fatalf("expected missing password to fail") + } +} diff --git a/backend/internal/proxy/singbox_bridge_process.go b/backend/internal/proxy/singbox_bridge_process.go new file mode 100644 index 00000000..fb9f0e1f --- /dev/null +++ b/backend/internal/proxy/singbox_bridge_process.go @@ -0,0 +1,40 @@ +package proxy + +func (bridge *SingBoxBridge) startExitWatcher() { + if bridge == nil || bridge.Cmd == nil { + return + } + if bridge.ExitDone == nil { + bridge.ExitDone = make(chan struct{}) + } + bridge.waitOnce.Do(func() { + go func() { + err := bridge.Cmd.Wait() + bridge.exitMu.Lock() + bridge.ExitErr = err + bridge.exitMu.Unlock() + close(bridge.ExitDone) + }() + }) +} + +func (bridge *SingBoxBridge) waitExit() error { + if bridge == nil || bridge.Cmd == nil { + return nil + } + bridge.startExitWatcher() + if bridge.ExitDone == nil { + return nil + } + <-bridge.ExitDone + return bridge.exitErr() +} + +func (bridge *SingBoxBridge) exitErr() error { + if bridge == nil { + return nil + } + bridge.exitMu.Lock() + defer bridge.exitMu.Unlock() + return bridge.ExitErr +} diff --git a/backend/internal/proxy/singbox_bridge_recovery.go b/backend/internal/proxy/singbox_bridge_recovery.go new file mode 100644 index 00000000..fb862098 --- /dev/null +++ b/backend/internal/proxy/singbox_bridge_recovery.go @@ -0,0 +1,70 @@ +package proxy + +import ( + "ant-chrome/backend/internal/logger" + "errors" + "fmt" + "time" +) + +var errSingBoxBridgeRestartNotNeeded = errors.New("sing-box 桥接已无须恢复") + +func cloneStringInterfaceMap(items map[string]interface{}) map[string]interface{} { + if len(items) == 0 { + return nil + } + cloned := make(map[string]interface{}, len(items)) + for key, value := range items { + cloned[key] = value + } + return cloned +} + +func (m *SingBoxManager) restartBridgeOnSamePort(log *logger.Logger, key string, bridge *SingBoxBridge) error { + if bridge == nil { + return fmt.Errorf("sing-box 桥接不存在") + } + m.mu.Lock() + current := m.Bridges[key] + if current != bridge || bridge.Stopping || bridge.Restarting { + m.mu.Unlock() + return errSingBoxBridgeRestartNotNeeded + } + if len(bridge.Outbound) == 0 { + m.mu.Unlock() + return fmt.Errorf("sing-box 桥接缺少重启上下文") + } + bridge.Restarting = true + m.mu.Unlock() + + binaryPath, err := m.resolveBinary() + if err != nil { + return err + } + log.Warn("sing-box 桥接进程退出,尝试同端口恢复", + logger.F("key", key[:8]), + logger.F("port", bridge.Port), + ) + restarted, err := m.launchBridgeOnPort(log, key, binaryPath, cloneStringInterfaceMap(bridge.Outbound), bridge.Port, 1) + if err != nil { + return err + } + restarted.RestartCount = bridge.RestartCount + 1 + restarted.LastUsedAt = time.Now() + m.mu.Lock() + if current := m.Bridges[key]; current != bridge { + m.mu.Unlock() + restarted.Stopping = true + m.stopBridgeProcess(restarted) + return errSingBoxBridgeRestartNotNeeded + } + m.Bridges[key] = restarted + m.mu.Unlock() + log.Info("sing-box 桥接已同端口恢复", + logger.F("key", key[:8]), + logger.F("port", restarted.Port), + logger.F("pid", restarted.Pid), + ) + go m.watchBridge(restarted, key) + return nil +} diff --git a/backend/internal/proxy/singbox_bridge_runtime.go b/backend/internal/proxy/singbox_bridge_runtime.go index d71b7444..529cc41f 100644 --- a/backend/internal/proxy/singbox_bridge_runtime.go +++ b/backend/internal/proxy/singbox_bridge_runtime.go @@ -3,10 +3,12 @@ package proxy import ( "ant-chrome/backend/internal/config" "ant-chrome/backend/internal/logger" + "errors" "fmt" "os" "os/exec" "path/filepath" + "strings" "time" ) @@ -39,69 +41,26 @@ func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.Brows } log.Debug("sing-box binary", logger.F("path", binaryPath)) - const maxRetries = 3 + const maxRetries = 2 var lastErr error + attemptsUsed := 0 for attempt := 1; attempt <= maxRetries; attempt++ { + attemptsUsed = attempt port, err := nextAvailablePort() if err != nil { lastErr = err continue } - cfgPath, err := m.buildConfig(key, outbound, port) + bridge, err := m.launchBridgeOnPort(log, key, binaryPath, outbound, port, attempt) 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, - } - 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 !isRetryableSingBoxLaunchError(err) { + break } - if content, readErr := os.ReadFile(stderrPath); readErr == nil && len(content) > 0 { - log.Error("sing-box stderr", logger.F("output", string(content))) - } - bridge.Stopping = true - m.stopBridgeProcess(bridge) - bridge.Running = false - bridge.Pid = 0 - bridge.LastError = err.Error() - 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() - } - if socksURL, reused := m.registerBridge(key, bridge); reused { log.Info("复用已就绪 sing-box 桥接", logger.F("key", key[:8]), logger.F("socks_url", socksURL)) bridge.Stopping = true @@ -113,7 +72,182 @@ func (m *SingBoxManager) EnsureBridge(proxyConfig string, proxies []config.Brows return fmt.Sprintf("socks5://127.0.0.1:%d", port), nil } - return "", fmt.Errorf("sing-box 启动失败(已重试 %d 次): %w", maxRetries, lastErr) + return "", fmt.Errorf("sing-box 启动失败(已尝试 %d 次): %w", attemptsUsed, lastErr) +} + +func (m *SingBoxManager) launchBridgeOnPort(log *logger.Logger, key string, binaryPath string, outbound map[string]interface{}, port int, attempt int) (*SingBoxBridge, error) { + cfgPath, err := m.buildConfig(key, outbound, port) + if err != nil { + return nil, fmt.Errorf("sing-box 配置生成失败: %w", err) + } + stderrPath := filepath.Join(filepath.Dir(cfgPath), "singbox-stderr.log") + if err := m.testRuntimeConfig(binaryPath, cfgPath, stderrPath); err != nil { + log.Error("sing-box 配置预检失败", logger.F("error", err), logger.F("attempt", attempt), logger.F("config", cfgPath)) + return nil, err + } + + cmd := exec.Command(binaryPath, "run", "-c", cfgPath) + hideWindow(cmd) + cmd.Dir = filepath.Dir(cfgPath) + 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)) + return nil, &singBoxLaunchError{err: err, retryable: false} + } + + bridge := &SingBoxBridge{ + NodeKey: key, + Port: port, + Cmd: cmd, + Pid: cmd.Process.Pid, + Running: true, + Outbound: cloneStringInterfaceMap(outbound), + LastUsedAt: time.Now(), + } + bridge.startExitWatcher() + log.Info("sing-box 启动", logger.F("key", key[:8]), logger.F("pid", bridge.Pid), logger.F("port", port)) + + if err := m.waitBridgeSocksReady(bridge, 10*time.Second); err != nil { + if stderrFile != nil { + stderrFile.Close() + } + m.logBridgeStartupError(log, cfgPath, stderrPath) + bridge.Stopping = true + m.stopBridgeProcess(bridge) + bridge.Running = false + bridge.Pid = 0 + bridge.LastError = m.describeBridgeReadyError(err, cfgPath, stderrPath) + retryable := m.isRetryableBridgeReadyError(err, cfgPath, stderrPath) + message := "sing-box 桥接未就绪" + if retryable { + message = "sing-box 桥接未就绪,重试" + } + log.Error(message, logger.F("error", err), logger.F("attempt", attempt), logger.F("port", port), logger.F("retryable", retryable)) + time.Sleep(200 * time.Millisecond) + return nil, &singBoxLaunchError{err: fmt.Errorf("%s", bridge.LastError), retryable: retryable} + } + + if stderrFile != nil { + stderrFile.Close() + } + return bridge, nil +} + +type singBoxLaunchError struct { + err error + retryable bool +} + +func (e *singBoxLaunchError) Error() string { + if e == nil || e.err == nil { + return "" + } + return e.err.Error() +} + +func (e *singBoxLaunchError) Unwrap() error { + if e == nil { + return nil + } + return e.err +} + +func isRetryableSingBoxLaunchError(err error) bool { + if err == nil { + return false + } + var launchErr *singBoxLaunchError + if errors.As(err, &launchErr) { + return launchErr.retryable + } + return true +} + +func (m *SingBoxManager) testRuntimeConfig(binaryPath string, cfgPath string, stderrPath string) error { + cmd := exec.Command(binaryPath, "check", "-c", cfgPath) + hideWindow(cmd) + cmd.Dir = filepath.Dir(cfgPath) + stderrFile, _ := os.Create(stderrPath) + if stderrFile != nil { + defer stderrFile.Close() + cmd.Stderr = stderrFile + } + output, err := cmd.Output() + if err == nil { + return nil + } + if len(output) > 0 && stderrFile != nil { + _, _ = stderrFile.Write(output) + } + return &singBoxLaunchError{ + err: fmt.Errorf("sing-box 配置预检失败: %w;%s", err, m.describeBridgeReadyError(err, cfgPath, stderrPath)), + retryable: false, + } +} + +func (m *SingBoxManager) waitBridgeSocksReady(bridge *SingBoxBridge, timeout time.Duration) error { + if bridge == nil { + return fmt.Errorf("sing-box 桥接进程不存在") + } + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ready := make(chan error, 1) + go func() { + ready <- waitSocks5Ready("127.0.0.1", bridge.Port, timeout) + }() + select { + case err := <-ready: + return err + case <-bridge.ExitDone: + if err := bridge.exitErr(); err != nil { + return fmt.Errorf("sing-box 进程提前退出: %w", err) + } + return fmt.Errorf("sing-box 进程提前退出") + case <-deadline.C: + return fmt.Errorf("sing-box socks5 端口 %d 启动超时", bridge.Port) + } +} + +func (m *SingBoxManager) isRetryableBridgeReadyError(err error, cfgPath string, stderrPath string) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + if !strings.Contains(message, "提前退出") { + return true + } + tail := strings.ToLower(readLogTail(stderrPath, 1200)) + if tail == "" && strings.TrimSpace(cfgPath) != "" { + tail = strings.ToLower(readLogTail(filepath.Join(filepath.Dir(cfgPath), "singbox-error.log"), 1200)) + } + return strings.Contains(tail, "address already in use") || + strings.Contains(tail, "only one usage of each socket") || + strings.Contains(tail, "bind:") || + strings.Contains(tail, "bind ") +} + +func (m *SingBoxManager) describeBridgeReadyError(err error, cfgPath string, stderrPath string) string { + parts := []string{err.Error()} + if strings.TrimSpace(cfgPath) != "" { + parts = append(parts, "配置文件: "+cfgPath) + } + if tail := readLogTail(stderrPath, 1200); tail != "" { + parts = append(parts, "stderr: "+tail) + } + return strings.Join(parts, ";") +} + +func (m *SingBoxManager) logBridgeStartupError(log *logger.Logger, cfgPath string, stderrPath string) { + if stderrContent, readErr := os.ReadFile(stderrPath); readErr == nil && len(stderrContent) > 0 { + log.Error("sing-box stderr", logger.F("output", string(stderrContent))) + } } // StopAll 关闭所有 sing-box 桥接进程 @@ -140,7 +274,7 @@ func (m *SingBoxManager) tryReuseBridge(key string) (string, bool) { 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 alive && waitSocks5Ready("127.0.0.1", bridge.Port, 800*time.Millisecond) == nil { socksURL := fmt.Sprintf("socks5://127.0.0.1:%d", bridge.Port) m.mu.Unlock() return socksURL, true @@ -169,7 +303,7 @@ func (m *SingBoxManager) registerBridge(key string, bridge *SingBoxBridge) (stri } 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 alive && waitSocks5Ready("127.0.0.1", existing.Port, 800*time.Millisecond) == nil { duplicate = bridge socksURL := fmt.Sprintf("socks5://127.0.0.1:%d", existing.Port) m.mu.Unlock() @@ -197,16 +331,37 @@ func (m *SingBoxManager) watchBridge(bridge *SingBoxBridge, key string) { if bridge == nil || bridge.Cmd == nil { return } - _ = bridge.Cmd.Wait() + _ = bridge.waitExit() + var shouldRestart bool m.mu.Lock() if current, ok := m.Bridges[key]; ok && current == bridge { - delete(m.Bridges, key) + if !bridge.Stopping && !bridge.Restarting && bridge.RestartCount < 1 { + shouldRestart = true + } else { + delete(m.Bridges, key) + } } bridge.Running = false stopping := bridge.Stopping m.mu.Unlock() + if shouldRestart { + log := logger.New("SingBox") + if err := m.restartBridgeOnSamePort(log, key, bridge); err == nil { + return + } else if errors.Is(err, errSingBoxBridgeRestartNotNeeded) { + return + } else { + log.Error("sing-box 桥接同端口恢复失败", logger.F("key", key[:8]), logger.F("port", bridge.Port), logger.F("error", err.Error())) + m.mu.Lock() + if current, ok := m.Bridges[key]; ok && current == bridge { + delete(m.Bridges, key) + } + m.mu.Unlock() + } + } + if !stopping && m.OnBridgeDied != nil { m.OnBridgeDied(key, fmt.Errorf("sing-box 桥接进程意外退出")) } diff --git a/backend/internal/proxy/singbox_parser.go b/backend/internal/proxy/singbox_parser.go index e48355d3..ef72fad9 100644 --- a/backend/internal/proxy/singbox_parser.go +++ b/backend/internal/proxy/singbox_parser.go @@ -138,7 +138,7 @@ func buildSingBoxAnyTLSFromClash(node map[string]interface{}) (map[string]interf } skipVerify := getMapBool(node, "skip-cert-verify") - if host == "" || port == 0 { + if host == "" || port == 0 || password == "" { return nil, fmt.Errorf("anytls node info incomplete") } diff --git a/backend/internal/proxy/singbox_test.go b/backend/internal/proxy/singbox_test.go index 9eb93972..fe6f6096 100644 --- a/backend/internal/proxy/singbox_test.go +++ b/backend/internal/proxy/singbox_test.go @@ -1,6 +1,12 @@ package proxy -import "testing" +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" +) func TestSingBoxRegisterBridgeStoresNewBridge(t *testing.T) { manager := &SingBoxManager{ @@ -49,3 +55,68 @@ func TestSingBoxRegisterBridgeIgnoresSamePointer(t *testing.T) { t.Fatalf("same bridge pointer should not be marked as stopping") } } + +func TestSingBoxLaunchErrorRetryClassification(t *testing.T) { + t.Parallel() + + if isRetryableSingBoxLaunchError(&singBoxLaunchError{err: fmt.Errorf("config invalid"), retryable: false}) { + t.Fatalf("non-retryable sing-box launch error was classified as retryable") + } + if !isRetryableSingBoxLaunchError(&singBoxLaunchError{err: fmt.Errorf("port race"), retryable: true}) { + t.Fatalf("retryable sing-box launch error was classified as non-retryable") + } + if !isRetryableSingBoxLaunchError(fmt.Errorf("legacy error")) { + t.Fatalf("plain errors should remain retryable for backward compatibility") + } +} + +func TestSingBoxBridgeReadyErrorRetryPolicy(t *testing.T) { + t.Parallel() + + manager := &SingBoxManager{} + dir := t.TempDir() + cfgPath := filepath.Join(dir, "singbox-config.json") + stderrPath := filepath.Join(dir, "singbox-stderr.log") + if err := os.WriteFile(cfgPath, []byte(`{}`), 0o644); err != nil { + t.Fatalf("write config failed: %v", err) + } + + if manager.isRetryableBridgeReadyError(fmt.Errorf("sing-box 进程提前退出: config invalid"), cfgPath, stderrPath) { + t.Fatalf("early process exit without bind evidence should not be retried") + } + if err := os.WriteFile(stderrPath, []byte("listen tcp 127.0.0.1:10001: bind: address already in use"), 0o644); err != nil { + t.Fatalf("write stderr failed: %v", err) + } + if !manager.isRetryableBridgeReadyError(fmt.Errorf("sing-box 进程提前退出"), cfgPath, stderrPath) { + t.Fatalf("bind conflict should be retried with another port") + } +} + +func TestSingBoxRestartBridgeNotNeededWhenBridgeChanged(t *testing.T) { + t.Parallel() + + manager := &SingBoxManager{Bridges: make(map[string]*SingBoxBridge)} + oldBridge := &SingBoxBridge{NodeKey: "node-a", Port: 21001, Outbound: map[string]interface{}{"type": "direct"}} + manager.Bridges["node-a"] = &SingBoxBridge{NodeKey: "node-a", Port: 21002} + + err := manager.restartBridgeOnSamePort(nil, "node-a", oldBridge) + if !errors.Is(err, errSingBoxBridgeRestartNotNeeded) { + t.Fatalf("restartBridgeOnSamePort() error = %v, want restart-not-needed", err) + } +} + +func TestSingBoxRestartBridgeRequiresContext(t *testing.T) { + t.Parallel() + + manager := &SingBoxManager{Bridges: make(map[string]*SingBoxBridge)} + bridge := &SingBoxBridge{NodeKey: "node-a", Port: 21001} + manager.Bridges["node-a"] = bridge + + err := manager.restartBridgeOnSamePort(nil, "node-a", bridge) + if err == nil { + t.Fatalf("restartBridgeOnSamePort() returned nil, want missing context error") + } + if errors.Is(err, errSingBoxBridgeRestartNotNeeded) { + t.Fatalf("missing restart context should not be treated as restart-not-needed") + } +} diff --git a/backend/internal/proxy/singbox_types.go b/backend/internal/proxy/singbox_types.go index d99838b7..36bcce78 100644 --- a/backend/internal/proxy/singbox_types.go +++ b/backend/internal/proxy/singbox_types.go @@ -4,17 +4,26 @@ import ( "ant-chrome/backend/internal/config" "os/exec" "sync" + "time" ) // SingBoxBridge sing-box 桥接进程 type SingBoxBridge struct { - NodeKey string - Port int - Cmd *exec.Cmd - Pid int - Running bool - Stopping bool - LastError string + NodeKey string + Port int + Cmd *exec.Cmd + Pid int + Running bool + Stopping bool + LastError string + Outbound map[string]interface{} + LastUsedAt time.Time + Restarting bool + RestartCount int + ExitDone chan struct{} + ExitErr error + exitMu sync.Mutex + waitOnce sync.Once } // SingBoxManager sing-box 桥接管理器 diff --git a/backend/internal/proxy/types.go b/backend/internal/proxy/types.go index 34d0a38e..c3198613 100644 --- a/backend/internal/proxy/types.go +++ b/backend/internal/proxy/types.go @@ -2,6 +2,7 @@ package proxy import ( "os/exec" + "sync" "time" ) @@ -16,6 +17,14 @@ type XrayBridge struct { RefCount int LastUsedAt time.Time Stopping bool + Restarting bool + Outbounds []interface{} + Routes []interface{} + DNSServers string + ExitDone chan struct{} + ExitErr error + exitMu sync.Mutex + waitOnce sync.Once } // ProxyResult 代理解析结果 diff --git a/backend/internal/proxy/xray.go b/backend/internal/proxy/xray.go index 8999a0cb..377e3ffe 100644 --- a/backend/internal/proxy/xray.go +++ b/backend/internal/proxy/xray.go @@ -20,7 +20,7 @@ type XrayManager struct { Bridges map[string]*XrayBridge OnBridgeDied func(key string, err error) // 桥接进程意外退出回调 mu sync.Mutex - launchMu sync.Mutex + launchLocks map[string]*xrayLaunchLock stopCh chan struct{} stopOnce sync.Once } @@ -28,10 +28,11 @@ type XrayManager struct { // 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{}), + Config: cfg, + AppRoot: appRoot, + Bridges: make(map[string]*XrayBridge), + launchLocks: make(map[string]*xrayLaunchLock), + stopCh: make(chan struct{}), } go manager.cleanupLoop() return manager diff --git a/backend/internal/proxy/xray_bridge_launch.go b/backend/internal/proxy/xray_bridge_launch.go index 6575567b..13d40034 100644 --- a/backend/internal/proxy/xray_bridge_launch.go +++ b/backend/internal/proxy/xray_bridge_launch.go @@ -3,6 +3,7 @@ package proxy import ( "ant-chrome/backend/internal/config" "ant-chrome/backend/internal/logger" + "errors" "fmt" "os" "os/exec" @@ -95,8 +96,8 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP log.Info("复用桥接进程", logger.F("key", key), logger.F("socks_url", socksURL)) return socksURL, key, nil } - m.launchMu.Lock() - defer m.launchMu.Unlock() + unlockLaunch := m.lockLaunchForKey(key) + defer unlockLaunch() if socksURL, reused := m.tryReuseBridge(key, pin); reused { log.Info("复用桥接进程", logger.F("key", key), logger.F("socks_url", socksURL)) return socksURL, key, nil @@ -108,12 +109,14 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP return "", "", err } - maxLaunchRetries := 3 + maxLaunchRetries := 2 if preferredPort > 0 { maxLaunchRetries = 1 } var lastErr error + attemptsUsed := 0 for attempt := 1; attempt <= maxLaunchRetries; attempt++ { + attemptsUsed = attempt socksURL, bridge, err := m.launchBridgeAttempt(log, key, binaryPath, outbounds, routes, preferredPort, dnsServers, pin, attempt) if err == nil { return socksURL, key, nil @@ -122,8 +125,41 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP go m.watchBridge(bridge, key) } lastErr = err + if !isRetryableXrayLaunchError(err) { + break + } } - return "", "", fmt.Errorf("xray 启动失败(已重试 %d 次): %w", maxLaunchRetries, lastErr) + return "", "", fmt.Errorf("xray 启动失败(已尝试 %d 次): %w", attemptsUsed, lastErr) +} + +type xrayLaunchError struct { + err error + retryable bool +} + +func (e *xrayLaunchError) Error() string { + if e == nil || e.err == nil { + return "" + } + return e.err.Error() +} + +func (e *xrayLaunchError) Unwrap() error { + if e == nil { + return nil + } + return e.err +} + +func isRetryableXrayLaunchError(err error) bool { + if err == nil { + return false + } + var launchErr *xrayLaunchError + if errors.As(err, &launchErr) { + return launchErr.retryable + } + return true } func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binaryPath string, outbounds []interface{}, routes []interface{}, preferredPort int, dnsServers string, pin bool, attempt int) (string, *XrayBridge, error) { @@ -141,11 +177,15 @@ func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binary log.Error("xray 配置生成失败", logger.F("error", err)) return "", nil, err } + stderrPath := filepath.Join(filepath.Dir(cfgPath), "xray-stderr.log") + if err := m.testRuntimeConfig(binaryPath, cfgPath, stderrPath); err != nil { + log.Error("xray 配置预检失败", logger.F("error", err), logger.F("attempt", attempt), logger.F("config", cfgPath)) + return "", nil, 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 @@ -156,7 +196,7 @@ func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binary stderrFile.Close() } log.Error("xray 启动失败", logger.F("error", err), logger.F("attempt", attempt)) - return "", nil, err + return "", nil, &xrayLaunchError{err: err, retryable: false} } bridge := &XrayBridge{ @@ -167,7 +207,11 @@ func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binary Running: true, RefCount: 0, LastUsedAt: time.Now(), + Outbounds: cloneInterfaceSlice(outbounds), + Routes: cloneInterfaceSlice(routes), + DNSServers: dnsServers, } + bridge.startExitWatcher() log.Info("xray 启动", logger.F("key", key), logger.F("pid", bridge.Pid), logger.F("port", bridge.Port), logger.F("attempt", attempt)) if err := m.waitBridgeReady(log, bridge, cfgPath, stderrPath, stderrFile, attempt); err != nil { @@ -254,7 +298,7 @@ func chainHTTPOutbound(hop chainSocks5Hop, tag string, nextTag string) map[strin } func (m *XrayManager) waitBridgeReady(log *logger.Logger, bridge *XrayBridge, cfgPath string, stderrPath string, stderrFile *os.File, attempt int) error { - if err := waitPortReady("127.0.0.1", bridge.Port, m.bridgeStartTimeout()); err != nil { + if err := m.waitBridgeSocksReady(bridge, m.bridgeStartTimeout()); err != nil { if stderrFile != nil { stderrFile.Close() } @@ -264,9 +308,14 @@ func (m *XrayManager) waitBridgeReady(log *logger.Logger, bridge *XrayBridge, cf bridge.Running = false bridge.Pid = 0 bridge.LastError = m.describeBridgeReadyError(err, cfgPath, stderrPath) - log.Error("xray 端口不可用,重试", logger.F("key", bridge.NodeKey), logger.F("error", err), logger.F("port", bridge.Port), logger.F("attempt", attempt)) + retryable := m.isRetryableBridgeReadyError(err, cfgPath, stderrPath) + message := "xray 桥接未就绪" + if retryable { + message = "xray 桥接未就绪,重试" + } + log.Error(message, logger.F("key", bridge.NodeKey), logger.F("error", err), logger.F("port", bridge.Port), logger.F("attempt", attempt), logger.F("retryable", retryable)) time.Sleep(200 * time.Millisecond) - return fmt.Errorf("%s", bridge.LastError) + return &xrayLaunchError{err: fmt.Errorf("%s", bridge.LastError), retryable: retryable} } if stderrFile != nil { stderrFile.Close() @@ -274,6 +323,69 @@ func (m *XrayManager) waitBridgeReady(log *logger.Logger, bridge *XrayBridge, cf return nil } +func (m *XrayManager) isRetryableBridgeReadyError(err error, cfgPath string, stderrPath string) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + if !strings.Contains(message, "提前退出") { + return true + } + tail := strings.ToLower(readLogTail(stderrPath, 1200)) + if tail == "" && strings.TrimSpace(cfgPath) != "" { + tail = strings.ToLower(readLogTail(filepath.Join(filepath.Dir(cfgPath), "xray-error.log"), 1200)) + } + return strings.Contains(tail, "address already in use") || + strings.Contains(tail, "only one usage of each socket") || + strings.Contains(tail, "bind:") || + strings.Contains(tail, "bind ") +} + +func (m *XrayManager) testRuntimeConfig(binaryPath string, cfgPath string, stderrPath string) error { + cmd := exec.Command(binaryPath, "run", "-test", "-c", cfgPath) + hideWindow(cmd) + cmd.Dir = filepath.Dir(cfgPath) + stderrFile, _ := os.Create(stderrPath) + if stderrFile != nil { + defer stderrFile.Close() + cmd.Stderr = stderrFile + } + output, err := cmd.Output() + if err == nil { + return nil + } + if len(output) > 0 && stderrFile != nil { + _, _ = stderrFile.Write(output) + } + return &xrayLaunchError{ + err: fmt.Errorf("xray 配置预检失败: %w;%s", err, m.describeBridgeReadyError(err, cfgPath, stderrPath)), + retryable: false, + } +} + +func (m *XrayManager) waitBridgeSocksReady(bridge *XrayBridge, timeout time.Duration) error { + if bridge == nil { + return fmt.Errorf("xray 桥接进程不存在") + } + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ready := make(chan error, 1) + go func() { + ready <- waitSocks5Ready("127.0.0.1", bridge.Port, timeout) + }() + select { + case err := <-ready: + return err + case <-bridge.ExitDone: + if err := bridge.exitErr(); err != nil { + return fmt.Errorf("xray 进程提前退出: %w", err) + } + return fmt.Errorf("xray 进程提前退出") + case <-deadline.C: + return fmt.Errorf("xray socks5 端口 %d 启动超时", bridge.Port) + } +} + func (m *XrayManager) bridgeStartTimeout() time.Duration { if m != nil && m.Config != nil && m.Config.ProxyCheck.BridgeStartTimeoutMs > 0 { return time.Duration(m.Config.ProxyCheck.BridgeStartTimeoutMs) * time.Millisecond diff --git a/backend/internal/proxy/xray_bridge_process.go b/backend/internal/proxy/xray_bridge_process.go new file mode 100644 index 00000000..53e99b73 --- /dev/null +++ b/backend/internal/proxy/xray_bridge_process.go @@ -0,0 +1,40 @@ +package proxy + +func (bridge *XrayBridge) startExitWatcher() { + if bridge == nil || bridge.Cmd == nil { + return + } + if bridge.ExitDone == nil { + bridge.ExitDone = make(chan struct{}) + } + bridge.waitOnce.Do(func() { + go func() { + err := bridge.Cmd.Wait() + bridge.exitMu.Lock() + bridge.ExitErr = err + bridge.exitMu.Unlock() + close(bridge.ExitDone) + }() + }) +} + +func (bridge *XrayBridge) waitExit() error { + if bridge == nil || bridge.Cmd == nil { + return nil + } + bridge.startExitWatcher() + if bridge.ExitDone == nil { + return nil + } + <-bridge.ExitDone + return bridge.exitErr() +} + +func (bridge *XrayBridge) exitErr() error { + if bridge == nil { + return nil + } + bridge.exitMu.Lock() + defer bridge.exitMu.Unlock() + return bridge.ExitErr +} diff --git a/backend/internal/proxy/xray_bridge_runtime.go b/backend/internal/proxy/xray_bridge_runtime.go new file mode 100644 index 00000000..1c66710c --- /dev/null +++ b/backend/internal/proxy/xray_bridge_runtime.go @@ -0,0 +1,80 @@ +package proxy + +import ( + "ant-chrome/backend/internal/logger" + "errors" + "fmt" + "time" +) + +var errXrayBridgeRestartNotNeeded = errors.New("xray 桥接已无须恢复") + +func cloneInterfaceSlice(items []interface{}) []interface{} { + if len(items) == 0 { + return nil + } + cloned := make([]interface{}, len(items)) + copy(cloned, items) + return cloned +} + +func (m *XrayManager) restartPinnedBridge(log *logger.Logger, key string, bridge *XrayBridge, refCount int) error { + if bridge == nil { + return fmt.Errorf("xray 桥接不存在") + } + unlockLaunch := m.lockLaunchForKey(key) + defer unlockLaunch() + + m.mu.Lock() + current := m.Bridges[key] + if current != bridge || bridge.Stopping || bridge.RefCount <= 0 { + m.mu.Unlock() + return errXrayBridgeRestartNotNeeded + } + refCount = bridge.RefCount + m.mu.Unlock() + + if refCount <= 0 { + return fmt.Errorf("xray 桥接无活动引用") + } + if len(bridge.Outbounds) == 0 || len(bridge.Routes) == 0 { + return fmt.Errorf("xray 桥接缺少重启上下文") + } + + log.Warn("xray 桥接进程退出,尝试同端口恢复", + logger.F("key", key), + logger.F("port", bridge.Port), + logger.F("ref_count", refCount), + ) + binaryPath, err := m.resolveBinary() + if err != nil { + return err + } + socksURL, restarted, err := m.launchBridgeAttempt( + log, + key, + binaryPath, + cloneInterfaceSlice(bridge.Outbounds), + cloneInterfaceSlice(bridge.Routes), + bridge.Port, + bridge.DNSServers, + false, + 1, + ) + if err != nil { + return err + } + if restarted == nil { + return fmt.Errorf("xray 桥接恢复异常: 未返回新进程") + } + restarted.LastUsedAt = time.Now() + log.Info("xray 桥接已同端口恢复", + logger.F("key", key), + logger.F("port", restarted.Port), + logger.F("pid", restarted.Pid), + logger.F("socks_url", socksURL), + logger.F("ref_count", refCount), + ) + go m.watchBridge(restarted, key) + return nil +} diff --git a/backend/internal/proxy/xray_bridge_store.go b/backend/internal/proxy/xray_bridge_store.go index 4b702fea..037a42c5 100644 --- a/backend/internal/proxy/xray_bridge_store.go +++ b/backend/internal/proxy/xray_bridge_store.go @@ -1,6 +1,8 @@ package proxy import ( + "ant-chrome/backend/internal/logger" + "errors" "fmt" "time" ) @@ -11,7 +13,7 @@ func (m *XrayManager) tryReuseBridge(key string, pin bool) (string, bool) { 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 alive && waitSocks5Ready("127.0.0.1", bridge.Port, 800*time.Millisecond) == nil { if pin { bridge.RefCount++ } @@ -44,7 +46,7 @@ func (m *XrayManager) registerBridge(key string, bridge *XrayBridge, pin bool) ( } 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 alive && waitSocks5Ready("127.0.0.1", existing.Port, 800*time.Millisecond) == nil { if pin { existing.RefCount++ } @@ -59,9 +61,16 @@ func (m *XrayManager) registerBridge(key string, bridge *XrayBridge, pin bool) ( return socksURL, true } + transferredRefCount := 0 + if existing.Restarting && existing.RefCount > 0 { + transferredRefCount = existing.RefCount + } existing.Stopping = true delete(m.Bridges, key) duplicate = existing + if transferredRefCount > 0 && !pin { + bridge.RefCount = transferredRefCount + } } if pin { @@ -81,16 +90,40 @@ func (m *XrayManager) watchBridge(bridge *XrayBridge, key string) { if bridge == nil || bridge.Cmd == nil { return } - _ = bridge.Cmd.Wait() + _ = bridge.waitExit() + var shouldRestart bool + var refCount int m.mu.Lock() if current, ok := m.Bridges[key]; ok && current == bridge { - delete(m.Bridges, key) + refCount = bridge.RefCount + if !bridge.Stopping && refCount > 0 && !bridge.Restarting { + bridge.Restarting = true + shouldRestart = true + } else { + delete(m.Bridges, key) + } } bridge.Running = false stopping := bridge.Stopping m.mu.Unlock() + if shouldRestart { + log := logger.New("Xray") + if err := m.restartPinnedBridge(log, key, bridge, refCount); err == nil { + return + } else if errors.Is(err, errXrayBridgeRestartNotNeeded) { + return + } else { + log.Error("xray 桥接同端口恢复失败", logger.F("key", key), logger.F("port", bridge.Port), logger.F("error", err.Error())) + m.mu.Lock() + if current, ok := m.Bridges[key]; ok && current == bridge { + delete(m.Bridges, key) + } + m.mu.Unlock() + } + } + if !stopping && m.OnBridgeDied != nil { m.OnBridgeDied(key, fmt.Errorf("xray 桥接进程意外退出")) } diff --git a/backend/internal/proxy/xray_launch_lock.go b/backend/internal/proxy/xray_launch_lock.go new file mode 100644 index 00000000..342f0d64 --- /dev/null +++ b/backend/internal/proxy/xray_launch_lock.go @@ -0,0 +1,36 @@ +package proxy + +import "sync" + +type xrayLaunchLock struct { + mu sync.Mutex + refs int +} + +func (m *XrayManager) lockLaunchForKey(key string) func() { + if m == nil { + return func() {} + } + m.mu.Lock() + if m.launchLocks == nil { + m.launchLocks = make(map[string]*xrayLaunchLock) + } + lock := m.launchLocks[key] + if lock == nil { + lock = &xrayLaunchLock{} + m.launchLocks[key] = lock + } + lock.refs++ + m.mu.Unlock() + + lock.mu.Lock() + return func() { + lock.mu.Unlock() + m.mu.Lock() + lock.refs-- + if lock.refs <= 0 && m.launchLocks[key] == lock { + delete(m.launchLocks, key) + } + m.mu.Unlock() + } +} diff --git a/backend/internal/proxy/xray_launch_lock_test.go b/backend/internal/proxy/xray_launch_lock_test.go new file mode 100644 index 00000000..2f2dc9bb --- /dev/null +++ b/backend/internal/proxy/xray_launch_lock_test.go @@ -0,0 +1,58 @@ +package proxy + +import ( + "sync/atomic" + "testing" + "time" +) + +func TestXrayLaunchLockSerializesSameKey(t *testing.T) { + t.Parallel() + + manager := &XrayManager{} + unlockFirst := manager.lockLaunchForKey("node-a") + acquiredSecond := make(chan struct{}) + go func() { + unlockSecond := manager.lockLaunchForKey("node-a") + defer unlockSecond() + close(acquiredSecond) + }() + + select { + case <-acquiredSecond: + t.Fatalf("same-key launch lock was not serialized") + case <-time.After(30 * time.Millisecond): + } + + unlockFirst() + select { + case <-acquiredSecond: + case <-time.After(time.Second): + t.Fatalf("same-key launch lock did not release") + } +} + +func TestXrayLaunchLockAllowsDifferentKeys(t *testing.T) { + t.Parallel() + + manager := &XrayManager{} + unlockFirst := manager.lockLaunchForKey("node-a") + defer unlockFirst() + var acquired int32 + done := make(chan struct{}) + go func() { + unlockSecond := manager.lockLaunchForKey("node-b") + defer unlockSecond() + atomic.StoreInt32(&acquired, 1) + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatalf("different-key launch lock was unexpectedly blocked") + } + if atomic.LoadInt32(&acquired) != 1 { + t.Fatalf("different-key launch lock was not acquired") + } +} diff --git a/backend/internal/proxy/xray_runtime_helpers_test.go b/backend/internal/proxy/xray_runtime_helpers_test.go index 30d9b83e..ca5d449f 100644 --- a/backend/internal/proxy/xray_runtime_helpers_test.go +++ b/backend/internal/proxy/xray_runtime_helpers_test.go @@ -1,8 +1,11 @@ package proxy import ( + "io" + "net" "reflect" "testing" + "time" ) func TestParseDnsConfigFromClashYAML(t *testing.T) { @@ -49,3 +52,50 @@ func TestNormalizeNodeScheme(t *testing.T) { t.Fatalf("normalizeNodeScheme() unexpectedly changed vmess: %q", got) } } + +func TestWaitPortReadyIncludesLastDialError(t *testing.T) { + t.Parallel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen failed: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + + err = waitPortReady("127.0.0.1", port, 50*time.Millisecond) + if err == nil { + t.Fatalf("expected waitPortReady error") + } + if got := err.Error(); got == "" || got == "端口 0 不可用" { + t.Fatalf("expected detailed port error, got %q", got) + } +} + +func TestWaitSocks5ReadyRequiresHandshake(t *testing.T) { + t.Parallel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen failed: %v", err) + } + defer listener.Close() + port := listener.Addr().(*net.TCPAddr).Port + done := make(chan struct{}) + go func() { + defer close(done) + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + buf := make([]byte, 3) + _, _ = io.ReadFull(conn, buf) + _, _ = conn.Write([]byte{0x05, 0x00}) + }() + + if err := waitSocks5Ready("127.0.0.1", port, time.Second); err != nil { + t.Fatalf("waitSocks5Ready() error = %v", err) + } + <-done +} diff --git a/backend/internal/proxy/xray_test.go b/backend/internal/proxy/xray_test.go index 1fbfbf00..698188a4 100644 --- a/backend/internal/proxy/xray_test.go +++ b/backend/internal/proxy/xray_test.go @@ -1,6 +1,12 @@ package proxy -import "testing" +import ( + "fmt" + "net" + "os" + "path/filepath" + "testing" +) func TestXrayRegisterBridgeStoresNewBridge(t *testing.T) { t.Parallel() @@ -53,3 +59,81 @@ func TestXrayRegisterBridgeIgnoresSamePointer(t *testing.T) { t.Fatalf("same bridge pointer should not be marked as stopping") } } + +func TestXrayLaunchErrorRetryClassification(t *testing.T) { + t.Parallel() + + if isRetryableXrayLaunchError(&xrayLaunchError{err: fmt.Errorf("config invalid"), retryable: false}) { + t.Fatalf("non-retryable xray launch error was classified as retryable") + } + if !isRetryableXrayLaunchError(&xrayLaunchError{err: fmt.Errorf("port race"), retryable: true}) { + t.Fatalf("retryable xray launch error was classified as non-retryable") + } + if !isRetryableXrayLaunchError(fmt.Errorf("legacy error")) { + t.Fatalf("plain errors should remain retryable for backward compatibility") + } +} + +func TestXrayBridgeReadyErrorRetryPolicy(t *testing.T) { + t.Parallel() + + manager := &XrayManager{} + dir := t.TempDir() + cfgPath := filepath.Join(dir, "xray-config.json") + stderrPath := filepath.Join(dir, "xray-stderr.log") + if err := os.WriteFile(cfgPath, []byte(`{}`), 0o644); err != nil { + t.Fatalf("write config failed: %v", err) + } + + if manager.isRetryableBridgeReadyError(fmt.Errorf("xray 进程提前退出: config invalid"), cfgPath, stderrPath) { + t.Fatalf("early process exit without bind evidence should not be retried") + } + if err := os.WriteFile(stderrPath, []byte("listen tcp 127.0.0.1:10001: bind: address already in use"), 0o644); err != nil { + t.Fatalf("write stderr failed: %v", err) + } + if !manager.isRetryableBridgeReadyError(fmt.Errorf("xray 进程提前退出"), cfgPath, stderrPath) { + t.Fatalf("bind conflict should be retried with another port") + } +} + +func TestXrayRegisterBridgeTransfersRestartingRefCount(t *testing.T) { + t.Parallel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen failed: %v", err) + } + defer listener.Close() + port := listener.Addr().(*net.TCPAddr).Port + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + _ = conn.Close() + } + }() + + manager := &XrayManager{Bridges: make(map[string]*XrayBridge)} + oldBridge := &XrayBridge{ + NodeKey: "node-a", + Port: 21001, + Running: false, + RefCount: 2, + Restarting: true, + } + manager.Bridges["node-a"] = oldBridge + newBridge := &XrayBridge{NodeKey: "node-a", Port: port, Running: true} + + socksURL, reused := manager.registerBridge("node-a", newBridge, false) + if reused { + t.Fatalf("expected new restarted bridge registration, got reused %q", socksURL) + } + if manager.Bridges["node-a"] != newBridge { + t.Fatalf("new bridge was not registered") + } + if newBridge.RefCount != 2 { + t.Fatalf("restart refcount = %d, want 2", newBridge.RefCount) + } +} diff --git a/frontend/src/modules/browser/api/proxies.ts b/frontend/src/modules/browser/api/proxies.ts index 1f4d9814..afca7030 100644 --- a/frontend/src/modules/browser/api/proxies.ts +++ b/frontend/src/modules/browser/api/proxies.ts @@ -1,4 +1,4 @@ -import type { BrowserProxy, ProxyIPHealthResult } from '../types' +import type { BrowserProxy, ProxyBridgeWarmupResult, ProxyIPHealthResult, ProxyLocationResolveResult } from '../types' import { getBindings, getGoApp, getMockProxies, nowISOString, setMockProxies } from './runtime' export interface ClashImportURLResult { @@ -117,6 +117,46 @@ export async function browserProxyBatchTestSpeed(proxyIds: string[], concurrency return proxyIds.map((proxyId) => ({ proxyId, ok: true, latencyMs: Math.floor(100 + Math.random() * 400), error: '' })) } +export async function browserProxyWarmupBridge(proxyId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProxyWarmupBridge) { + return (await bindings.BrowserProxyWarmupBridge(proxyId)) || { + proxyId, + ok: false, + engine: '', + socksUrl: '', + latencyMs: 0, + error: '调用失败', + } + } + await sleep(200) + return { proxyId, ok: true, engine: 'mock', socksUrl: '', latencyMs: 0, error: '' } +} + +export async function browserProxyWarmupBridgeWithConfig(proxyId: string, proxyConfig: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProxyWarmupBridgeWithConfig) { + return (await bindings.BrowserProxyWarmupBridgeWithConfig(proxyId, proxyConfig)) || { + proxyId, + ok: false, + engine: '', + socksUrl: '', + latencyMs: 0, + error: '调用失败', + } + } + return browserProxyWarmupBridge(proxyId) +} + +export async function browserProxyBatchWarmupBridge(proxyIds: string[], concurrency: number = 5): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProxyBatchWarmupBridge) { + return (await bindings.BrowserProxyBatchWarmupBridge(proxyIds, concurrency)) || [] + } + await sleep(400) + return proxyIds.map((proxyId) => ({ proxyId, ok: true, engine: 'mock', socksUrl: '', latencyMs: 0, error: '' })) +} + export async function browserProxyCheckIPHealth(proxyId: string): Promise { const bindings: any = await getBindings() if (bindings?.BrowserProxyCheckIPHealth) { @@ -159,6 +199,42 @@ export async function browserProxyCheckIPHealth(proxyId: string): Promise { + const bindings: any = await getBindings() + if (bindings?.BrowserProxyResolveLocation) { + return (await bindings.BrowserProxyResolveLocation(proxyId)) || { + proxyId, + ok: false, + auto: false, + source: 'location', + error: '调用失败', + ip: '', + country: '', + region: '', + city: '', + timezone: '', + lang: '', + resolvedAt: nowISOString(), + } + } + + await sleep(400) + return { + proxyId, + ok: true, + auto: true, + source: 'mock', + error: '', + ip: '127.0.0.1', + country: 'US', + region: 'New York', + city: 'New York', + timezone: 'America/New_York', + lang: 'en-US', + resolvedAt: nowISOString(), + } +} + export async function browserProxyBatchCheckIPHealth(proxyIds: string[], concurrency: number = 10): Promise { const bindings: any = await getBindings() if (bindings?.BrowserProxyBatchCheckIPHealth) { diff --git a/frontend/src/modules/browser/components/FingerprintPanel.tsx b/frontend/src/modules/browser/components/FingerprintPanel.tsx index abeb4470..5adf5aa6 100644 --- a/frontend/src/modules/browser/components/FingerprintPanel.tsx +++ b/frontend/src/modules/browser/components/FingerprintPanel.tsx @@ -34,12 +34,21 @@ const PLATFORM_OPTIONS = [ const LANG_OPTIONS = [ { value: '', label: '不设置' }, { value: 'zh-CN', label: '中文 (zh-CN)' }, - { value: 'en-US', label: 'English (en-US)' }, - { value: 'en-GB', label: 'English (en-GB)' }, + { value: 'zh-HK', label: '繁體中文香港 (zh-HK)' }, + { value: 'zh-TW', label: '繁體中文台灣 (zh-TW)' }, + { value: 'en-US', label: 'English US (en-US)' }, + { value: 'en-GB', label: 'English UK (en-GB)' }, + { value: 'en-CA', label: 'English Canada (en-CA)' }, + { value: 'en-AU', label: 'English Australia (en-AU)' }, + { value: 'en-SG', label: 'English Singapore (en-SG)' }, + { value: 'en-IN', label: 'English India (en-IN)' }, { value: 'ja-JP', label: '日本語 (ja-JP)' }, { value: 'ko-KR', label: '한국어 (ko-KR)' }, { value: 'fr-FR', label: 'Français (fr-FR)' }, { value: 'de-DE', label: 'Deutsch (de-DE)' }, + { value: 'nl-NL', label: 'Nederlands (nl-NL)' }, + { value: 'ru-RU', label: 'Русский (ru-RU)' }, + { value: 'pt-BR', label: 'Português Brasil (pt-BR)' }, ] const TIMEZONE_OPTIONS = [ @@ -51,6 +60,7 @@ const TIMEZONE_OPTIONS = [ { value: 'Asia/Seoul', label: 'Asia/Seoul (UTC+9)' }, { value: 'Asia/Singapore', label: 'Asia/Singapore (UTC+8)' }, { value: 'Asia/Hong_Kong', label: 'Asia/Hong_Kong (UTC+8)' }, + { value: 'Asia/Taipei', label: 'Asia/Taipei (UTC+8)' }, { value: 'Asia/Dubai', label: 'Asia/Dubai (UTC+4)' }, { value: 'Asia/Kolkata', label: 'Asia/Kolkata (UTC+5:30)' }, // 美洲 @@ -59,6 +69,8 @@ const TIMEZONE_OPTIONS = [ { value: 'America/Chicago', label: 'America/Chicago (UTC-6)' }, { value: 'America/Denver', label: 'America/Denver (UTC-7)' }, { value: 'America/Toronto', label: 'America/Toronto (UTC-5)' }, + { value: 'America/Vancouver', label: 'America/Vancouver (UTC-8)' }, + { value: 'America/Phoenix', label: 'America/Phoenix (UTC-7)' }, { value: 'America/Sao_Paulo', label: 'America/Sao_Paulo (UTC-3)' }, // EMEA { value: 'Europe/London', label: 'Europe/London (UTC+0)' }, @@ -67,6 +79,8 @@ const TIMEZONE_OPTIONS = [ { value: 'Europe/Moscow', label: 'Europe/Moscow (UTC+3)' }, // 大洋洲 { value: 'Australia/Sydney', label: 'Australia/Sydney (UTC+10)' }, + { value: 'Australia/Melbourne', label: 'Australia/Melbourne (UTC+10)' }, + { value: 'Australia/Perth', label: 'Australia/Perth (UTC+8)' }, { value: 'Pacific/Auckland', label: 'Pacific/Auckland (UTC+12)' }, ] diff --git a/frontend/src/modules/browser/pages/BrowserDetailPage.tsx b/frontend/src/modules/browser/pages/BrowserDetailPage.tsx index 03ae0d7b..6bb4f8e9 100644 --- a/frontend/src/modules/browser/pages/BrowserDetailPage.tsx +++ b/frontend/src/modules/browser/pages/BrowserDetailPage.tsx @@ -17,6 +17,7 @@ import { import { CookieManagerCard } from '../components/CookieManagerCard' import { SnapshotTab } from '../components/SnapshotTab' import { resolveActionErrorMessage, resolveActionFeedback } from '../utils/actionErrors' +import { warmupProfileProxyBeforeStart } from '../utils/proxyWarmup' const resolveRuntimeStatus = (running: boolean, debugReady: boolean) => { if (!running) return { variant: 'warning' as const, label: '已停止' } @@ -128,6 +129,7 @@ export function BrowserDetailPage() { const handleStart = async () => { setPendingAction('starting') try { + await warmupProfileProxyBeforeStart(profile) const startedProfile = await startBrowserInstance(profile.profileId) if (startedProfile) { setProfile(startedProfile) @@ -169,6 +171,7 @@ export function BrowserDetailPage() { const handleRestart = async () => { setPendingAction('restarting') try { + await warmupProfileProxyBeforeStart(profile) const restartedProfile = await restartBrowserInstance(profile.profileId) if (restartedProfile) { setProfile(restartedProfile) diff --git a/frontend/src/modules/browser/pages/BrowserEditPage.tsx b/frontend/src/modules/browser/pages/BrowserEditPage.tsx index 8f101418..2305cbec 100644 --- a/frontend/src/modules/browser/pages/BrowserEditPage.tsx +++ b/frontend/src/modules/browser/pages/BrowserEditPage.tsx @@ -2,9 +2,10 @@ import { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { FolderOpen, Layers } from 'lucide-react' import { Button, Card, ConfirmModal, FormItem, Input, Modal, Select, Textarea, toast } from '../../../shared/components' -import type { BrowserCore, BrowserProfileInput, BrowserProxy, BrowserGroup } from '../types' -import { createBrowserProfile, fetchAllTags, fetchBrowserCores, fetchBrowserProfiles, fetchBrowserProxies, fetchBrowserSettings, fetchGroups, openUserDataDir, updateBrowserProfile, validateProxyConfig } from '../api' +import type { BrowserCore, BrowserProfileInput, BrowserProxy, BrowserGroup, ProxyLocationResolveResult } from '../types' +import { browserProxyResolveLocation, createBrowserProfile, fetchAllTags, fetchBrowserCores, fetchBrowserProfiles, fetchBrowserProxies, fetchBrowserSettings, fetchGroups, openUserDataDir, updateBrowserProfile, validateProxyConfig } from '../api' import { FingerprintPanel } from '../components/FingerprintPanel' +import { applyLocaleToFingerprintArgs } from '../utils/fingerprintSerializer' import { TagInput } from '../components/TagInput' import { GroupSelector } from '../components/GroupSelector' import { ProxyPickerModal } from '../components/ProxyPickerModal' @@ -76,6 +77,8 @@ export function BrowserEditPage() { const [isDirty, setIsDirty] = useState(false) const [leaveConfirm, setLeaveConfirm] = useState(false) const [saveError, setSaveError] = useState('') + const [locationResolving, setLocationResolving] = useState(false) + const [locationResult, setLocationResult] = useState(null) useEffect(() => { const loadData = async () => { @@ -198,6 +201,30 @@ export function BrowserEditPage() { if (isDirty) { setLeaveConfirm(true) } else { navigate('/browser/list') } } + const handleApplyProxyLocation = async () => { + if (proxyMode !== 'pool' || !formData.proxyId || formData.proxyId === directProxyID) { + toast.error('请选择代理池中的非直连节点') + return + } + setLocationResolving(true) + setLocationResult(null) + try { + const result = await browserProxyResolveLocation(formData.proxyId) + setLocationResult(result) + if (!result.ok || !result.lang || !result.timezone) { + toast.error(result.error || '无法根据代理 IP 匹配定位') + return + } + const nextArgs = applyLocaleToFingerprintArgs(formData.fingerprintArgs, result.lang, result.timezone) + handleChange('fingerprintArgs', nextArgs) + toast.success(`已设置 ${result.lang} / ${result.timezone}`) + } catch (error: unknown) { + toast.error((error as Error)?.message || '代理定位失败') + } finally { + setLocationResolving(false) + } + } + const defaultCore = cores.find(c => c.isDefault) const selectedPoolProxy = proxies.find((proxy) => proxy.proxyId === formData.proxyId) @@ -314,7 +341,7 @@ export function BrowserEditPage() {