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 监控设置 // MonitorSettings 监控设置
type MonitorSettings struct { type MonitorSettings struct {
Target int `json:"target"` Target int `json:"target"`
AutoAdd bool `json:"auto_add"` AutoAdd bool `json:"auto_add"`
MinInterval int `json:"min_interval"` MinInterval int `json:"min_interval"`
CheckInterval int `json:"check_interval"` // 自动补号检查间隔(秒) CheckInterval int `json:"check_interval"` // 自动补号检查间隔(秒)
PollingEnabled bool `json:"polling_enabled"` PollingEnabled bool `json:"polling_enabled"`
PollingInterval int `json:"polling_interval"` PollingInterval int `json:"polling_interval"`
ReplenishUseProxy bool `json:"replenish_use_proxy"` // 补号时使用代理 ReplenishUseProxy bool `json:"replenish_use_proxy"` // 补号时使用代理
BrowserType string `json:"browser_type"` // 授权浏览器引擎: chromedp 或 rod
} }
// HandleGetMonitorSettings 获取监控设置 // HandleGetMonitorSettings 获取监控设置
@@ -40,6 +41,7 @@ func HandleGetMonitorSettings(w http.ResponseWriter, r *http.Request) {
PollingEnabled: false, PollingEnabled: false,
PollingInterval: 60, PollingInterval: 60,
ReplenishUseProxy: false, ReplenishUseProxy: false,
BrowserType: "chromedp", // 默认使用 chromedp
} }
if val, _ := database.Instance.GetConfig("monitor_target"); val != "" { 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" { if val, _ := database.Instance.GetConfig("monitor_replenish_use_proxy"); val == "true" {
settings.ReplenishUseProxy = true settings.ReplenishUseProxy = true
} }
if val, _ := database.Instance.GetConfig("monitor_browser_type"); val != "" {
settings.BrowserType = val
}
Success(w, settings) 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 { if err := database.Instance.SetConfig("monitor_replenish_use_proxy", strconv.FormatBool(settings.ReplenishUseProxy)); err != nil {
saveErrors = append(saveErrors, "replenish_use_proxy: "+err.Error()) 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 { if len(saveErrors) > 0 {
errMsg := "保存监控设置部分失败: " + saveErrors[0] errMsg := "保存监控设置部分失败: " + saveErrors[0]

View File

@@ -15,6 +15,7 @@ import (
"codex-pool/internal/invite" "codex-pool/internal/invite"
"codex-pool/internal/logger" "codex-pool/internal/logger"
"codex-pool/internal/mail" "codex-pool/internal/mail"
"codex-pool/internal/proxyutil"
"codex-pool/internal/register" "codex-pool/internal/register"
) )
@@ -127,6 +128,14 @@ func HandleTeamProcess(w http.ResponseWriter, r *http.Request) {
if req.Proxy == "" && config.Global != nil { if req.Proxy == "" && config.Global != nil {
req.Proxy = config.Global.GetProxy() // 使用新的代理获取方法 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 teamProcessState.Running = true

View File

@@ -6,12 +6,30 @@ import (
"strings" "strings"
"time" "time"
"codex-pool/internal/proxyutil"
"github.com/chromedp/cdproto/fetch"
"github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
) )
// CompleteWithChromedp 使用 chromedp 完成 S2A OAuth 授权 // CompleteWithChromedp 使用 chromedp 完成 S2A OAuth 授权
func CompleteWithChromedp(authURL, email, password, teamID string, headless bool, proxy string) (string, error) { 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[:], opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", headless), chromedp.Flag("headless", headless),
chromedp.Flag("disable-gpu", true), 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"), 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 != "" { if proxyServer != "" {
opts = append(opts, chromedp.ProxyServer(proxy)) opts = append(opts, chromedp.ProxyServer(proxyServer))
} }
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
@@ -37,8 +55,36 @@ func CompleteWithChromedp(authURL, email, password, teamID string, headless bool
var callbackURL string var callbackURL string
chromedp.ListenTarget(ctx, func(ev interface{}) { chromedp.ListenTarget(ctx, func(ev interface{}) {
if req, ok := ev.(*network.EventRequestWillBeSent); ok { switch ev := ev.(type) {
url := req.Request.URL 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=") { if strings.Contains(url, "localhost") && strings.Contains(url, "code=") {
callbackURL = url callbackURL = url
} }
@@ -46,6 +92,8 @@ func CompleteWithChromedp(authURL, email, password, teamID string, headless bool
}) })
err := chromedp.Run(ctx, err := chromedp.Run(ctx,
// Handle proxy auth (407) in headless mode.
fetch.Enable().WithHandleAuthRequests(true),
network.Enable(), network.Enable(),
chromedp.Navigate(authURL), chromedp.Navigate(authURL),
chromedp.WaitReady("body"), chromedp.WaitReady("body"),

View File

@@ -6,6 +6,8 @@ import (
"strings" "strings"
"time" "time"
"codex-pool/internal/proxyutil"
"github.com/go-rod/rod" "github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher" "github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto" "github.com/go-rod/rod/lib/proto"
@@ -17,6 +19,8 @@ type RodAuth struct {
browser *rod.Browser browser *rod.Browser
headless bool headless bool
proxy string proxy string
proxyUser string
proxyPass string
} }
// getChromiumPath 获取 Chromium 路径 // getChromiumPath 获取 Chromium 路径
@@ -49,6 +53,24 @@ func getChromiumPath() string {
// NewRodAuth 创建 Rod 授权器 // NewRodAuth 创建 Rod 授权器
func NewRodAuth(headless bool, proxy string) (*RodAuth, error) { 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(). l := launcher.New().
Headless(headless). Headless(headless).
Set("disable-blink-features", "AutomationControlled"). Set("disable-blink-features", "AutomationControlled").
@@ -67,8 +89,8 @@ func NewRodAuth(headless bool, proxy string) (*RodAuth, error) {
l = l.Bin(chromiumPath) l = l.Bin(chromiumPath)
} }
if proxy != "" { if proxyServer != "" {
l = l.Proxy(proxy) l = l.Proxy(proxyServer)
} }
controlURL, err := l.Launch() controlURL, err := l.Launch()
@@ -85,6 +107,8 @@ func NewRodAuth(headless bool, proxy string) (*RodAuth, error) {
browser: browser, browser: browser,
headless: headless, headless: headless,
proxy: proxy, proxy: proxy,
proxyUser: proxyUser,
proxyPass: proxyPass,
}, nil }, nil
} }
@@ -97,6 +121,39 @@ func (r *RodAuth) Close() {
// CompleteOAuth 完成 OAuth 授权 // CompleteOAuth 完成 OAuth 授权
func (r *RodAuth) CompleteOAuth(authURL, email, password, teamID string) (string, error) { 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) page, err := stealth.Page(r.browser)
if err != nil { if err != nil {
return "", fmt.Errorf("创建页面失败: %v", err) return "", fmt.Errorf("创建页面失败: %v", err)

View File

@@ -10,6 +10,8 @@ import (
"net/url" "net/url"
"strings" "strings"
"time" "time"
"codex-pool/internal/proxyutil"
) )
const ( const (
@@ -214,8 +216,13 @@ func RefreshCodexToken(refreshToken string, proxyURL string) (*CodexTokens, erro
client := &http.Client{Timeout: 30 * time.Second} client := &http.Client{Timeout: 30 * time.Second}
if proxyURL != "" { if proxyURL != "" {
proxyURLParsed, _ := url.Parse(proxyURL) info, err := proxyutil.Parse(proxyURL)
client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyURLParsed)} 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{ data := url.Values{

View File

@@ -9,6 +9,8 @@ import (
"net/url" "net/url"
"strings" "strings"
"codex-pool/internal/proxyutil"
"github.com/andybalholm/brotli" "github.com/andybalholm/brotli"
http2 "github.com/bogdanfinn/fhttp" http2 "github.com/bogdanfinn/fhttp"
tls_client "github.com/bogdanfinn/tls-client" tls_client "github.com/bogdanfinn/tls-client"
@@ -44,7 +46,11 @@ func New(proxyStr string) (*TLSClient, error) {
} }
if proxyStr != "" { 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...) 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
}

View File

@@ -275,7 +275,7 @@ export default function Config() {
setDefaultProxy(e.target.value) setDefaultProxy(e.target.value)
setProxyStatus('unknown') 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" className="flex-1"
/> />
<Button <Button
@@ -299,7 +299,7 @@ export default function Config() {
</Button> </Button>
</div> </div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400"> <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
http://host:port、http://user:pass@host:port、host:port:user:pass默认按 http 解析)
{proxyOriginIP && ( {proxyOriginIP && (
<span className="ml-2 text-green-600 dark:text-green-400"> <span className="ml-2 text-green-600 dark:text-green-400">
IP: {proxyOriginIP} IP: {proxyOriginIP}

View File

@@ -69,6 +69,7 @@ export default function Monitor() {
const [pollingInterval, setPollingInterval] = useState(60) const [pollingInterval, setPollingInterval] = useState(60)
const [replenishUseProxy, setReplenishUseProxy] = useState(false) // 补号时使用代理 const [replenishUseProxy, setReplenishUseProxy] = useState(false) // 补号时使用代理
const [globalProxy, setGlobalProxy] = useState('') // 全局代理地址(只读显示) const [globalProxy, setGlobalProxy] = useState('') // 全局代理地址(只读显示)
const [browserType, setBrowserType] = useState<'chromedp' | 'rod'>('chromedp') // 授权浏览器引擎
// 倒计时状态 // 倒计时状态
const [countdown, setCountdown] = useState(60) const [countdown, setCountdown] = useState(60)
@@ -132,6 +133,7 @@ export default function Monitor() {
polling_enabled: pollingEnabled, polling_enabled: pollingEnabled,
polling_interval: pollingInterval, polling_interval: pollingInterval,
replenish_use_proxy: replenishUseProxy, replenish_use_proxy: replenishUseProxy,
browser_type: browserType,
}), }),
}) })
const data = await res.json() const data = await res.json()
@@ -269,6 +271,7 @@ export default function Monitor() {
const pollingEnabledVal = s.polling_enabled || false const pollingEnabledVal = s.polling_enabled || false
const interval = s.polling_interval || 60 const interval = s.polling_interval || 60
const replenishUseProxyVal = s.replenish_use_proxy || false const replenishUseProxyVal = s.replenish_use_proxy || false
const browserTypeVal = s.browser_type || 'chromedp'
setTargetInput(target) setTargetInput(target)
setAutoAdd(autoAddVal) setAutoAdd(autoAddVal)
@@ -277,11 +280,12 @@ export default function Monitor() {
setPollingEnabled(pollingEnabledVal) setPollingEnabled(pollingEnabledVal)
setPollingInterval(interval) setPollingInterval(interval)
setReplenishUseProxy(replenishUseProxyVal) setReplenishUseProxy(replenishUseProxyVal)
setBrowserType(browserTypeVal as 'chromedp' | 'rod')
savedPollingIntervalRef.current = interval savedPollingIntervalRef.current = interval
setCountdown(interval) setCountdown(interval)
// 返回加载的配置用于后续刷新 // 返回加载的配置用于后续刷新
return { target, autoAdd: autoAddVal, minInterval: minIntervalVal, checkInterval: checkIntervalVal, pollingEnabled: pollingEnabledVal, pollingInterval: interval, replenishUseProxy: replenishUseProxyVal } return { target, autoAdd: autoAddVal, minInterval: minIntervalVal, checkInterval: checkIntervalVal, pollingEnabled: pollingEnabledVal, pollingInterval: interval, replenishUseProxy: replenishUseProxyVal, browserType: browserTypeVal }
} }
} }
@@ -532,8 +536,45 @@ export default function Monitor() {
onChange={setReplenishUseProxy} onChange={setReplenishUseProxy}
disabled={!autoAdd || !globalProxy} disabled={!autoAdd || !globalProxy}
label="补号时使用代理" label="补号时使用代理"
description={globalProxy ? `当前代理: ${globalProxy}` : '请先在系统配置中设置代理地址'} description={
/> globalProxy
? `当前代理: ${globalProxy}`
: '请先在系统配置中设置代理地址(支持 http://host:port、http://user:pass@host:port、host:port:user:pass'
}
/>
</div>
{/* 浏览器选择器 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
</label>
<div className="flex rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<button
type="button"
onClick={() => setBrowserType('chromedp')}
disabled={!autoAdd}
className={`flex-1 px-4 py-2.5 text-sm font-medium transition-all duration-200 ${browserType === 'chromedp'
? 'bg-blue-600 text-white'
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700'
} ${!autoAdd ? 'opacity-50 cursor-not-allowed' : ''}`}
>
Chromedp
</button>
<button
type="button"
onClick={() => setBrowserType('rod')}
disabled={!autoAdd}
className={`flex-1 px-4 py-2.5 text-sm font-medium transition-all duration-200 border-l border-slate-200 dark:border-slate-700 ${browserType === 'rod'
? 'bg-blue-600 text-white'
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700'
} ${!autoAdd ? 'opacity-50 cursor-not-allowed' : ''}`}
>
Rod
</button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Chromedp Rod
</p>
</div> </div>
<Input <Input
label="最小间隔 (秒)" label="最小间隔 (秒)"

View File

@@ -478,7 +478,11 @@ export default function Upload() {
onChange={setUseProxy} onChange={setUseProxy}
disabled={isRunning || !globalProxy} disabled={isRunning || !globalProxy}
label="使用全局代理" label="使用全局代理"
description={globalProxy ? `当前代理: ${globalProxy}` : '请先在系统配置中设置代理地址'} description={
globalProxy
? `当前代理: ${globalProxy}`
: '请先在系统配置中设置代理地址(支持 http://host:port、http://user:pass@host:port、host:port:user:pass'
}
/> />
</div> </div>