最终待发布状态

This commit is contained in:
ant-black
2026-05-05 22:52:26 +08:00
parent 70132417ad
commit 50e292f27e
25 changed files with 552 additions and 47 deletions
+11 -1
View File
@@ -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()
+40 -2
View File
@@ -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()
+35 -5
View File
@@ -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,
+20 -3
View File
@@ -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()
@@ -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 {
@@ -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}
}
@@ -11,3 +11,7 @@ import (
func hideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
}
func prepareTaskCommand(cmd *exec.Cmd) {
hideWindow(cmd)
}
@@ -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 {
@@ -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 {
@@ -0,0 +1,10 @@
//go:build !windows
// +build !windows
package automation
import "syscall"
func killProcessGroup(pid int) error {
return syscall.Kill(-pid, syscall.SIGKILL)
}
@@ -0,0 +1,8 @@
//go:build windows
// +build windows
package automation
func killProcessGroup(pid int) error {
return nil
}
@@ -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()
@@ -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 {
@@ -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
}
+11
View File
@@ -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 管理器未初始化")
+22 -3
View File
@@ -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)
+20 -1
View File
@@ -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"}
}
+9
View File
@@ -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
}
+92 -18
View File
@@ -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 {
+86
View File
@@ -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)
}
}
+19 -10
View File
@@ -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 {
@@ -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)
}
@@ -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(),
@@ -74,6 +74,7 @@ export interface AutomationScriptRunInput {
paramsText?: string;
useScriptSelector?: boolean;
useScriptParams?: boolean;
timeoutMs?: number;
launchCode?: string;
startByCodeBeforeRun?: boolean;
}
+3 -1
View File
@@ -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"];
}
}
}