From e867bc5cbdcfe06eb919613e9b7a3a9829d25808 Mon Sep 17 00:00:00 2001 From: kyx236 Date: Sun, 1 Feb 2026 04:58:12 +0800 Subject: [PATCH] feat: Implement S2A, Rod, and Chromedp based authentication for external services, and introduce new frontend pages and backend APIs for monitoring, configuration, upload, and team processes. --- backend/internal/api/monitor.go | 27 +++++-- backend/internal/api/team_process.go | 9 +++ backend/internal/auth/chromedp.go | 56 +++++++++++++- backend/internal/auth/rod.go | 61 ++++++++++++++- backend/internal/auth/s2a.go | 11 ++- backend/internal/client/tls.go | 8 +- backend/internal/proxyutil/proxy.go | 107 +++++++++++++++++++++++++++ frontend/src/pages/Config.tsx | 4 +- frontend/src/pages/Monitor.tsx | 47 +++++++++++- frontend/src/pages/Upload.tsx | 6 +- 10 files changed, 314 insertions(+), 22 deletions(-) create mode 100644 backend/internal/proxyutil/proxy.go diff --git a/backend/internal/api/monitor.go b/backend/internal/api/monitor.go index b302d41..3616e9b 100644 --- a/backend/internal/api/monitor.go +++ b/backend/internal/api/monitor.go @@ -11,13 +11,14 @@ import ( // MonitorSettings 监控设置 type MonitorSettings struct { - Target int `json:"target"` - AutoAdd bool `json:"auto_add"` - MinInterval int `json:"min_interval"` - CheckInterval int `json:"check_interval"` // 自动补号检查间隔(秒) - PollingEnabled bool `json:"polling_enabled"` - PollingInterval int `json:"polling_interval"` - ReplenishUseProxy bool `json:"replenish_use_proxy"` // 补号时使用代理 + Target int `json:"target"` + AutoAdd bool `json:"auto_add"` + MinInterval int `json:"min_interval"` + CheckInterval int `json:"check_interval"` // 自动补号检查间隔(秒) + PollingEnabled bool `json:"polling_enabled"` + PollingInterval int `json:"polling_interval"` + ReplenishUseProxy bool `json:"replenish_use_proxy"` // 补号时使用代理 + BrowserType string `json:"browser_type"` // 授权浏览器引擎: chromedp 或 rod } // HandleGetMonitorSettings 获取监控设置 @@ -40,6 +41,7 @@ func HandleGetMonitorSettings(w http.ResponseWriter, r *http.Request) { PollingEnabled: false, PollingInterval: 60, ReplenishUseProxy: false, + BrowserType: "chromedp", // 默认使用 chromedp } if val, _ := database.Instance.GetConfig("monitor_target"); val != "" { @@ -71,6 +73,9 @@ func HandleGetMonitorSettings(w http.ResponseWriter, r *http.Request) { if val, _ := database.Instance.GetConfig("monitor_replenish_use_proxy"); val == "true" { settings.ReplenishUseProxy = true } + if val, _ := database.Instance.GetConfig("monitor_browser_type"); val != "" { + settings.BrowserType = val + } Success(w, settings) } @@ -128,6 +133,14 @@ func HandleSaveMonitorSettings(w http.ResponseWriter, r *http.Request) { if err := database.Instance.SetConfig("monitor_replenish_use_proxy", strconv.FormatBool(settings.ReplenishUseProxy)); err != nil { saveErrors = append(saveErrors, "replenish_use_proxy: "+err.Error()) } + // 浏览器类型默认为 chromedp + browserType := settings.BrowserType + if browserType != "chromedp" && browserType != "rod" { + browserType = "chromedp" + } + if err := database.Instance.SetConfig("monitor_browser_type", browserType); err != nil { + saveErrors = append(saveErrors, "browser_type: "+err.Error()) + } if len(saveErrors) > 0 { errMsg := "保存监控设置部分失败: " + saveErrors[0] diff --git a/backend/internal/api/team_process.go b/backend/internal/api/team_process.go index a59d701..682f6b5 100644 --- a/backend/internal/api/team_process.go +++ b/backend/internal/api/team_process.go @@ -15,6 +15,7 @@ import ( "codex-pool/internal/invite" "codex-pool/internal/logger" "codex-pool/internal/mail" + "codex-pool/internal/proxyutil" "codex-pool/internal/register" ) @@ -127,6 +128,14 @@ func HandleTeamProcess(w http.ResponseWriter, r *http.Request) { if req.Proxy == "" && config.Global != nil { req.Proxy = config.Global.GetProxy() // 使用新的代理获取方法 } + if req.Proxy != "" { + normalized, err := proxyutil.Normalize(req.Proxy) + if err != nil { + Error(w, http.StatusBadRequest, fmt.Sprintf("代理格式错误: %v", err)) + return + } + req.Proxy = normalized + } // 初始化状态 teamProcessState.Running = true diff --git a/backend/internal/auth/chromedp.go b/backend/internal/auth/chromedp.go index 32b3ff8..31eed14 100644 --- a/backend/internal/auth/chromedp.go +++ b/backend/internal/auth/chromedp.go @@ -6,12 +6,30 @@ import ( "strings" "time" + "codex-pool/internal/proxyutil" + + "github.com/chromedp/cdproto/fetch" "github.com/chromedp/cdproto/network" "github.com/chromedp/chromedp" ) // CompleteWithChromedp 使用 chromedp 完成 S2A OAuth 授权 func CompleteWithChromedp(authURL, email, password, teamID string, headless bool, proxy string) (string, error) { + var proxyServer string + var proxyUser string + var proxyPass string + if proxy != "" { + info, err := proxyutil.Parse(proxy) + if err != nil { + return "", fmt.Errorf("代理格式错误: %v", err) + } + if info.Server != nil { + proxyServer = info.Server.String() + } + proxyUser = info.Username + proxyPass = info.Password + } + opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.Flag("headless", headless), chromedp.Flag("disable-gpu", true), @@ -21,8 +39,8 @@ func CompleteWithChromedp(authURL, email, password, teamID string, headless bool chromedp.UserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"), ) - if proxy != "" { - opts = append(opts, chromedp.ProxyServer(proxy)) + if proxyServer != "" { + opts = append(opts, chromedp.ProxyServer(proxyServer)) } allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) @@ -37,8 +55,36 @@ func CompleteWithChromedp(authURL, email, password, teamID string, headless bool var callbackURL string chromedp.ListenTarget(ctx, func(ev interface{}) { - if req, ok := ev.(*network.EventRequestWillBeSent); ok { - url := req.Request.URL + switch ev := ev.(type) { + case *fetch.EventRequestPaused: + // Fetch domain pauses requests; we must continue them to avoid stalling navigation. + reqID := ev.RequestID + go func() { _ = fetch.ContinueRequest(reqID).Do(ctx) }() + + case *fetch.EventAuthRequired: + reqID := ev.RequestID + source := fetch.AuthChallengeSourceServer + if ev.AuthChallenge != nil { + source = ev.AuthChallenge.Source + } + + go func() { + resp := &fetch.AuthChallengeResponse{Response: fetch.AuthChallengeResponseResponseDefault} + if source == fetch.AuthChallengeSourceProxy { + if proxyUser != "" { + resp.Response = fetch.AuthChallengeResponseResponseProvideCredentials + resp.Username = proxyUser + resp.Password = proxyPass + } else { + // Fail fast if the proxy requires auth but user didn't provide credentials. + resp.Response = fetch.AuthChallengeResponseResponseCancelAuth + } + } + _ = fetch.ContinueWithAuth(reqID, resp).Do(ctx) + }() + + case *network.EventRequestWillBeSent: + url := ev.Request.URL if strings.Contains(url, "localhost") && strings.Contains(url, "code=") { callbackURL = url } @@ -46,6 +92,8 @@ func CompleteWithChromedp(authURL, email, password, teamID string, headless bool }) err := chromedp.Run(ctx, + // Handle proxy auth (407) in headless mode. + fetch.Enable().WithHandleAuthRequests(true), network.Enable(), chromedp.Navigate(authURL), chromedp.WaitReady("body"), diff --git a/backend/internal/auth/rod.go b/backend/internal/auth/rod.go index 7117be8..cebfdd3 100644 --- a/backend/internal/auth/rod.go +++ b/backend/internal/auth/rod.go @@ -6,6 +6,8 @@ import ( "strings" "time" + "codex-pool/internal/proxyutil" + "github.com/go-rod/rod" "github.com/go-rod/rod/lib/launcher" "github.com/go-rod/rod/lib/proto" @@ -17,6 +19,8 @@ type RodAuth struct { browser *rod.Browser headless bool proxy string + proxyUser string + proxyPass string } // getChromiumPath 获取 Chromium 路径 @@ -49,6 +53,24 @@ func getChromiumPath() string { // NewRodAuth 创建 Rod 授权器 func NewRodAuth(headless bool, proxy string) (*RodAuth, error) { + var proxyServer string + var proxyUser string + var proxyPass string + if proxy != "" { + info, err := proxyutil.Parse(proxy) + if err != nil { + return nil, fmt.Errorf("代理格式错误: %v", err) + } + if info.URL != nil { + proxy = info.URL.String() // normalized + } + if info.Server != nil { + proxyServer = info.Server.String() + } + proxyUser = info.Username + proxyPass = info.Password + } + l := launcher.New(). Headless(headless). Set("disable-blink-features", "AutomationControlled"). @@ -67,8 +89,8 @@ func NewRodAuth(headless bool, proxy string) (*RodAuth, error) { l = l.Bin(chromiumPath) } - if proxy != "" { - l = l.Proxy(proxy) + if proxyServer != "" { + l = l.Proxy(proxyServer) } controlURL, err := l.Launch() @@ -85,6 +107,8 @@ func NewRodAuth(headless bool, proxy string) (*RodAuth, error) { browser: browser, headless: headless, proxy: proxy, + proxyUser: proxyUser, + proxyPass: proxyPass, }, nil } @@ -97,6 +121,39 @@ func (r *RodAuth) Close() { // CompleteOAuth 完成 OAuth 授权 func (r *RodAuth) CompleteOAuth(authURL, email, password, teamID string) (string, error) { + // Handle proxy auth (407) in headless mode. + // When Fetch domain is enabled without patterns, requests will be paused and must be continued. + if r.proxy != "" { + authBrowser, cancel := r.browser.WithCancel() + defer cancel() + + restoreFetch := authBrowser.EnableDomain("", &proto.FetchEnable{HandleAuthRequests: true}) + defer restoreFetch() + + wait := authBrowser.EachEvent( + func(e *proto.FetchRequestPaused) { + _ = proto.FetchContinueRequest{RequestID: e.RequestID}.Call(authBrowser) + }, + func(e *proto.FetchAuthRequired) { + resp := &proto.FetchAuthChallengeResponse{ + Response: proto.FetchAuthChallengeResponseResponseDefault, + } + if e.AuthChallenge != nil && e.AuthChallenge.Source == proto.FetchAuthChallengeSourceProxy { + if r.proxyUser != "" { + resp.Response = proto.FetchAuthChallengeResponseResponseProvideCredentials + resp.Username = r.proxyUser + resp.Password = r.proxyPass + } else { + // Fail fast if the proxy requires auth but user didn't provide credentials. + resp.Response = proto.FetchAuthChallengeResponseResponseCancelAuth + } + } + _ = proto.FetchContinueWithAuth{RequestID: e.RequestID, AuthChallengeResponse: resp}.Call(authBrowser) + }, + ) + go wait() + } + page, err := stealth.Page(r.browser) if err != nil { return "", fmt.Errorf("创建页面失败: %v", err) diff --git a/backend/internal/auth/s2a.go b/backend/internal/auth/s2a.go index 42d590e..069f0d8 100644 --- a/backend/internal/auth/s2a.go +++ b/backend/internal/auth/s2a.go @@ -10,6 +10,8 @@ import ( "net/url" "strings" "time" + + "codex-pool/internal/proxyutil" ) const ( @@ -214,8 +216,13 @@ func RefreshCodexToken(refreshToken string, proxyURL string) (*CodexTokens, erro client := &http.Client{Timeout: 30 * time.Second} if proxyURL != "" { - proxyURLParsed, _ := url.Parse(proxyURL) - client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyURLParsed)} + info, err := proxyutil.Parse(proxyURL) + if err != nil { + return nil, fmt.Errorf("代理格式错误: %v", err) + } + if info.URL != nil { + client.Transport = &http.Transport{Proxy: http.ProxyURL(info.URL)} + } } data := url.Values{ diff --git a/backend/internal/client/tls.go b/backend/internal/client/tls.go index 88139dd..34eb434 100644 --- a/backend/internal/client/tls.go +++ b/backend/internal/client/tls.go @@ -9,6 +9,8 @@ import ( "net/url" "strings" + "codex-pool/internal/proxyutil" + "github.com/andybalholm/brotli" http2 "github.com/bogdanfinn/fhttp" tls_client "github.com/bogdanfinn/tls-client" @@ -44,7 +46,11 @@ func New(proxyStr string) (*TLSClient, error) { } if proxyStr != "" { - options = append(options, tls_client.WithProxyUrl(proxyStr)) + normalized, err := proxyutil.Normalize(proxyStr) + if err != nil { + return nil, err + } + options = append(options, tls_client.WithProxyUrl(normalized)) } client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(), options...) diff --git a/backend/internal/proxyutil/proxy.go b/backend/internal/proxyutil/proxy.go new file mode 100644 index 0000000..16811e0 --- /dev/null +++ b/backend/internal/proxyutil/proxy.go @@ -0,0 +1,107 @@ +package proxyutil + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +// Info is a parsed proxy configuration. +// +// It supports the following input formats: +// - http://host:port +// - http://user:pass@host:port +// - host:port (defaults to http) +// - host:port:user:pass (defaults to http; password may contain ':') +// - user:pass@host:port (defaults to http) +type Info struct { + // URL is the normalized proxy URL (may include credentials). + URL *url.URL + // Server is the proxy server URL without credentials (scheme + host:port). + Server *url.URL + Username string + Password string +} + +func Parse(raw string) (*Info, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return &Info{}, nil + } + + // URL-style proxy (has scheme) + if strings.Contains(raw, "://") { + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("parse proxy url: %w", err) + } + if u.Scheme == "" || u.Host == "" { + return nil, fmt.Errorf("invalid proxy url: %q", raw) + } + + u = &url.URL{ + Scheme: strings.ToLower(u.Scheme), + Host: u.Host, + User: u.User, + } + + user := "" + pass := "" + if u.User != nil { + user = u.User.Username() + pass, _ = u.User.Password() + } + + server := &url.URL{Scheme: u.Scheme, Host: u.Host} + return &Info{ + URL: u, + Server: server, + Username: user, + Password: pass, + }, nil + } + + // user:pass@host:port (no scheme) + if strings.Contains(raw, "@") { + return Parse("http://" + raw) + } + + // host:port[:user:pass] + parts := strings.Split(raw, ":") + switch { + case len(parts) == 2: + host, port := parts[0], parts[1] + hp := net.JoinHostPort(host, port) + u := &url.URL{Scheme: "http", Host: hp} + return &Info{URL: u, Server: &url.URL{Scheme: u.Scheme, Host: u.Host}}, nil + case len(parts) >= 4: + host, port := parts[0], parts[1] + user := parts[2] + pass := strings.Join(parts[3:], ":") + hp := net.JoinHostPort(host, port) + u := &url.URL{Scheme: "http", Host: hp, User: url.UserPassword(user, pass)} + return &Info{ + URL: u, + Server: &url.URL{Scheme: u.Scheme, Host: u.Host}, + Username: user, + Password: pass, + }, nil + default: + return nil, fmt.Errorf("unsupported proxy format: %q", raw) + } +} + +// Normalize returns a normalized proxy URL string. +// Empty input returns empty output. +func Normalize(raw string) (string, error) { + info, err := Parse(raw) + if err != nil { + return "", err + } + if info.URL == nil { + return "", nil + } + return info.URL.String(), nil +} + diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index 4f58a94..716d3a3 100644 --- a/frontend/src/pages/Config.tsx +++ b/frontend/src/pages/Config.tsx @@ -275,7 +275,7 @@ export default function Config() { setDefaultProxy(e.target.value) setProxyStatus('unknown') }} - placeholder="http://127.0.0.1:7890" + placeholder="http://127.0.0.1:7890 或 1.2.3.4:5678:user:pass" className="flex-1" /> + + +

+ 选择用于自动授权的浏览器引擎,Chromedp 为默认高效选项,Rod 为备选 +