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.

This commit is contained in:
2026-02-01 04:58:12 +08:00
parent 842a4ab4b2
commit e867bc5cbd
10 changed files with 314 additions and 22 deletions

View File

@@ -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]

View File

@@ -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

View File

@@ -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"),

View File

@@ -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)

View File

@@ -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{

View File

@@ -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...)

View File

@@ -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
}