fix: keep launch proxy overrides temporary

This commit is contained in:
ant-black
2026-05-09 20:58:18 +08:00
parent 5b4daa2cc6
commit 4b252ee697
13 changed files with 188 additions and 30 deletions
+5 -5
View File
@@ -1,7 +1,7 @@
package backend
func (a *App) BrowserInstanceStart(profileId string) (*BrowserProfile, error) {
return a.browserInstanceStartInternal(profileId, nil, nil, false, false, false)
return a.browserInstanceStartInternal(profileId, nil, nil, false, false, false, "", "")
}
func shouldPreferVisibleWindowForStartWithParams(startURLs []string) bool {
@@ -10,17 +10,17 @@ func shouldPreferVisibleWindowForStartWithParams(startURLs []string) bool {
// BrowserInstanceStartDirect 仅本次启动走直连,不落库修改实例代理配置。
func (a *App) BrowserInstanceStartDirect(profileId string) (*BrowserProfile, error) {
return a.browserInstanceStartInternal(profileId, nil, nil, false, false, true)
return a.browserInstanceStartInternal(profileId, nil, nil, false, false, true, "", "")
}
// BrowserInstanceStartWithParams 通过额外参数启动实例(仅本次启动生效,不落库)
func (a *App) BrowserInstanceStartWithParams(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool) (*BrowserProfile, error) {
preferVisibleWindow := shouldPreferVisibleWindowForStartWithParams(startURLs)
return a.browserInstanceStartInternal(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow, false)
return a.browserInstanceStartInternal(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow, false, "", "")
}
func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool, forceDirectProxy bool) (*BrowserProfile, error) {
input := newBrowserStartInput(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow, forceDirectProxy)
func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool, forceDirectProxy bool, proxyId string, proxyConfig string) (*BrowserProfile, error) {
input := newBrowserStartInput(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow, forceDirectProxy, proxyId, proxyConfig)
a.browserMgr.Mutex.Lock()
defer a.browserMgr.Mutex.Unlock()
+9 -1
View File
@@ -17,6 +17,8 @@ type browserStartInput struct {
SkipDefaultStartURLs bool
PreferVisibleWindow bool
ForceDirectProxy bool
TemporaryProxyID string
TemporaryProxyConfig string
}
type browserStartPlan struct {
@@ -34,7 +36,7 @@ type browserStartPlan struct {
totalReadyTimeout time.Duration
}
func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool, forceDirectProxy bool) browserStartInput {
func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool, forceDirectProxy bool, proxyID string, proxyConfig string) browserStartInput {
normalizedExtraLaunchArgs := normalizeNonEmptyStrings(extraLaunchArgs)
if preferVisibleWindow {
normalizedExtraLaunchArgs = ensureNewWindowLaunchArg(normalizedExtraLaunchArgs)
@@ -47,9 +49,15 @@ func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs
SkipDefaultStartURLs: skipDefaultStartURLs,
PreferVisibleWindow: preferVisibleWindow,
ForceDirectProxy: forceDirectProxy,
TemporaryProxyID: strings.TrimSpace(proxyID),
TemporaryProxyConfig: strings.TrimSpace(proxyConfig),
}
}
func (input browserStartInput) hasTemporaryProxy() bool {
return strings.TrimSpace(input.TemporaryProxyID) != "" || strings.TrimSpace(input.TemporaryProxyConfig) != ""
}
func (plan *browserStartPlan) releaseBridgeIfNeeded(a *App) {
if plan == nil || a == nil {
return
+58 -14
View File
@@ -7,15 +7,42 @@ import (
"strings"
)
const temporaryDirectProxyID = "__direct__"
func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *BrowserProfile) (string, string, bool, error) {
log := logger.New("Browser")
proxies := a.getLatestProxies()
profileID := input.ProfileID
if input.ForceDirectProxy {
log.Warn("按请求直连启动实例",
logger.F("profile_id", profileID),
logger.F("proxy_id", profile.ProxyId),
)
return "direct://", "", false, nil
}
resolvedProxyID := strings.TrimSpace(profile.ProxyId)
resolvedProxyConfig := strings.TrimSpace(profile.ProxyConfig)
if profile.ProxyId != "" {
usingTemporaryProxy := input.hasTemporaryProxy()
if usingTemporaryProxy {
var err error
resolvedProxyID, resolvedProxyConfig, err = resolveTemporaryBrowserStartProxy(input.TemporaryProxyID, input.TemporaryProxyConfig, proxies)
if err != nil {
startErr := fmt.Errorf("实例启动失败:%s", err.Error())
profile.LastError = startErr.Error()
log.Error("一次性代理配置无效",
logger.F("profile_id", profileID),
logger.F("temporary_proxy_id", input.TemporaryProxyID),
logger.F("error", err.Error()),
logger.F("reason", startErr.Error()),
)
return "", "", false, startErr
}
} else if resolvedProxyID != "" {
for _, item := range proxies {
if strings.EqualFold(item.ProxyId, profile.ProxyId) {
if strings.EqualFold(item.ProxyId, resolvedProxyID) {
resolvedProxyID = strings.TrimSpace(item.ProxyId)
resolvedProxyConfig = strings.TrimSpace(item.ProxyConfig)
break
}
@@ -26,21 +53,17 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser
logger.F("profile_id", profileID),
logger.F("proxy_id", profile.ProxyId),
logger.F("profile_proxy_config", profile.ProxyConfig),
logger.F("temporary_proxy", usingTemporaryProxy),
logger.F("temporary_proxy_id", input.TemporaryProxyID),
logger.F("temporary_proxy_config", input.TemporaryProxyConfig),
logger.F("resolved_proxy_config", resolvedProxyConfig),
)
if input.ForceDirectProxy {
log.Warn("按请求直连启动实例",
logger.F("profile_id", profileID),
logger.F("proxy_id", profile.ProxyId),
)
return "direct://", "", false, nil
}
if supported, errorMsg := proxy.ValidateProxyConfig(resolvedProxyConfig, proxies, profile.ProxyId); !supported {
if supported, errorMsg := proxy.ValidateProxyConfig(resolvedProxyConfig, proxies, resolvedProxyID); !supported {
startErr := fmt.Errorf("实例启动失败:%s", errorMsg)
profile.LastError = startErr.Error()
log.Error("代理配置无效",
logger.F("profile_id", profileID),
logger.F("proxy_id", profile.ProxyId),
logger.F("proxy_id", resolvedProxyID),
logger.F("error", errorMsg),
logger.F("reason", startErr.Error()),
)
@@ -48,7 +71,7 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser
}
if proxy.IsSingBoxProtocol(resolvedProxyConfig) {
socksURL, bridgeErr := a.singboxMgr.EnsureBridge(resolvedProxyConfig, proxies, profile.ProxyId)
socksURL, bridgeErr := a.singboxMgr.EnsureBridge(resolvedProxyConfig, proxies, resolvedProxyID)
if bridgeErr != nil {
startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(sing-box)。原因:%v。请检查代理节点配置、sing-box 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr)
log.Error("代理桥接失败(sing-box)",
@@ -62,8 +85,8 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser
return socksURL, "", false, nil
}
if proxy.RequiresBridge(resolvedProxyConfig, proxies, profile.ProxyId) || proxy.RequiresLocalProxyBridgeForBrowser(resolvedProxyConfig) {
socksURL, bridgeKey, bridgeErr := a.xrayMgr.AcquireBridge(resolvedProxyConfig, proxies, profile.ProxyId)
if proxy.RequiresBridge(resolvedProxyConfig, proxies, resolvedProxyID) || proxy.RequiresLocalProxyBridgeForBrowser(resolvedProxyConfig) {
socksURL, bridgeKey, bridgeErr := a.xrayMgr.AcquireBridge(resolvedProxyConfig, proxies, resolvedProxyID)
if bridgeErr != nil {
startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(xray)。原因:%v。请检查代理节点配置、xray 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr)
log.Error("代理桥接失败(xray)",
@@ -79,3 +102,24 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser
return resolvedProxyConfig, "", false, nil
}
func resolveTemporaryBrowserStartProxy(proxyID string, proxyConfig string, proxies []BrowserProxy) (string, string, error) {
proxyID = strings.TrimSpace(proxyID)
proxyConfig = strings.TrimSpace(proxyConfig)
if proxyID == "" {
return "", proxyConfig, nil
}
for _, item := range proxies {
if strings.EqualFold(item.ProxyId, proxyID) {
return strings.TrimSpace(item.ProxyId), strings.TrimSpace(item.ProxyConfig), nil
}
}
if strings.EqualFold(proxyID, temporaryDirectProxyID) {
return temporaryDirectProxyID, "direct://", nil
}
if proxyConfig != "" {
return "", proxyConfig, nil
}
return "", "", fmt.Errorf("代理ID不存在(proxy id not found: %s),且未提供 proxyConfig", proxyID)
}
+49
View File
@@ -461,6 +461,55 @@ func TestSanitizeManagedLaunchArgsKeepsUnmanagedFlags(t *testing.T) {
}
}
func TestResolveBrowserStartProxyUsesTemporaryProxyWithoutMutatingProfile(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Browser.Proxies = []config.BrowserProxy{
{ProxyId: "stored-proxy", ProxyName: "Stored", ProxyConfig: "http://127.0.0.1:18080"},
{ProxyId: "runtime-proxy", ProxyName: "Runtime", ProxyConfig: "http://127.0.0.1:28080"},
}
app := NewApp("")
app.config = cfg
app.browserMgr = browser.NewManager(cfg, t.TempDir())
profile := &BrowserProfile{
ProfileId: "profile-temporary-proxy",
ProfileName: "Temporary Proxy",
ProxyId: "stored-proxy",
ProxyConfig: "http://127.0.0.1:18080",
}
input := newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "runtime-proxy", "")
effectiveProxy, bridgeKey, releaseBridge, err := app.resolveBrowserStartProxy(input, profile)
if err != nil {
t.Fatalf("resolveBrowserStartProxy returned error: %v", err)
}
if effectiveProxy != "http://127.0.0.1:28080" {
t.Fatalf("expected temporary proxy, got %q", effectiveProxy)
}
if bridgeKey != "" || releaseBridge {
t.Fatalf("plain HTTP proxy should not acquire bridge: key=%q release=%v", bridgeKey, releaseBridge)
}
if profile.ProxyId != "stored-proxy" || profile.ProxyConfig != "http://127.0.0.1:18080" {
t.Fatalf("temporary proxy should not mutate profile: %+v", profile)
}
fallbackInput := newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "missing-proxy", "http://127.0.0.1:38080")
effectiveProxy, bridgeKey, releaseBridge, err = app.resolveBrowserStartProxy(fallbackInput, profile)
if err != nil {
t.Fatalf("fallback temporary proxy returned error: %v", err)
}
if effectiveProxy != "http://127.0.0.1:38080" {
t.Fatalf("expected fallback temporary proxy config, got %q", effectiveProxy)
}
if bridgeKey != "" || releaseBridge {
t.Fatalf("fallback HTTP proxy should not acquire bridge: key=%q release=%v", bridgeKey, releaseBridge)
}
if profile.ProxyId != "stored-proxy" || profile.ProxyConfig != "http://127.0.0.1:18080" {
t.Fatalf("fallback temporary proxy should not mutate profile: %+v", profile)
}
}
func TestAppendLaunchTargetsUsesConfiguredDefaultStartURLs(t *testing.T) {
t.Parallel()
+2 -1
View File
@@ -14,7 +14,8 @@ func (a *App) StartInstance(profileId string) (*browser.Profile, error) {
// StartInstanceWithParams 实现 launchcode.BrowserStarterWithParams 接口
func (a *App) StartInstanceWithParams(profileId string, params launchcode.LaunchRequestParams) (*browser.Profile, error) {
return a.BrowserInstanceStartWithParams(profileId, params.LaunchArgs, params.StartURLs, params.SkipDefaultStartURLs)
preferVisibleWindow := shouldPreferVisibleWindowForStartWithParams(params.StartURLs)
return a.browserInstanceStartInternal(profileId, params.LaunchArgs, params.StartURLs, params.SkipDefaultStartURLs, preferVisibleWindow, false, params.ProxyId, params.ProxyConfig)
}
// StatusInstance 实现 launchcode.BrowserStatusProvider 接口
@@ -60,11 +60,7 @@ func (s *LaunchServer) maybeAutoLaunchProfile(profile *browser.Profile, req Prof
params := LaunchRequestParams{}
if req.Start != nil {
params = LaunchRequestParams{
LaunchArgs: normalizeStringSlice(req.Start.LaunchArgs),
StartURLs: normalizeStringSlice(req.Start.StartURLs),
SkipDefaultStartURLs: req.Start.SkipDefaultStartURLs,
}
params = normalizeLaunchRequestParams(*req.Start)
}
launchedProfile, err := s.launchProfile(profile.ProfileId, params)
@@ -85,8 +85,7 @@ func (s *LaunchServer) handleRuntimeSession(w http.ResponseWriter, r *http.Reque
return
}
req.LaunchArgs = normalizeStringSlice(req.LaunchArgs)
req.StartURLs = normalizeStringSlice(req.StartURLs)
req.LaunchRequestParams = normalizeLaunchRequestParams(req.LaunchRequestParams)
profile, launchCode, status, errMsg := s.launchBySelector(selector, req.LaunchRequestParams)
if errMsg != "" {
writeJSON(w, status, map[string]interface{}{
+2
View File
@@ -30,6 +30,8 @@ type LaunchRequestParams struct {
LaunchArgs []string `json:"launchArgs"`
StartURLs []string `json:"startUrls"`
SkipDefaultStartURLs bool `json:"skipDefaultStartUrls"`
ProxyId string `json:"proxyId"`
ProxyConfig string `json:"proxyConfig"`
}
// LaunchRequest POST /api/launch 的请求体
@@ -90,8 +90,7 @@ func (s *LaunchServer) handleLaunchWithBody(w http.ResponseWriter, r *http.Reque
return
}
req.LaunchArgs = normalizeStringSlice(req.LaunchArgs)
req.StartURLs = normalizeStringSlice(req.StartURLs)
req.LaunchRequestParams = normalizeLaunchRequestParams(req.LaunchRequestParams)
if selector.MatchMode == launchMatchModeAll {
profiles, status, errMsg := s.launchAllBySelector(selector, req.LaunchRequestParams)
if errMsg != "" {
@@ -75,6 +75,14 @@ func normalizeStringSlice(items []string) []string {
return out
}
func normalizeLaunchRequestParams(params LaunchRequestParams) LaunchRequestParams {
params.LaunchArgs = normalizeStringSlice(params.LaunchArgs)
params.StartURLs = normalizeStringSlice(params.StartURLs)
params.ProxyId = strings.TrimSpace(params.ProxyId)
params.ProxyConfig = strings.TrimSpace(params.ProxyConfig)
return params
}
func remoteIP(remoteAddr string) string {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
@@ -93,6 +93,53 @@ func TestLaunchWithParams(t *testing.T) {
}
}
func TestLaunchWithTemporaryProxyParams(t *testing.T) {
svc := newInMemoryService()
starter := newMockStarterWithParams()
starter.addProfile(&browser.Profile{
ProfileId: "profile-temporary-proxy",
ProfileName: "temporary-proxy",
ProxyId: "stored-proxy",
ProxyConfig: "http://127.0.0.1:18080",
Pid: 322,
DebugPort: 9556,
DebugReady: true,
Running: true,
LastStartAt: "2026-05-09T00:00:00Z",
LastError: "",
LaunchCode: "",
RuntimeWarning: "",
})
code, err := svc.EnsureCode("profile-temporary-proxy")
if err != nil {
t.Fatalf("EnsureCode 失败: %v", err)
}
handler := buildTestHandler(svc, starter)
body := map[string]interface{}{
"code": code,
"proxyConfig": " http://127.0.0.1:28080 ",
}
payload, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("期望 200,实际 %dbody=%s", w.Code, w.Body.String())
}
if starter.lastParams.ProxyConfig != "http://127.0.0.1:28080" {
t.Fatalf("一次性 proxyConfig 未透传或未归一化: %+v", starter.lastParams)
}
profile := starter.profiles["profile-temporary-proxy"]
if profile.ProxyConfig != "http://127.0.0.1:18080" || profile.ProxyId != "stored-proxy" {
t.Fatalf("启动接口不应覆盖实例原代理: %+v", profile)
}
}
func TestLaunchWithParamsUsingCodeAsKeywordFallback(t *testing.T) {
svc := newInMemoryService()
starter := newMockStarterWithParams()
@@ -488,6 +488,7 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [
notes: [
'selector 为空且没有任何兼容顶层选择字段时返回 400。',
'matchMode=all 只在这个接口可用。',
'proxyId / proxyConfig 只影响本次启动,不覆盖实例原代理。',
],
},
{
+4
View File
@@ -899,6 +899,8 @@ export namespace launchcode {
launchArgs: string[];
startUrls: string[];
skipDefaultStartUrls: boolean;
proxyId: string;
proxyConfig: string;
static createFrom(source: any = {}) {
return new LaunchRequestParams(source);
@@ -909,6 +911,8 @@ export namespace launchcode {
this.launchArgs = source["launchArgs"];
this.startUrls = source["startUrls"];
this.skipDefaultStartUrls = source["skipDefaultStartUrls"];
this.proxyId = source["proxyId"];
this.proxyConfig = source["proxyConfig"];
}
}