From 50e292f27ea93faad931249a67288959bd354442 Mon Sep 17 00:00:00 2001 From: ant-black <1016930479@qq.com> Date: Tue, 5 May 2026 22:52:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=80=E7=BB=88=E5=BE=85=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/automation_demo_api.go | 12 +- backend/automation_script_run_entry.go | 42 ++++++- backend/automation_script_run_launch_api.go | 40 ++++++- backend/automation_script_run_playwright.go | 23 +++- .../internal/automation/script_run_store.go | 1 + backend/internal/automation/sysproc_others.go | 9 +- .../internal/automation/sysproc_windows.go | 4 + .../internal/automation/task_runner_exec.go | 58 ++++++++- .../automation/task_runner_process.go | 4 + .../automation/task_runner_process_others.go | 10 ++ .../automation/task_runner_process_windows.go | 8 ++ .../internal/automation/task_runner_test.go | 60 ++++++++++ .../internal/automation/task_runner_types.go | 3 + backend/internal/launchcode/automation_api.go | 2 + backend/internal/proxy/http_client.go | 11 ++ backend/internal/proxy/speedtest.go | 25 +++- backend/internal/proxy/utils_connectivity.go | 21 +++- backend/internal/proxy/xray.go | 9 ++ backend/internal/proxy/xray_bridge_launch.go | 110 +++++++++++++++--- backend/internal/proxy/xray_chain_test.go | 86 ++++++++++++++ backend/internal/proxy/xray_runtime_config.go | 29 +++-- backend/internal/proxy/xray_validate_test.go | 23 ++++ .../modules/browser/automationScriptApi.ts | 4 + .../src/modules/browser/automationScripts.ts | 1 + frontend/src/wailsjs/go/models.ts | 4 +- 25 files changed, 552 insertions(+), 47 deletions(-) create mode 100644 backend/internal/automation/task_runner_process_others.go create mode 100644 backend/internal/automation/task_runner_process_windows.go create mode 100644 backend/internal/proxy/xray_chain_test.go diff --git a/backend/automation_demo_api.go b/backend/automation_demo_api.go index d898f2bb..84c61e22 100644 --- a/backend/automation_demo_api.go +++ b/backend/automation_demo_api.go @@ -116,13 +116,20 @@ func (a *App) AutomationDemoDeleteProfile(profileId string) (map[string]interfac } func (a *App) automationDemoRequest(method string, apiPath string, body any) (int, map[string]interface{}, error) { + return a.automationDemoRequestWithContext(context.Background(), method, apiPath, body) +} + +func (a *App) automationDemoRequestWithContext(ctx context.Context, method string, apiPath string, body any) (int, map[string]interface{}, error) { + if ctx == nil { + ctx = context.Background() + } baseURL, authHeader, authValue, err := a.automationDemoEndpoint() if err != nil { return 0, nil, err } requestURL := strings.TrimRight(baseURL, "/") + apiPath - ctx, cancel := context.WithTimeout(context.Background(), automationDemoTimeout) + ctx, cancel := context.WithTimeout(ctx, automationDemoTimeout) defer cancel() var reader io.Reader @@ -147,6 +154,9 @@ func (a *App) automationDemoRequest(method string, apiPath string, body any) (in resp, err := (&http.Client{Timeout: automationDemoTimeout}).Do(req) if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return 0, nil, fmt.Errorf("call launch api failed: %w", ctxErr) + } return 0, nil, fmt.Errorf("call launch api failed: %w", err) } defer resp.Body.Close() diff --git a/backend/automation_script_run_entry.go b/backend/automation_script_run_entry.go index 0345c402..3b30de2a 100644 --- a/backend/automation_script_run_entry.go +++ b/backend/automation_script_run_entry.go @@ -1,6 +1,7 @@ package backend import ( + "context" "fmt" "path/filepath" "strings" @@ -9,6 +10,12 @@ import ( "ant-chrome/backend/internal/automation" ) +const ( + automationScriptRunDefaultTimeout = 5 * time.Minute + automationScriptRunMinTimeout = 1 * time.Second + automationScriptRunMaxTimeout = 30 * time.Minute +) + func (a *App) automationScriptRunStore() *automation.ScriptRunStore { return automation.NewScriptRunStore(a.resolveAppPath(filepath.ToSlash(filepath.Join("data", "automation", "runs")))) } @@ -43,9 +50,16 @@ func (a *App) AutomationScriptRunWithOptions(input automation.ScriptRunRequest) run.ScriptName = script.Name run.ScriptType = script.Type + runCtx := a.ctx + if runCtx == nil { + runCtx = context.Background() + } + runCtx, cancel := context.WithTimeout(runCtx, automationScriptRunTimeout(input)) + defer cancel() + switch script.Type { case "launch-api": - resultText, summary, errText := a.runLaunchAPIScript(script, input) + resultText, summary, errText := a.runLaunchAPIScript(runCtx, script, input) run.ResultText = resultText run.Summary = summary run.Error = errText @@ -53,7 +67,7 @@ func (a *App) AutomationScriptRunWithOptions(input automation.ScriptRunRequest) run.Status = "success" } case "playwright-cdp": - resultText, summary, errText := a.runPlaywrightScript(script, input) + resultText, summary, errText := a.runPlaywrightScript(runCtx, script, input) run.ResultText = resultText run.Summary = summary run.Error = errText @@ -68,6 +82,30 @@ func (a *App) AutomationScriptRunWithOptions(input automation.ScriptRunRequest) return a.finalizeAutomationScriptRun(run, startedAt) } +func automationScriptRunTimeout(input automation.ScriptRunRequest) time.Duration { + if input.TimeoutMs <= 0 { + return automationScriptRunDefaultTimeout + } + timeout := time.Duration(input.TimeoutMs) * time.Millisecond + if timeout < automationScriptRunMinTimeout { + return automationScriptRunMinTimeout + } + if timeout > automationScriptRunMaxTimeout { + return automationScriptRunMaxTimeout + } + return timeout +} + +func automationRunContextErrorMessage(err error) string { + if err == context.DeadlineExceeded { + return "自动化任务超时,已终止" + } + if err == context.Canceled { + return "自动化任务已取消" + } + return err.Error() +} + func (a *App) finalizeAutomationScriptRun(run automation.ScriptRunRecord, startedAt time.Time) (*automation.ScriptRunRecord, error) { run.FinishedAt = time.Now().Format(time.RFC3339) run.DurationMs = time.Since(startedAt).Milliseconds() diff --git a/backend/automation_script_run_launch_api.go b/backend/automation_script_run_launch_api.go index f55b90f5..82820720 100644 --- a/backend/automation_script_run_launch_api.go +++ b/backend/automation_script_run_launch_api.go @@ -1,6 +1,7 @@ package backend import ( + "context" "encoding/json" "fmt" "net/http" @@ -34,10 +35,16 @@ type dualInstanceRuntimeBrowser struct { LaunchArgs []string } -func (a *App) runLaunchAPIScript(script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) { +func (a *App) runLaunchAPIScript(ctx context.Context, script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return "", "脚本执行失败", automationRunContextErrorMessage(err) + } paramsText := resolveAutomationRunJSONText(input.ParamsText, script.ParamsText, input.UseScriptParams) if script.ID == automation.DualInstanceRuntimeScriptID { - return a.runDualInstanceRuntimeLaunchAPIScript(paramsText) + return a.runDualInstanceRuntimeLaunchAPIScript(ctx, paramsText) } selector, targetSummary, err := a.resolveAutomationEffectiveSelector(script, input, true) @@ -55,8 +62,11 @@ func (a *App) runLaunchAPIScript(script automation.ScriptRecord, input automatio body[key] = value } - status, payload, reqErr := a.automationDemoRequest(http.MethodPost, automationDemoLaunchPath, body) + status, payload, reqErr := a.automationDemoRequestWithContext(ctx, http.MethodPost, automationDemoLaunchPath, body) if reqErr != nil { + if err := ctx.Err(); err != nil { + return "", "Launch API 请求失败", automationRunContextErrorMessage(err) + } return "", "Launch API 请求失败", reqErr.Error() } @@ -80,7 +90,10 @@ func (a *App) runLaunchAPIScript(script automation.ScriptRecord, input automatio return responseText, summary, errorText } -func (a *App) runDualInstanceRuntimeLaunchAPIScript(paramsText string) (string, string, string) { +func (a *App) runDualInstanceRuntimeLaunchAPIScript(ctx context.Context, paramsText string) (string, string, string) { + if ctx == nil { + ctx = context.Background() + } browsers, timeoutMs, err := parseDualInstanceRuntimeParams(paramsText) if err != nil { return "", "脚本执行失败", err.Error() @@ -90,7 +103,16 @@ func (a *App) runDualInstanceRuntimeLaunchAPIScript(paramsText string) (string, browserCodes := make([]string, 0, len(browsers)) for _, browser := range browsers { - sessionStatus, sessionPayload, reqErr := a.automationDemoRequest( + if err := ctx.Err(); err != nil { + return buildDualInstanceRuntimeFailureResult( + sessions, + browserCodes, + "双实例流程超时", + automationRunContextErrorMessage(err), + ) + } + sessionStatus, sessionPayload, reqErr := a.automationDemoRequestWithContext( + ctx, http.MethodPost, automationDemoRuntimeSessionPath, map[string]any{ @@ -107,6 +129,14 @@ func (a *App) runDualInstanceRuntimeLaunchAPIScript(paramsText string) (string, sessionPayload = ensureAutomationPayload(sessionPayload, browser.Code) sessions = append(sessions, sessionPayload) if reqErr != nil { + if err := ctx.Err(); err != nil { + return buildDualInstanceRuntimeFailureResult( + sessions, + browserCodes, + "双实例流程超时", + automationRunContextErrorMessage(err), + ) + } return buildDualInstanceRuntimeFailureResult( sessions, browserCodes, diff --git a/backend/automation_script_run_playwright.go b/backend/automation_script_run_playwright.go index 591b4d28..9a17737c 100644 --- a/backend/automation_script_run_playwright.go +++ b/backend/automation_script_run_playwright.go @@ -1,6 +1,7 @@ package backend import ( + "context" "fmt" "strings" @@ -28,16 +29,25 @@ func (a *App) ensurePlaywrightTargetReady(selector map[string]any) error { return nil } -func (a *App) runPlaywrightScript(script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) { +func (a *App) runPlaywrightScript(ctx context.Context, script automation.ScriptRecord, input automation.ScriptRunRequest) (string, string, string) { + if ctx == nil { + ctx = context.Background() + } if a.automationMgr == nil { return "", "脚本执行失败", "automation runtime manager is not initialized" } if a.config == nil || !a.config.Automation.Enabled { return "", "脚本执行失败", "自动化支持尚未启用" } - if err := a.automationMgr.EnsureInstalled(a.ctx); err != nil { + if err := ctx.Err(); err != nil { + return "", "脚本执行失败", automationRunContextErrorMessage(err) + } + if err := a.automationMgr.EnsureInstalled(ctx); err != nil { return "", "脚本执行失败", err.Error() } + if err := ctx.Err(); err != nil { + return "", "脚本执行失败", automationRunContextErrorMessage(err) + } state := a.automationMgr.CurrentState() if !state.Ready { @@ -53,6 +63,9 @@ func (a *App) runPlaywrightScript(script automation.ScriptRecord, input automati if err := a.ensurePlaywrightTargetReady(selector); err != nil { return "", "脚本执行失败", err.Error() } + if err := ctx.Err(); err != nil { + return "", "脚本执行失败", automationRunContextErrorMessage(err) + } params, err := parseAutomationJSONObject(paramsText, false) if err != nil { return "", "脚本执行失败", err.Error() @@ -68,8 +81,11 @@ func (a *App) runPlaywrightScript(script automation.ScriptRecord, input automati return "", "脚本执行失败", err.Error() } defer cleanup() + if err := ctx.Err(); err != nil { + return "", "脚本执行失败", automationRunContextErrorMessage(err) + } - taskResult, err := a.automationMgr.RunScriptTask(a.ctx, automation.ScriptTaskRequest{ + taskResult, err := a.automationMgr.RunScriptTask(ctx, automation.ScriptTaskRequest{ TaskKey: "script:" + script.ID, ScriptPath: scriptPath, Selector: selector, @@ -78,6 +94,7 @@ func (a *App) runPlaywrightScript(script automation.ScriptRecord, input automati LaunchAuthHeader: authHeader, LaunchAuthValue: authValue, ArtifactDir: artifactDir, + Timeout: automationScriptRunTimeout(input), }) if err != nil { return "", "脚本执行失败", err.Error() diff --git a/backend/internal/automation/script_run_store.go b/backend/internal/automation/script_run_store.go index 41f0841b..571d057d 100644 --- a/backend/internal/automation/script_run_store.go +++ b/backend/internal/automation/script_run_store.go @@ -32,6 +32,7 @@ type ScriptRunRequest struct { ParamsText string `json:"paramsText"` UseScriptSelector bool `json:"useScriptSelector"` UseScriptParams bool `json:"useScriptParams"` + TimeoutMs int `json:"timeoutMs,omitempty"` } type ScriptRunStore struct { diff --git a/backend/internal/automation/sysproc_others.go b/backend/internal/automation/sysproc_others.go index 32cd94c7..169e08c3 100644 --- a/backend/internal/automation/sysproc_others.go +++ b/backend/internal/automation/sysproc_others.go @@ -3,7 +3,14 @@ package automation -import "os/exec" +import ( + "os/exec" + "syscall" +) func hideWindow(cmd *exec.Cmd) { } + +func prepareTaskCommand(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} diff --git a/backend/internal/automation/sysproc_windows.go b/backend/internal/automation/sysproc_windows.go index 0b19cb56..e082009e 100644 --- a/backend/internal/automation/sysproc_windows.go +++ b/backend/internal/automation/sysproc_windows.go @@ -11,3 +11,7 @@ import ( func hideWindow(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} } + +func prepareTaskCommand(cmd *exec.Cmd) { + hideWindow(cmd) +} diff --git a/backend/internal/automation/task_runner_exec.go b/backend/internal/automation/task_runner_exec.go index 61cf5e16..e34f8862 100644 --- a/backend/internal/automation/task_runner_exec.go +++ b/backend/internal/automation/task_runner_exec.go @@ -21,6 +21,14 @@ func (m *Manager) RunScriptTask(ctx context.Context, req ScriptTaskRequest) (Scr if ctx == nil { ctx = context.Background() } + timeoutLimit := req.Timeout + if req.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, req.Timeout) + defer cancel() + } else if deadline, ok := ctx.Deadline(); ok { + timeoutLimit = time.Until(deadline) + } state := m.CurrentState() if !state.Ready { @@ -58,6 +66,7 @@ func (m *Manager) RunScriptTask(ctx context.Context, req ScriptTaskRequest) (Scr payload, "自动化 script task 已启动", "自动化 script task 已完成", + timeoutLimit, ) if err != nil { return ScriptTaskResult{}, err @@ -87,7 +96,7 @@ func (m *Manager) RunScriptTask(ctx context.Context, req ScriptTaskRequest) (Scr return result, nil } -func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskRunnerPayload, startMessage string, completeMessage string) (string, taskRunnerResponse, string, int64, error) { +func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskRunnerPayload, startMessage string, completeMessage string, timeoutLimit time.Duration) (string, taskRunnerResponse, string, int64, error) { taskID, err := m.registerTask(taskKey) if err != nil { return "", taskRunnerResponse{}, "", 0, err @@ -103,7 +112,11 @@ func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskR state := m.CurrentState() cmd := exec.CommandContext(ctx, state.NodePath, state.RunnerPath, payloadPath) cmd.Dir = state.RuntimeDir - hideWindow(cmd) + prepareTaskCommand(cmd) + cmd.Cancel = func() error { + return stopTaskProcess(cmd) + } + cmd.WaitDelay = 5 * time.Second startedAt := time.Now() m.attachTaskCommand(taskID, cmd) @@ -118,6 +131,21 @@ func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskR output, runErr := cmd.CombinedOutput() durationMs := time.Since(startedAt).Milliseconds() if runErr != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + _ = stopTaskProcess(cmd) + message := taskContextErrorMessage(ctxErr, timeoutLimit) + m.emitTaskEvent(TaskEvent{ + TaskID: taskID, + ProfileID: taskKey, + Phase: "failed", + Message: message, + StartedAt: startedAt.Format(time.RFC3339), + FinishedAt: time.Now().Format(time.RFC3339), + DurationMs: durationMs, + }) + return "", taskRunnerResponse{}, "", durationMs, fmt.Errorf("%s", message) + } + message := strings.TrimSpace(string(output)) if message == "" { message = runErr.Error() @@ -152,6 +180,32 @@ func (m *Manager) executeTask(ctx context.Context, taskKey string, payload taskR return taskID, runnerResp, string(output), durationMs, nil } +func taskContextErrorMessage(err error, timeoutLimit time.Duration) string { + if err == context.DeadlineExceeded { + if timeoutText := formatTaskTimeout(timeoutLimit); timeoutText != "" { + return fmt.Sprintf("自动化任务超时,已终止(上限 %s)", timeoutText) + } + return "自动化任务超时,已终止" + } + if err == context.Canceled { + return "自动化任务已取消" + } + return err.Error() +} + +func formatTaskTimeout(timeout time.Duration) string { + if timeout <= 0 { + return "" + } + if timeout >= time.Minute && timeout%time.Minute == 0 { + return fmt.Sprintf("%d 分钟", int64(timeout/time.Minute)) + } + if timeout >= time.Second && timeout%time.Second == 0 { + return fmt.Sprintf("%d 秒", int64(timeout/time.Second)) + } + return fmt.Sprintf("%d 毫秒", timeout.Milliseconds()) +} + func (m *Manager) writeTaskPayload(payload taskRunnerPayload) (string, error) { tempDir := filepath.Join(m.runtimeRoot(), "tmp") if err := os.MkdirAll(tempDir, 0o755); err != nil { diff --git a/backend/internal/automation/task_runner_process.go b/backend/internal/automation/task_runner_process.go index db158163..d56289c8 100644 --- a/backend/internal/automation/task_runner_process.go +++ b/backend/internal/automation/task_runner_process.go @@ -80,6 +80,10 @@ func stopTaskProcess(cmd *exec.Cmd) error { if err := killCmd.Run(); err == nil { return nil } + } else if cmd.Process.Pid > 0 { + if err := killProcessGroup(cmd.Process.Pid); err == nil { + return nil + } } err := cmd.Process.Kill() if err == nil { diff --git a/backend/internal/automation/task_runner_process_others.go b/backend/internal/automation/task_runner_process_others.go new file mode 100644 index 00000000..a487c5e3 --- /dev/null +++ b/backend/internal/automation/task_runner_process_others.go @@ -0,0 +1,10 @@ +//go:build !windows +// +build !windows + +package automation + +import "syscall" + +func killProcessGroup(pid int) error { + return syscall.Kill(-pid, syscall.SIGKILL) +} diff --git a/backend/internal/automation/task_runner_process_windows.go b/backend/internal/automation/task_runner_process_windows.go new file mode 100644 index 00000000..1726b4cc --- /dev/null +++ b/backend/internal/automation/task_runner_process_windows.go @@ -0,0 +1,8 @@ +//go:build windows +// +build windows + +package automation + +func killProcessGroup(pid int) error { + return nil +} diff --git a/backend/internal/automation/task_runner_test.go b/backend/internal/automation/task_runner_test.go index 35217e73..d2d26963 100644 --- a/backend/internal/automation/task_runner_test.go +++ b/backend/internal/automation/task_runner_test.go @@ -395,6 +395,66 @@ func TestRunScriptTaskClosesBrowserConnections(t *testing.T) { } } +func TestRunScriptTaskTerminatesHungScriptOnTimeout(t *testing.T) { + nodeExecPath := lookupNodeExecutable(t) + + cfg := config.DefaultConfig() + cfg.Automation.Enabled = true + cfg.Automation.NodeSource = config.AutomationNodeSourceSystem + cfg.Automation.SystemNodePath = nodeExecPath + cfg.Automation.NodeVersion = "test-node" + cfg.Automation.PlaywrightCoreVersion = "1.59.0" + cfg.Automation.RuntimeVersion = "test-runtime" + + manager := NewManager(t.TempDir(), cfg, nil, Options{}) + + state := manager.CurrentState() + if err := writeRunnerScript(state.RunnerPath); err != nil { + t.Fatalf("write runner script failed: %v", err) + } + if err := writeMockPlaywrightModule(state.RuntimeDir, cfg.Automation.PlaywrightCoreVersion); err != nil { + t.Fatalf("write mock playwright module failed: %v", err) + } + + scriptDir := filepath.Join(state.RuntimeDir, "tmp", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatalf("create script dir failed: %v", err) + } + scriptPath := filepath.Join(scriptDir, "script-timeout.cjs") + scriptSource := `module.exports.run = async () => { + await new Promise(() => setInterval(() => {}, 1000)) +}` + if err := os.WriteFile(scriptPath, []byte(scriptSource), 0o644); err != nil { + t.Fatalf("write script failed: %v", err) + } + + startedAt := time.Now() + _, err := manager.RunScriptTask(context.Background(), ScriptTaskRequest{ + TaskKey: "script:timeout", + ScriptPath: scriptPath, + LaunchBaseURL: "http://127.0.0.1", + Timeout: 150 * time.Millisecond, + }) + elapsed := time.Since(startedAt) + if err == nil { + t.Fatalf("expected RunScriptTask to fail on timeout") + } + if !strings.Contains(err.Error(), "超时") { + t.Fatalf("expected timeout error, got %v", err) + } + if elapsed > 3*time.Second { + t.Fatalf("expected timeout to terminate quickly, took %s", elapsed) + } + + manager.mu.Lock() + activeTaskCount := len(manager.activeTasks) + profileTaskCount := len(manager.profileTask) + manager.mu.Unlock() + if activeTaskCount != 0 || profileTaskCount != 0 { + t.Fatalf("expected timed out task to be unregistered, active=%d profile=%d", activeTaskCount, profileTaskCount) + } +} + func lookupNodeExecutable(t *testing.T) string { t.Helper() diff --git a/backend/internal/automation/task_runner_types.go b/backend/internal/automation/task_runner_types.go index 10fce170..ba7e0e3c 100644 --- a/backend/internal/automation/task_runner_types.go +++ b/backend/internal/automation/task_runner_types.go @@ -1,5 +1,7 @@ package automation +import "time" + type ScriptTaskRequest struct { TaskKey string `json:"taskKey"` ScriptPath string `json:"scriptPath"` @@ -9,6 +11,7 @@ type ScriptTaskRequest struct { LaunchAuthHeader string `json:"launchAuthHeader,omitempty"` LaunchAuthValue string `json:"launchAuthValue,omitempty"` ArtifactDir string `json:"artifactDir,omitempty"` + Timeout time.Duration `json:"-"` } type ScriptTaskResult struct { diff --git a/backend/internal/launchcode/automation_api.go b/backend/internal/launchcode/automation_api.go index 568be5a5..9aae627c 100644 --- a/backend/internal/launchcode/automation_api.go +++ b/backend/internal/launchcode/automation_api.go @@ -17,6 +17,7 @@ type automationScriptRunAPIRequest struct { Params json.RawMessage `json:"params"` UseScriptSelector *bool `json:"useScriptSelector"` UseScriptParams *bool `json:"useScriptParams"` + TimeoutMs int `json:"timeoutMs"` } type automationScriptSummary struct { @@ -324,6 +325,7 @@ func normalizeAutomationRunRequest(req automationScriptRunAPIRequest) (automatio ParamsText: paramsText, UseScriptSelector: useScriptSelector, UseScriptParams: useScriptParams, + TimeoutMs: req.TimeoutMs, }, nil } diff --git a/backend/internal/proxy/http_client.go b/backend/internal/proxy/http_client.go index 423ccb52..9b4ab292 100644 --- a/backend/internal/proxy/http_client.go +++ b/backend/internal/proxy/http_client.go @@ -26,6 +26,17 @@ func buildProxyHTTPClient( return &http.Client{Timeout: timeout}, nil } + if IsChainSocks5Proxy(src) { + 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 IsSingBoxProtocol(src) { if singboxMgr == nil { return nil, fmt.Errorf("sing-box 管理器未初始化") diff --git a/backend/internal/proxy/speedtest.go b/backend/internal/proxy/speedtest.go index 71f2d514..96ee5b06 100644 --- a/backend/internal/proxy/speedtest.go +++ b/backend/internal/proxy/speedtest.go @@ -66,13 +66,32 @@ func SpeedTest( testURL = cfg.URLs[0] } - mapping, err := proxyConfigToMapping(src) + resolvedSrc := src + if IsChainSocks5Proxy(src) { + if xrayMgr == nil { + log.Warn("链式代理测速缺少 Xray 管理器,降级到 TCP ping", + logger.F("proxy_id", proxyId), + ) + return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log) + } + bridgeSocksURL, bridgeErr := xrayMgr.EnsureBridge(src, proxies, proxyId) + if bridgeErr != nil { + log.Warn("链式代理桥接失败,降级到 TCP ping", + logger.F("proxy_id", proxyId), + logger.F("error", bridgeErr.Error()), + ) + return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log) + } + resolvedSrc = strings.TrimSpace(bridgeSocksURL) + } + + mapping, err := proxyConfigToMapping(resolvedSrc) if err != nil { log.Warn("代理配置解析失败,降级到 TCP ping", logger.F("proxy_id", proxyId), logger.F("error", err.Error()), ) - return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log) + return tcpPingFallback(proxyId, resolvedSrc, cfg.TCPTimeout, log) } proxyInstance, err := adapter.ParseProxy(mapping) @@ -82,7 +101,7 @@ func SpeedTest( logger.F("error", err.Error()), logger.F("type", mapping["type"]), ) - return tcpPingFallback(proxyId, src, cfg.TCPTimeout, log) + return tcpPingFallback(proxyId, resolvedSrc, cfg.TCPTimeout, log) } return unifiedDelayTest(proxyId, proxyInstance, testURL, cfg.Timeout) diff --git a/backend/internal/proxy/utils_connectivity.go b/backend/internal/proxy/utils_connectivity.go index 50a2ccd9..f3e5f2f4 100644 --- a/backend/internal/proxy/utils_connectivity.go +++ b/backend/internal/proxy/utils_connectivity.go @@ -79,7 +79,26 @@ func TestRealConnectivityWithSingBox( var client *http.Client - if IsSingBoxProtocol(src) { + if IsChainSocks5Proxy(src) { + 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)} + } + 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 IsSingBoxProtocol(src) { if singboxMgr == nil { return TestResult{ProxyId: proxyId, Ok: false, Error: "sing-box 管理器未初始化,无法测试 hysteria2"} } diff --git a/backend/internal/proxy/xray.go b/backend/internal/proxy/xray.go index 9036f1a7..a673fcf6 100644 --- a/backend/internal/proxy/xray.go +++ b/backend/internal/proxy/xray.go @@ -65,6 +65,12 @@ func ValidateProxyConfig(proxyConfig string, proxies []config.BrowserProxy, prox if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "socks5://") { return true, "" } + if IsChainSocks5Proxy(src) { + if _, err := ParseChainSocks5Config(src); err != nil { + return false, fmt.Sprintf("链式代理配置解析失败: %v", err) + } + return true, "" + } if IsSingBoxProtocol(src) { if _, err := BuildSingBoxOutbound(src); err != nil { return false, fmt.Sprintf("代理配置解析失败: %v", err) @@ -102,6 +108,9 @@ func RequiresBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId s if strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") || strings.HasPrefix(l, "socks5://") { return false } + if IsChainSocks5Proxy(src) { + return true + } if strings.HasPrefix(l, "hysteria://") || strings.HasPrefix(l, "hysteria2://") { return false } diff --git a/backend/internal/proxy/xray_bridge_launch.go b/backend/internal/proxy/xray_bridge_launch.go index a1c42257..28198c9d 100644 --- a/backend/internal/proxy/xray_bridge_launch.go +++ b/backend/internal/proxy/xray_bridge_launch.go @@ -28,16 +28,51 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP 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("节点解析失败") + + var ( + outbounds []interface{} + routes []interface{} + preferredPort int + ) + + if IsChainSocks5Proxy(src) { + chainCfg, err := ParseChainSocks5Config(src) + if err != nil { + log.Error("链式节点解析失败", logger.F("error", err)) + return "", "", err + } + outbounds = []interface{}{ + chainSocks5Outbound(chainCfg.First, "first-hop", ""), + chainSocks5Outbound(chainCfg.Second, "second-hop", "first-hop"), + } + routes = []interface{}{ + map[string]interface{}{ + "type": "field", + "inboundTag": []string{"socks-in"}, + "outboundTag": "second-hop", + }, + } + preferredPort = chainCfg.LocalPort + } else { + 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("节点解析失败") + } + outbounds = []interface{}{outbound} + routes = []interface{}{ + map[string]interface{}{ + "type": "field", + "inboundTag": []string{"socks-in"}, + "outboundTag": "proxy-out", + }, + } } key := computeNodeKey(src + "\x00" + dnsServers) @@ -52,10 +87,13 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP return "", "", err } - const maxLaunchRetries = 3 + maxLaunchRetries := 3 + if preferredPort > 0 { + maxLaunchRetries = 1 + } var lastErr error for attempt := 1; attempt <= maxLaunchRetries; attempt++ { - socksURL, bridge, err := m.launchBridgeAttempt(log, key, binaryPath, outbound, dnsServers, pin, attempt) + socksURL, bridge, err := m.launchBridgeAttempt(log, key, binaryPath, outbounds, routes, preferredPort, dnsServers, pin, attempt) if err == nil { return socksURL, key, nil } @@ -67,13 +105,17 @@ func (m *XrayManager) ensureBridge(proxyConfig string, proxies []config.BrowserP return "", "", fmt.Errorf("xray 启动失败(已重试 %d 次): %w", maxLaunchRetries, lastErr) } -func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binaryPath string, outbound map[string]interface{}, dnsServers string, pin bool, attempt int) (string, *XrayBridge, error) { - port, err := nextAvailablePort() - if err != nil { - log.Error("端口分配失败", logger.F("error", err), logger.F("attempt", attempt)) - return "", nil, err +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) { + port := preferredPort + if port <= 0 { + var err error + port, err = nextAvailablePort() + if err != nil { + log.Error("端口分配失败", logger.F("error", err), logger.F("attempt", attempt)) + return "", nil, err + } } - cfgPath, err := m.buildRuntimeConfig(key, outbound, port, dnsServers) + cfgPath, err := m.buildRuntimeConfigWithRoute(key, outbounds, routes, port, dnsServers) if err != nil { log.Error("xray 配置生成失败", logger.F("error", err)) return "", nil, err @@ -121,6 +163,38 @@ func (m *XrayManager) launchBridgeAttempt(log *logger.Logger, key string, binary return fmt.Sprintf("socks5://127.0.0.1:%d", port), bridge, nil } +func chainSocks5Outbound(hop chainSocks5Hop, tag string, nextTag string) map[string]interface{} { + user := map[string]interface{}{} + if strings.TrimSpace(hop.Username) != "" { + user["user"] = strings.TrimSpace(hop.Username) + if strings.TrimSpace(hop.Password) != "" { + user["pass"] = hop.Password + } + } + + server := map[string]interface{}{ + "address": strings.TrimSpace(hop.Server), + "port": hop.Port, + } + if len(user) > 0 { + server["users"] = []interface{}{user} + } + + outbound := map[string]interface{}{ + "protocol": "socks", + "tag": tag, + "settings": map[string]interface{}{ + "servers": []interface{}{server}, + }, + } + if strings.TrimSpace(nextTag) != "" { + outbound["proxySettings"] = map[string]interface{}{ + "tag": strings.TrimSpace(nextTag), + } + } + return outbound +} + 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, 10*time.Second); err != nil { if stderrFile != nil { diff --git a/backend/internal/proxy/xray_chain_test.go b/backend/internal/proxy/xray_chain_test.go new file mode 100644 index 00000000..537feb8b --- /dev/null +++ b/backend/internal/proxy/xray_chain_test.go @@ -0,0 +1,86 @@ +package proxy + +import ( + "encoding/json" + "os" + "testing" + + "ant-chrome/backend/internal/config" +) + +func TestChainSocks5RuntimeConfigRoutesThroughSecondHop(t *testing.T) { + chainConfig := buildTestChainSocks5Config(t, 19090) + chainCfg, err := ParseChainSocks5Config(chainConfig) + if err != nil { + t.Fatalf("ParseChainSocks5Config returned error: %v", err) + } + + cfg := config.DefaultConfig() + cfg.Browser.UserDataRoot = t.TempDir() + manager := &XrayManager{ + Config: cfg, + AppRoot: t.TempDir(), + } + + cfgPath, err := manager.buildRuntimeConfigWithRoute( + "chain-test", + []interface{}{ + chainSocks5Outbound(chainCfg.First, "first-hop", ""), + chainSocks5Outbound(chainCfg.Second, "second-hop", "first-hop"), + }, + []interface{}{ + map[string]interface{}{ + "type": "field", + "inboundTag": []string{"socks-in"}, + "outboundTag": "second-hop", + }, + }, + chainCfg.LocalPort, + "", + ) + if err != nil { + t.Fatalf("buildRuntimeConfigWithRoute returned error: %v", err) + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read runtime config failed: %v", err) + } + var runtimeConfig map[string]interface{} + if err := json.Unmarshal(data, &runtimeConfig); err != nil { + t.Fatalf("unmarshal runtime config failed: %v", err) + } + + inbounds := runtimeConfig["inbounds"].([]interface{}) + inbound := inbounds[0].(map[string]interface{}) + if got := int(inbound["port"].(float64)); got != 19090 { + t.Fatalf("inbound port = %d, want 19090", got) + } + + outbounds := runtimeConfig["outbounds"].([]interface{}) + byTag := map[string]map[string]interface{}{} + for _, item := range outbounds { + outbound := item.(map[string]interface{}) + if tag, ok := outbound["tag"].(string); ok { + byTag[tag] = outbound + } + } + secondHop := byTag["second-hop"] + if secondHop == nil { + t.Fatalf("second-hop outbound is missing: %+v", byTag) + } + proxySettings, ok := secondHop["proxySettings"].(map[string]interface{}) + if !ok { + t.Fatalf("second-hop proxySettings is missing: %+v", secondHop) + } + if got := proxySettings["tag"]; got != "first-hop" { + t.Fatalf("second-hop proxy tag = %v, want first-hop", got) + } + + routing := runtimeConfig["routing"].(map[string]interface{}) + rules := routing["rules"].([]interface{}) + rule := rules[0].(map[string]interface{}) + if got := rule["outboundTag"]; got != "second-hop" { + t.Fatalf("route outboundTag = %v, want second-hop", got) + } +} diff --git a/backend/internal/proxy/xray_runtime_config.go b/backend/internal/proxy/xray_runtime_config.go index c96b3ed0..499571a9 100644 --- a/backend/internal/proxy/xray_runtime_config.go +++ b/backend/internal/proxy/xray_runtime_config.go @@ -9,6 +9,22 @@ import ( ) func (m *XrayManager) buildRuntimeConfig(key string, outbound map[string]interface{}, port int, dnsServers string) (string, error) { + return m.buildRuntimeConfigWithRoute( + key, + []interface{}{outbound}, + []interface{}{ + map[string]interface{}{ + "type": "field", + "inboundTag": []string{"socks-in"}, + "outboundTag": "proxy-out", + }, + }, + port, + dnsServers, + ) +} + +func (m *XrayManager) buildRuntimeConfigWithRoute(key string, outbounds []interface{}, rules []interface{}, port int, dnsServers string) (string, error) { baseDir := m.resolveWorkdir(key) if err := os.MkdirAll(baseDir, 0o755); err != nil { return "", err @@ -33,8 +49,7 @@ func (m *XrayManager) buildRuntimeConfig(key string, outbound map[string]interfa }, }, }, - "outbounds": []interface{}{ - outbound, + "outbounds": append(outbounds, map[string]interface{}{ "protocol": "direct", "tag": "direct", @@ -43,15 +58,9 @@ func (m *XrayManager) buildRuntimeConfig(key string, outbound map[string]interfa "protocol": "blackhole", "tag": "block", }, - }, + ), "routing": map[string]interface{}{ - "rules": []interface{}{ - map[string]interface{}{ - "type": "field", - "inboundTag": []string{"socks-in"}, - "outboundTag": "proxy-out", - }, - }, + "rules": rules, }, } if dnsCfg := parseDnsConfig(dnsServers); dnsCfg != nil { diff --git a/backend/internal/proxy/xray_validate_test.go b/backend/internal/proxy/xray_validate_test.go index 3a686096..fd3ee014 100644 --- a/backend/internal/proxy/xray_validate_test.go +++ b/backend/internal/proxy/xray_validate_test.go @@ -2,6 +2,8 @@ package proxy import ( "ant-chrome/backend/internal/config" + "fmt" + "net/url" "strings" "testing" ) @@ -43,3 +45,24 @@ func TestValidateProxyConfigStandardProxy(t *testing.T) { t.Fatalf("expected standard proxy to pass: %s", msg) } } + +func TestValidateProxyConfigChainSocks5Proxy(t *testing.T) { + chainConfig := buildTestChainSocks5Config(t, 0) + ok, msg := ValidateProxyConfig(chainConfig, nil, "") + if !ok { + t.Fatalf("expected chain proxy to pass: %s", msg) + } + if !RequiresBridge(chainConfig, nil, "") { + t.Fatalf("expected chain proxy to require bridge") + } +} + +func buildTestChainSocks5Config(t *testing.T, localPort int) string { + t.Helper() + localPortField := "" + if localPort > 0 { + localPortField = fmt.Sprintf(`,"localPort":%d`, localPort) + } + raw := fmt.Sprintf(`{"first":{"protocol":"socks5","server":"127.0.0.1","port":1081,"username":"u1","password":"p1"},"second":{"protocol":"socks5","server":"127.0.0.2","port":1082}%s}`, localPortField) + return "chain+socks5://" + url.QueryEscape(raw) +} diff --git a/frontend/src/modules/browser/automationScriptApi.ts b/frontend/src/modules/browser/automationScriptApi.ts index 1fce4ffa..9ef87a91 100644 --- a/frontend/src/modules/browser/automationScriptApi.ts +++ b/frontend/src/modules/browser/automationScriptApi.ts @@ -107,6 +107,7 @@ function normalizeAutomationScriptRunInput( paramsText: "", useScriptSelector: true, useScriptParams: true, + timeoutMs: 0, launchCode: "", startByCodeBeforeRun: false, }; @@ -118,6 +119,9 @@ function normalizeAutomationScriptRunInput( paramsText: String(input?.paramsText || ""), useScriptSelector: input?.useScriptSelector !== false, useScriptParams: input?.useScriptParams !== false, + timeoutMs: Number.isFinite(Number(input?.timeoutMs)) + ? Math.round(Number(input?.timeoutMs)) + : 0, launchCode: String(input?.launchCode || "") .trim() .toUpperCase(), diff --git a/frontend/src/modules/browser/automationScripts.ts b/frontend/src/modules/browser/automationScripts.ts index 74d27e9c..a8165a03 100644 --- a/frontend/src/modules/browser/automationScripts.ts +++ b/frontend/src/modules/browser/automationScripts.ts @@ -74,6 +74,7 @@ export interface AutomationScriptRunInput { paramsText?: string; useScriptSelector?: boolean; useScriptParams?: boolean; + timeoutMs?: number; launchCode?: string; startByCodeBeforeRun?: boolean; } diff --git a/frontend/src/wailsjs/go/models.ts b/frontend/src/wailsjs/go/models.ts index 25016891..4454806f 100755 --- a/frontend/src/wailsjs/go/models.ts +++ b/frontend/src/wailsjs/go/models.ts @@ -178,6 +178,7 @@ export namespace automation { paramsText: string; useScriptSelector: boolean; useScriptParams: boolean; + timeoutMs: number; static createFrom(source: any = {}) { return new ScriptRunRequest(source); @@ -190,8 +191,9 @@ export namespace automation { this.paramsText = source["paramsText"]; this.useScriptSelector = source["useScriptSelector"]; this.useScriptParams = source["useScriptParams"]; + this.timeoutMs = source["timeoutMs"]; } - } +}