mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
最终待发布状态
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 管理器未初始化")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user