diff --git a/README.en.md b/README.en.md index bc61c86..e7236a1 100644 --- a/README.en.md +++ b/README.en.md @@ -132,13 +132,13 @@ When the service runs on a server without Chrome, generate a pairing token on th .\gemini-web2api.exe setup --email "name@example.com" --remote https://example.com ``` -Setup creates `auth//storage-state.json` and verifies the live model catalog. Chrome import also saves the OAuth renewal material needed to renew cookies after Chrome exits. Daily startup is: +Setup creates `auth//storage-state.json` and verifies the live model catalog. Chrome import also saves the OAuth renewal material needed to renew cookies after Chrome exits. While the service is running, import accounts on the console's Import account page; when the `auth` directory that setup would write to is in use by a running service, setup exits with a message. Daily startup is: ```powershell .\gemini-web2api.exe ``` -The default auth directory is `auth`, and the default listener is `127.0.0.1:8007`. Linux and macOS can use existing cookies copied in an `auth` directory; renewal requires setup on the original Windows device. +The default auth directory is `auth`, and the default listener is `127.0.0.1:8007`. Linux and macOS can use a copied `auth` directory: accounts whose `storage-state.json` has `wrappedBindingKey` (renewal material bound to the original device) work until their cookies expire and are renewed on the original Windows device; other Chrome-imported accounts renew automatically on any system. While running, the service rotates each account's sign-in cookies every 10 minutes, as the Gemini website does. Cookies that change in a response are written back to `auth//storage-state.json` and reused after a restart. Chrome-imported accounts renew automatically when their session expires. @@ -282,11 +282,11 @@ Authentication accepts `Authorization: Bearer xxx`, `?key=xxx`, `x-goog-api-key` Open `http://127.0.0.1:8007/` in a browser for the console, which uses the `/api/*` endpoints. - **Connect**: the Connect page lists the base URLs of the three protocols and ready-made configuration for Claude Code, Codex, OpenCode, OpenClaw, omp, Magpie, Cherry Studio, SillyTavern, and curl with the selected model; Connection test lists models with the selected API key and sends the shortest request to the selected model, showing the result, duration, and error reason per request -- **Import accounts**: pasting cookies lists the accounts signed in to them; the import of the selected accounts runs in the background on the service and continues after you leave the page, which shows the progress and failure reason of each account +- **Import accounts**: pasting cookies lists the accounts signed in to them; the import of the selected accounts runs in the background on the service and continues after you leave the page, which shows the progress and failure reason of each account; re-importing an account that is already imported keeps its disabled state, and keeps its fixed proxy when the proxy field is left empty - **Warm up**: Warm up on the All accounts page sends one minimal generation request for each selected account and model and lists the result, duration, and failure reason per row; the requests appear in the request records - **Listen port**: when the configured port falls inside a range reserved by Windows (Hyper-V, WSL2, Docker), the service listens on the next available port and the console shows the actual address at the top -- **Remote access**: only local connections are accepted by default; with `ADMIN_AUTH_ENABLED=true`, `ADMIN_PASSWORD`, and `LISTEN_ADDR` on an externally reachable address, other devices can sign in; after 5 consecutive failed sign-ins, sign-in returns 429 with `Retry-After` for 60 seconds -- **Reverse proxy**: the proxy must pass the browser's original `Host` header to the service; in nginx, use `proxy_set_header Host $http_host;`. The service also works under a subpath, for example nginx `location /gemini/ { proxy_pass http://127.0.0.1:8007/; proxy_set_header Host $http_host; proxy_buffering off; }`; open the console at `/gemini/` and add the same prefix to client base URLs +- **Remote access**: only local connections are accepted by default; with `ADMIN_AUTH_ENABLED=true`, `ADMIN_PASSWORD`, and `LISTEN_ADDR` on an externally reachable address, other devices can sign in; after 5 consecutive failed sign-ins from one client, sign-in from that client returns 429 with `Retry-After` for 60 seconds +- **Reverse proxy**: the proxy must pass the browser's original `Host` header to the service and forward the client address in `X-Forwarded-For`; in nginx, use `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`. The client address forwarded by a reverse proxy on the same machine is used for the local-access check, sign-in rate limiting, and request records. The service also works under a subpath, for example nginx `location /gemini/ { proxy_pass http://127.0.0.1:8007/; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_buffering off; }`; open the console at `/gemini/` and add the same prefix to client base URLs - **Applying settings**: settings saved in the console are written back to `.env`, and values equal to the default are removed from it; the listen address, auth paths, and log format take effect after a restart, and everything else applies immediately; invalid values in `.env` run with the default and are listed at the top of the settings page; settings set by process environment variables or `--auth` and `--listen` show their source and are read-only - **Online update**: the About page shows the latest GitHub release and its notes; after confirmation the command-line and desktop builds download the package for the current platform, verify its SHA-256, replace the executable, and restart into the new version, showing download progress meanwhile; the new version deletes the replaced files when it starts; downloads go through the first proxy in `PROXY`, or the system proxy when it is unset; Docker deployments update with the image diff --git a/README.md b/README.md index 0ab0fc8..7bd4ceb 100644 --- a/README.md +++ b/README.md @@ -132,13 +132,13 @@ Cookie 来自登录了多个 Google 账号的浏览器时,用 `--authuser` 指 .\gemini-web2api.exe setup --email "name@example.com" --remote https://example.com ``` -`setup` 会生成 `auth/<账号>/storage-state.json` 并验证模型目录。Chrome 导入还会保存浏览器退出后续签所需的 OAuth 续签材料。日常启动只需运行: +`setup` 会生成 `auth/<账号>/storage-state.json` 并验证模型目录。Chrome 导入还会保存浏览器退出后续签所需的 OAuth 续签材料。服务已在运行时,账号从管理端“导入账号”页导入;`setup` 要写入的 `auth` 目录正被运行中的服务使用时,`setup` 退出并给出提示。日常启动只需运行: ```powershell .\gemini-web2api.exe ``` -默认读取 `auth` 目录并监听 `127.0.0.1:8007`。Linux 和 macOS 可以在现有 Cookie 有效期内使用已经生成的 `auth` 目录;续签仍需在原 Windows 设备执行。 +默认读取 `auth` 目录并监听 `127.0.0.1:8007`。Linux 和 macOS 可以使用已经生成的 `auth` 目录:`storage-state.json` 带 `wrappedBindingKey`(续签材料绑定在原设备上)的账号在现有 Cookie 有效期内使用,续签在原 Windows 设备执行;其余 Chrome 导入的账号在任何系统上自动续签。 服务运行期间每 10 分钟为每个账号轮换一次登录 Cookie,与 Gemini 网页相同;响应中变化的 Cookie 写回 `auth/<账号>/storage-state.json`,重启后直接沿用。Chrome 导入的账号在登录态失效时自动续签。 @@ -282,11 +282,11 @@ GET /readyz 浏览器打开 `http://127.0.0.1:8007/` 进入管理端,页面使用 `/api/*` 接口。 - **接入**:“接入”页列出三种协议的 base URL,并按所选模型给出 Claude Code、Codex、OpenCode、OpenClaw、omp、Magpie、Cherry Studio、SillyTavern 与 curl 的现成配置;“连通测试”用所选 API key 读取模型列表并向所选模型发一条最短的请求,逐条显示结果、耗时与错误原因 -- **导入账号**:粘贴 Cookie 后自动识别其中登录的账号,勾选后导入;导入在服务端后台运行,离开页面后继续,页面显示每个账号的进度与失败原因 +- **导入账号**:粘贴 Cookie 后自动识别其中登录的账号,勾选后导入;导入在服务端后台运行,离开页面后继续,页面显示每个账号的进度与失败原因;已导入的账号重新导入时沿用停用状态,代理一栏留空时沿用原有固定代理 - **预热**:“全部账号”页的“预热”对选中的账号与模型各发一条最小生成请求,逐行显示结果、耗时与失败原因,请求计入请求记录 - **监听端口**:配置的端口落在 Windows 的系统保留端口段(Hyper-V、WSL2、Docker)时,服务改用其后第一个可用端口,管理端顶部显示实际地址 -- **远程访问**:默认只接受本机访问;设置 `ADMIN_AUTH_ENABLED=true` 与 `ADMIN_PASSWORD`,并让 `LISTEN_ADDR` 监听对外地址后,可从其他设备登录;登录连续失败 5 次后,60 秒内的登录返回 429 并带 `Retry-After` -- **反向代理**:代理需把浏览器请求的原始 `Host` 头透传给服务,nginx 写 `proxy_set_header Host $http_host;`;也可挂在子路径下,例如 nginx `location /gemini/ { proxy_pass http://127.0.0.1:8007/; proxy_set_header Host $http_host; proxy_buffering off; }`,管理端从 `/gemini/` 打开,客户端的 Base URL 带上同一前缀 +- **远程访问**:默认只接受本机访问;设置 `ADMIN_AUTH_ENABLED=true` 与 `ADMIN_PASSWORD`,并让 `LISTEN_ADDR` 监听对外地址后,可从其他设备登录;同一客户端登录连续失败 5 次后,60 秒内它的登录返回 429 并带 `Retry-After` +- **反向代理**:代理需把浏览器请求的原始 `Host` 头透传给服务,并以 `X-Forwarded-For` 转发客户端地址,nginx 写 `proxy_set_header Host $http_host;` 与 `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`;本机反向代理转发的客户端地址用于本机访问判定、登录限流与请求记录;也可挂在子路径下,例如 nginx `location /gemini/ { proxy_pass http://127.0.0.1:8007/; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_buffering off; }`,管理端从 `/gemini/` 打开,客户端的 Base URL 带上同一前缀 - **设置生效**:设置页修改的配置写回 `.env`,与默认值相同的项从 `.env` 中删去;监听地址、认证路径与日志格式重启后生效,其余立即生效;`.env` 中取值无效的项按默认值运行并在设置页顶部列出;由进程环境变量或 `--auth`、`--listen` 决定的项在设置页标出来源且不可修改 - **在线更新**:“关于”页显示 GitHub 最新发行版本与更新内容,命令行版与桌面版确认后下载当前平台的发行包、校验 SHA-256、替换可执行文件并重启到新版本,更新期间显示下载进度,新版本启动时删除被替换的旧文件;下载经 `PROXY` 的第一个代理发出,未设置时使用系统代理;Docker 部署随镜像更新 diff --git a/docs/development.md b/docs/development.md index 966aab5..ba8d21c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -127,14 +127,14 @@ Release 的 Windows 包是只含桌面版 `gemini-web2api.exe` 的 zip,`setup` | 加载 | `internal/auth` | 读取 `auth/<账号>/storage-state.json` | | 初始化 | `accounts.Manager.Start` | 执行初始化链路,失败按间隔重试 | | 轮换 | `internal/accounts`、`gemini/rotate.go` | 按官网周期轮换 Cookie 并重读目录 | -| 续签 | `internal/chromeauth` | Chrome 导入的账号在登录态失效时续签 | +| 续签 | `internal/chromeauth` | Chrome 导入的账号在登录态失效时续签,带设备绑定密钥的材料在原 Windows 设备上签发,其余材料在任何系统上签发 | | 冷却 | `AccountPool.ReportFailure` | 按模型或整个账号移出调度 | -| 运行期变更 | `Manager.Add`、`Remove`、`SetEnabled`、`SetProxy`、`Verify` | 导入、删除、停用、改代理、重新连接 | +| 运行期变更 | `Manager.Add`、`Remove`、`SetEnabled`、`SetProxy`、`Verify` | 导入、删除、停用、改代理、重新连接;账号每次启动都取认证文件当前的 Cookie、续签材料与设置 | | 代理 | `internal/accounts/proxies.go`、`internal/proxypool` | 分配代理,定时检测,故障时改绑 | | 写回 | `internal/auth` | 变化的 Cookie 原子写回认证文件 | - **启动**:`internal/app` 读取配置后创建 `AccountPool` 与 `accounts.Manager`,`Start` 为每个账号启动独立的初始化与轮换循环,并另建访客会话 -- **管理端操作**:`internal/admin` 的账号接口调用 `Manager` 的对应方法,Chrome 导入、Cookie 导入与远程配对分别经 `ImportChrome`、`ImportCookie`、`ImportState` 写入认证目录后加入调度;Chrome 与 Cookie 导入是 `internal/admin/tasks.go` 的后台任务,发起请求结束后逐个账号继续,进度由 `GET /api/accounts/tasks` 读取;粘贴的 Cookie 由 `DiscoverCookie` 按 authuser 读出登录邮箱 +- **管理端操作**:`internal/admin` 的账号接口调用 `Manager` 的对应方法,Chrome 导入、Cookie 导入与远程配对分别经 `ChromeImporter`、`ImportCookie`、`ImportState` 写入认证目录后加入调度;一次 Chrome 导入任务的各账号共用 `chromeauth.OpenChrome` 的账号发现结果与主密钥;三种导入写入已导入的账号时都经 `auth.Inherit` 沿用原有的停用标记与未重新填写的固定代理;Chrome 与 Cookie 导入是 `internal/admin/tasks.go` 的后台任务,发起请求结束后逐个账号继续,进度由 `GET /api/accounts/tasks` 读取;粘贴的 Cookie 由 `DiscoverCookie` 按 authuser 读出登录邮箱 - **预热**:`POST /api/warmup`(`internal/admin/warmup.go`)占用选定账号的请求槽,对选定模型发一条最小生成请求,结果写入请求账本,协议记为 `warmup` - **状态读取**:管理端读取 `AccountPool.Status` 并附上各账号首页 `oPEP7c` 的登录邮箱,账号用量读取 `AccountPool.Usage` - **冷却原因**:以代码记录,取值为 `usage_limit`、`rate_limited`、`model_mismatch`、`ip_restricted`、`captcha` 与 `upstream_error`,管理端按代码显示当前语言的文案 diff --git a/internal/accounts/manager.go b/internal/accounts/manager.go index 3ef76ef..b996752 100644 --- a/internal/accounts/manager.go +++ b/internal/accounts/manager.go @@ -10,7 +10,6 @@ import ( "os" "path/filepath" "slices" - "sort" "strings" "sync" "time" @@ -137,8 +136,7 @@ func (m *Manager) launchGuest(firstDone func()) { // runGuest 初始化访客会话直到成功,之后定期重读首页与模型目录,重读失败时保留原有参数 func (m *Manager) runGuest(ctx context.Context, client *gemini.Client, firstDone func()) { - var once sync.Once - first := func() { once.Do(firstDone) } + first := sync.OnceFunc(firstDone) defer first() for { settings := m.Settings() @@ -201,18 +199,14 @@ func (m *Manager) Add(account auth.LoadedAccount) error { if exists { return fmt.Errorf("账号 %q 已存在", account.ID) } - done := make(chan struct{}) - var once sync.Once - m.launch(account, func() { once.Do(func() { close(done) }) }) - select { - case <-done: - case <-m.ctx.Done(): - } - return nil + return m.restart(account) } -// launch 创建账号客户端并启动其生命周期,firstDone 在首轮初始化结束或账号停用时调用 +// launch 创建账号客户端并启动其生命周期,firstDone 在首轮初始化结束或账号停用时调用;有认证文件的账号以文件当前的 Cookie、续签材料与设置启动 func (m *Manager) launch(account auth.LoadedAccount, firstDone func()) { + if account.File != nil { + account = account.File.Account() + } proxy, source := m.accountProxy(account) account.ProxyURL = proxy info := balancer.AccountInfo{Source: "cookie", Profile: account.Profile, Proxy: proxy, ProxySource: source} @@ -269,8 +263,7 @@ func retryInterval(configured time.Duration, err error) time.Duration { // run 初始化账号直到成功,之后按轮换页给出的间隔轮换 Cookie 直到 ctx 结束 func (m *Manager) run(ctx context.Context, account auth.LoadedAccount, client *gemini.Client, firstDone func()) { - var once sync.Once - first := func() { once.Do(firstDone) } + first := sync.OnceFunc(firstDone) defer first() for { settings := m.Settings() @@ -360,7 +353,7 @@ func logReady(account auth.LoadedAccount, client *gemini.Client, duration time.D fields = append([]any{"邮箱", email}, fields...) } if account.ProxyURL != "" { - fields = append(fields, "代理", maskProxy(account.ProxyURL)) + fields = append(fields, "代理", proxypool.Redact(account.ProxyURL)) } fields = append(fields, "耗时", duration) logging.Info(account.ID, "账号就绪", fields...) @@ -507,7 +500,7 @@ func (m *Manager) SetProxy(accountID string, proxy string) error { } } entry.account.ProxyURL, _ = m.accountProxy(entry.account) - logging.Info(accountID, "账号代理已更新", "代理", cmp.Or(maskProxy(entry.account.ProxyURL), "直连")) + logging.Info(accountID, "账号代理已更新", "代理", cmp.Or(proxypool.Redact(entry.account.ProxyURL), "直连")) return m.restart(entry.account) } @@ -582,9 +575,13 @@ func usesGlobalProxy(account auth.LoadedAccount) bool { // restart 以新客户端重新启动账号并等待首轮初始化结束 func (m *Manager) restart(account auth.LoadedAccount) error { + return m.await(func(firstDone func()) { m.launch(account, firstDone) }) +} + +// await 调用 launch 并等待它的首轮初始化结束或服务停止 +func (m *Manager) await(launch func(firstDone func())) error { done := make(chan struct{}) - var once sync.Once - m.launch(account, func() { once.Do(func() { close(done) }) }) + launch(sync.OnceFunc(func() { close(done) })) select { case <-done: return nil @@ -618,7 +615,6 @@ func (m *Manager) ChromeAccounts() ([]ChromeAccount, error) { for _, account := range discovered { result = append(result, ChromeAccount{Account: account, Imported: imported[chromeauth.AccountID(account.Email)]}) } - sort.SliceStable(result, func(i, j int) bool { return result[i].Profile < result[j].Profile }) return result, nil } @@ -628,26 +624,27 @@ type ChromeAccount struct { Imported bool `json:"imported"` } -// ImportChrome 按邮箱从本机 Chrome 导入账号并启动 -func (m *Manager) ImportChrome(ctx context.Context, emails []string, proxy string) ([]string, error) { - root, err := chromeauth.DefaultChromeRoot() - if err != nil { - return nil, err - } - settings := m.Settings() - results, _, err := chromeauth.Import(ctx, chromeauth.ImportOptions{ChromeRoot: root, Output: settings.AuthDir, Proxy: proxy, RequestProxy: m.importProxy(), Emails: emails}) - if err != nil { - return nil, err - } - ids := make([]string, 0, len(results)) - for _, result := range results { - id, err := m.addFile(result.Path) +// ChromeImporter 返回按邮箱从本机 Chrome 导入账号并启动的函数,各次导入共用一次账号发现与主密钥读取 +func (m *Manager) ChromeImporter(proxy string) func(ctx context.Context, email string) error { + open := sync.OnceValues(func() (*chromeauth.Chrome, error) { + root, err := chromeauth.DefaultChromeRoot() if err != nil { - return ids, err + return nil, err } - ids = append(ids, id) + return chromeauth.OpenChrome(root) + }) + return func(ctx context.Context, email string) error { + chrome, err := open() + if err != nil { + return err + } + paths, _, err := chrome.Import(ctx, chromeauth.ImportOptions{Output: m.Settings().AuthDir, Proxy: proxy, RequestProxy: m.importProxy(), Emails: []string{email}}) + if err != nil { + return err + } + _, err = m.addFile(paths[0]) + return err } - return ids, nil } // CookieAccount 是 Cookie 中一个已登录的账号、按邮箱生成的账号 ID 及其导入状态 @@ -674,12 +671,11 @@ func (m *Manager) DiscoverCookie(ctx context.Context, cookies string, proxy stri // ImportCookie 从 Cookie Header、浏览器导出的 JSON 或 Netscape cookies.txt 导入账号并启动 func (m *Manager) ImportCookie(ctx context.Context, cookies string, id string, proxy string, authUser int) (string, error) { - settings := m.Settings() - result, _, err := chromeauth.ImportCookies(ctx, chromeauth.CookieImportOptions{Cookies: cookies, ID: id, Output: settings.AuthDir, Proxy: proxy, RequestProxy: m.importProxy(), AuthUser: authUser}) + path, _, err := chromeauth.ImportCookies(ctx, chromeauth.CookieImportOptions{Cookies: cookies, ID: id, Output: m.Settings().AuthDir, Proxy: proxy, RequestProxy: m.importProxy(), AuthUser: authUser}) if err != nil { return "", err } - return m.addFile(result.Path) + return m.addFile(path) } // ImportState 校验 setup --remote 上传的认证状态,写入认证目录后新增或更新账号 @@ -688,18 +684,17 @@ func (m *Manager) ImportState(data []byte) (string, error) { if err := json.Unmarshal(data, &state); err != nil { return "", fmt.Errorf("认证状态无效: %w", err) } - id := chromeauth.AccountID(state.Metadata.ID) - if id == "" || len(state.Cookies) == 0 { - return "", errors.New("认证状态缺少账号 ID 或 Cookie") - } - path := filepath.Join(m.Settings().AuthDir, id, "storage-state.json") - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + state.Metadata.ID = chromeauth.AccountID(state.Metadata.ID) + path := filepath.Join(m.Settings().AuthDir, state.Metadata.ID, "storage-state.json") + state.Metadata = auth.Inherit(path, state.Metadata) + file, err := auth.New(path, state) + if err != nil { return "", err } - if err := os.WriteFile(path, data, 0o600); err != nil { - return "", fmt.Errorf("写入认证状态: %w", err) + if err := file.Write(); err != nil { + return "", err } - return m.addFile(path) + return m.addFile(file.Path()) } // addFile 读取认证状态文件,账号已存在时以新文件重启该账号,否则新增 @@ -724,19 +719,6 @@ func (m *Manager) addFile(path string) (string, error) { return account.ID, m.Add(account) } -// maskProxy 隐藏代理 URL 中的密码 -func maskProxy(value string) string { - if at := strings.LastIndex(value, "@"); at > 0 { - if scheme := strings.Index(value, "://"); scheme >= 0 && scheme < at { - credentials := value[scheme+3 : at] - if user, _, found := strings.Cut(credentials, ":"); found { - return value[:scheme+3] + user + ":***" + value[at:] - } - } - } - return value -} - func sleep(ctx context.Context, duration time.Duration) bool { timer := time.NewTimer(duration) defer timer.Stop() diff --git a/internal/accounts/proxies.go b/internal/accounts/proxies.go index fe9e155..e184717 100644 --- a/internal/accounts/proxies.go +++ b/internal/accounts/proxies.go @@ -43,7 +43,7 @@ func fixedProxy(account auth.LoadedAccount) string { if account.File == nil { return "" } - return strings.TrimSpace(account.File.Proxy()) + return strings.TrimSpace(account.File.Metadata().Proxy) } // importProxy 返回导入时没有指定账号代理的请求使用的代理 @@ -124,7 +124,7 @@ func (m *Manager) rebind(accountID string) { return } stopped.account.ProxyURL, _ = m.accountProxy(stopped.account) - logging.Info(accountID, "账号代理切换", "原代理", cmp.Or(maskProxy(previous), "直连"), "新代理", cmp.Or(maskProxy(stopped.account.ProxyURL), "直连")) + logging.Info(accountID, "账号代理切换", "原代理", cmp.Or(proxypool.Redact(previous), "直连"), "新代理", cmp.Or(proxypool.Redact(stopped.account.ProxyURL), "直连")) _ = m.restart(stopped.account) } @@ -133,11 +133,5 @@ func (m *Manager) relaunchGuest() { m.guestMu.Lock() defer m.guestMu.Unlock() m.stopGuest() - done := make(chan struct{}) - var once sync.Once - m.launchGuest(func() { once.Do(func() { close(done) }) }) - select { - case <-done: - case <-m.ctx.Done(): - } + _ = m.await(m.launchGuest) } diff --git a/internal/admin/accounts.go b/internal/admin/accounts.go index 192ed09..9fa1d3e 100644 --- a/internal/admin/accounts.go +++ b/internal/admin/accounts.go @@ -138,10 +138,9 @@ func (h *handler) importChrome(c *gin.Context) { writeError(c, http.StatusBadRequest, "invalid_request", "emails 不能为空") return } - proxy := trimmed(input.Proxy) + importAccount := h.Accounts.ChromeImporter(trimmed(input.Proxy)) task := h.tasks.start(c.Request.Context(), "chrome", input.Emails, func(ctx context.Context, index int) error { - _, err := h.Accounts.ImportChrome(ctx, input.Emails[index:index+1], proxy) - return err + return importAccount(ctx, input.Emails[index]) }) c.JSON(http.StatusAccepted, gin.H{"task": task}) } diff --git a/internal/admin/auth.go b/internal/admin/auth.go index fe0932c..df030c4 100644 --- a/internal/admin/auth.go +++ b/internal/admin/auth.go @@ -89,7 +89,7 @@ func (store *sessionStore) clear() { store.mu.Unlock() } -// loginAttempts 记录各来源地址一分钟窗口内的登录失败次数 +// loginAttempts 记录各客户端地址一分钟窗口内的登录失败次数 type loginAttempts struct { mu sync.Mutex entries map[string]loginAttempt @@ -149,7 +149,7 @@ func (h *handler) guard(c *gin.Context) { } cfg := h.settings.Snapshot() if !cfg.AdminAuthEnabled { - if !loopbackRequest(c.Request) { + if !loopbackRequest(c) { writeError(c, http.StatusForbidden, "admin_remote_forbidden", "管理端只接受本机访问;远程访问需设置 ADMIN_AUTH_ENABLED=true 与 ADMIN_PASSWORD") return } @@ -206,7 +206,7 @@ func (h *handler) login(c *gin.Context) { c.JSON(http.StatusOK, sessionView{Enabled: false, Authenticated: true, Username: cfg.AdminUsername}) return } - peer, _, _ := net.SplitHostPort(c.Request.RemoteAddr) + peer := c.ClientIP() now := time.Now() if h.attempts.blocked(peer, now) { c.Header("Retry-After", "60") @@ -246,12 +246,8 @@ func sameOrigin(request *http.Request) bool { return err == nil && strings.EqualFold(parsed.Host, request.Host) } -// loopbackRequest 报告请求来自本机且 Host 为回环地址,防止 DNS 重绑定 -func loopbackRequest(request *http.Request) bool { - host, _, err := net.SplitHostPort(request.RemoteAddr) - if err != nil { - return false - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() && adapter.LoopbackHost(request.Host) +// loopbackRequest 报告请求的客户端在本机且 Host 为回环地址,防止 DNS 重绑定;经本机反向代理转发的请求以它转发的来源地址为客户端 +func loopbackRequest(c *gin.Context) bool { + ip := net.ParseIP(c.ClientIP()) + return ip != nil && ip.IsLoopback() && adapter.LoopbackHost(c.Request.Host) } diff --git a/internal/app/app.go b/internal/app/app.go index 09889ae..839fbf1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -43,6 +43,9 @@ const shutdownGrace = 10 * time.Second // dataDir 是请求账本与实例锁所在目录 const dataDir = "data" +// lockPath 是运行中的服务持有的实例锁 +var lockPath = filepath.Join(dataDir, "gemini-web2api.lock") + // envPath 是管理端写回配置的文件 const envPath = ".env" @@ -66,7 +69,7 @@ func Main(args []string) error { ctx, cancel := context.WithCancel(ctx) defer cancel() attachConsole(cancel) - return chromeauth.RunSetup(ctx, args[1:], os.Stdin, os.Stdout, os.Stderr, term.IsTerminal(int(os.Stdin.Fd()))) + return chromeauth.RunSetup(ctx, args[1:], os.Stdin, os.Stdout, os.Stderr, term.IsTerminal(int(os.Stdin.Fd())), servedAuthPaths()) case "version", "--version", "-version": attachConsole(nil) fmt.Println("Gemini-Web2API " + Version()) @@ -79,6 +82,17 @@ func Main(args []string) error { return runDefault(args) } +// servedAuthPaths 返回当前数据目录中运行的服务按配置读取的认证路径与导入账号的目录,没有服务在运行时为空 +func servedAuthPaths() []string { + lock := flock.New(lockPath) + if locked, err := lock.TryLock(); err != nil || locked { + _ = lock.Unlock() + return nil + } + paths := config.Load().AuthStatePaths + return append(paths, authDir(paths)) +} + // loadDotenv 把 .env 中进程环境没有的变量写入进程环境并记入 dotenvKeys func loadDotenv() { values, err := godotenv.Read() @@ -185,13 +199,13 @@ func Start(cfg config.Config, logWriter io.Writer, desktop bool) (*Instance, err if err := os.MkdirAll(dataDir, 0o700); err != nil { return nil, fmt.Errorf("创建数据目录: %w", err) } - lock := flock.New(filepath.Join(dataDir, "gemini-web2api.lock")) + lock := flock.New(lockPath) locked, err := lock.TryLock() if err != nil { return nil, fmt.Errorf("获取实例锁: %w", err) } if !locked { - return nil, fmt.Errorf("同一目录已有 Gemini-Web2API 实例在运行(%s)", filepath.Join(dataDir, "gemini-web2api.lock")) + return nil, fmt.Errorf("同一目录已有 Gemini-Web2API 实例在运行(%s)", lockPath) } listener, err := net.Listen("tcp", cfg.ListenAddress) reserved := "" @@ -367,11 +381,11 @@ func logStartup(cfg config.Config, accountCount int) { logging.Info(logging.SourceService, "Gemini-Web2API 启动", "版本", Version(), "Go", strings.TrimPrefix(runtime.Version(), "go"), "系统", runtime.GOOS+"/"+runtime.GOARCH) proxies := config.SplitList(cfg.ProxyURL) for index, proxy := range proxies { - proxies[index] = maskProxy(proxy) + proxies[index] = proxypool.Redact(proxy) } proxy := strings.Join(proxies, ",") if proxy == "" { - proxy = cmp.Or(maskProxy(proxypool.System()), "无") + proxy = cmp.Or(proxypool.Redact(proxypool.System()), "无") } key := "未设置" if cfg.ProxyAPIKey != "" { @@ -414,18 +428,6 @@ func authDir(paths []string) string { return "auth" } -// maskProxy 隐藏代理 URL 中的密码 -func maskProxy(value string) string { - if at := strings.LastIndex(value, "@"); at > 0 { - if scheme := strings.Index(value, "://"); scheme >= 0 && scheme < at { - if user, _, found := strings.Cut(value[scheme+3:at], ":"); found { - return value[:scheme+3] + user + ":***" + value[at:] - } - } - } - return value -} - // newHTTPServer 创建 HTTP 服务,活动请求在停机时先等待结束 func newHTTPServer(handler http.Handler) *http.Server { return &http.Server{Handler: handler, ReadHeaderTimeout: 10 * time.Second} diff --git a/internal/app/router.go b/internal/app/router.go index 94a7507..e21d483 100644 --- a/internal/app/router.go +++ b/internal/app/router.go @@ -26,11 +26,15 @@ type Deps struct { Admin *admin.Deps } +// localProxies 是可以代客户端声明来源地址的对端:只有本机的反向代理转发的 X-Forwarded-For 计入客户端地址 +var localProxies = []string{"127.0.0.1", "::1"} + // NewRouter 注册就绪检查与全部兼容 API func NewRouter(deps Deps) *gin.Engine { pool := deps.Pool gin.SetMode(gin.ReleaseMode) router := gin.New() + _ = router.SetTrustedProxies(localProxies) router.Use(gin.Recovery(), securityHeaders) router.GET("/readyz", func(c *gin.Context) { diff --git a/internal/auth/state.go b/internal/auth/state.go index 8cdcacc..c6e4f51 100644 --- a/internal/auth/state.go +++ b/internal/auth/state.go @@ -1,6 +1,7 @@ package auth import ( + "cmp" "encoding/json" "fmt" "os" @@ -168,6 +169,15 @@ func accountIDFromPath(path string) string { return name } +// Inherit 让本次导入的 metadata 沿用 path 处已导入账号的停用标记与未重新填写的固定代理 +func Inherit(path string, metadata Metadata) Metadata { + if previous, err := Load(path); err == nil { + metadata.Disabled = previous.state.Metadata.Disabled + metadata.Proxy = cmp.Or(metadata.Proxy, previous.state.Metadata.Proxy) + } + return metadata +} + // New 创建一个待写入的认证状态文件 func New(path string, state StorageState) (*File, error) { absPath, err := filepath.Abs(strings.TrimSpace(path)) @@ -204,7 +214,7 @@ func (f *File) Account() LoadedAccount { proxyURL := strings.TrimSpace(f.state.Metadata.Proxy) source := gemini.AccountSource{ ID: f.state.Metadata.ID, - Cookies: toGeminiCookies(f.state.Cookies), + Cookies: GeminiCookies(f.state.Cookies), Language: f.state.Metadata.Language, AuthUser: f.state.Metadata.AuthUser, } @@ -221,6 +231,13 @@ func (f *File) Path() string { return f.path } +// Metadata 返回账号固定身份 +func (f *File) Metadata() Metadata { + f.mu.Lock() + defer f.mu.Unlock() + return f.state.Metadata +} + // SetDisabled 修改账号停用标记并写回 func (f *File) SetDisabled(disabled bool) error { f.mu.Lock() @@ -237,13 +254,6 @@ func (f *File) SetProxy(proxy string) error { return f.writeLocked() } -// Proxy 返回账号固定代理 -func (f *File) Proxy() string { - f.mu.Lock() - defer f.mu.Unlock() - return f.state.Metadata.Proxy -} - // SetOAuth 更新续签材料与来源 Profile 并写回 func (f *File) SetOAuth(material OAuthMaterial, profile string) error { f.mu.Lock() @@ -257,7 +267,7 @@ func (f *File) SetOAuth(material OAuthMaterial, profile string) error { func (f *File) SaveCookies(cookies []gemini.Cookie) error { f.mu.Lock() defer f.mu.Unlock() - f.state.Cookies = toStateCookies(cookies) + f.state.Cookies = StateCookies(cookies) return f.writeLocked() } @@ -307,39 +317,25 @@ func validateOAuthMaterial(material *OAuthMaterial) error { return nil } -func toGeminiCookies(cookies []StateCookie) []gemini.Cookie { +// GeminiCookies 把认证状态中的 Cookie 转为协议客户端使用的 Cookie +func GeminiCookies(cookies []StateCookie) []gemini.Cookie { result := make([]gemini.Cookie, 0, len(cookies)) for _, cookie := range cookies { - path := cookie.Path - if path == "" { - path = "/" - } result = append(result, gemini.Cookie{ - Name: cookie.Name, - Value: cookie.Value, - Domain: cookie.Domain, - Path: path, - Expires: int64(cookie.Expires), - HTTPOnly: cookie.HTTPOnly, - Secure: cookie.Secure, - SameSite: cookie.SameSite, + Name: cookie.Name, Value: cookie.Value, Domain: cookie.Domain, Path: cookie.Path, + Expires: int64(cookie.Expires), HTTPOnly: cookie.HTTPOnly, Secure: cookie.Secure, SameSite: cookie.SameSite, }) } return result } -func toStateCookies(cookies []gemini.Cookie) []StateCookie { +// StateCookies 把协议客户端的 Cookie 转为认证状态中的 Cookie +func StateCookies(cookies []gemini.Cookie) []StateCookie { result := make([]StateCookie, 0, len(cookies)) for _, cookie := range cookies { result = append(result, StateCookie{ - Name: cookie.Name, - Value: cookie.Value, - Domain: cookie.Domain, - Path: cookie.Path, - Expires: float64(cookie.Expires), - HTTPOnly: cookie.HTTPOnly, - Secure: cookie.Secure, - SameSite: cookie.SameSite, + Name: cookie.Name, Value: cookie.Value, Domain: cookie.Domain, Path: cookie.Path, + Expires: float64(cookie.Expires), HTTPOnly: cookie.HTTPOnly, Secure: cookie.Secure, SameSite: cookie.SameSite, }) } return result diff --git a/internal/balancer/balancer.go b/internal/balancer/balancer.go index fbc017f..c6d767d 100644 --- a/internal/balancer/balancer.go +++ b/internal/balancer/balancer.go @@ -6,7 +6,6 @@ import ( "fmt" "maps" "math/rand/v2" - "net/url" "slices" "sort" "strings" @@ -15,6 +14,7 @@ import ( "github.com/Mag1cFall/Gemini-Web2API/internal/gemini" "github.com/Mag1cFall/Gemini-Web2API/internal/logging" + "github.com/Mag1cFall/Gemini-Web2API/internal/proxypool" ) // SourceGuest 是访客会话的来源 @@ -81,6 +81,7 @@ type AccountStatus struct { Profile string `json:"profile,omitempty"` Proxy string `json:"proxy,omitempty"` ProxySource string `json:"proxy_source,omitempty"` + FixedProxy string `json:"fixed_proxy,omitempty"` Tier string `json:"tier,omitempty"` ContextWindow int `json:"context_window,omitempty"` Models []string `json:"models"` @@ -949,9 +950,15 @@ func (p *AccountPool) Usage(ctx context.Context, refresh bool) PoolUsageStatus { func (e *accountEntry) status(now time.Time) AccountStatus { status := AccountStatus{ AccountID: e.AccountID, State: e.State, Enabled: e.State != StateDisabled, Source: e.Info.Source, Profile: e.Info.Profile, - Proxy: maskProxy(e.Info.Proxy), ProxySource: e.Info.ProxySource, Models: []string{}, ConsecutiveFails: e.ConsecutiveFails, LastError: e.LastError, + Proxy: proxypool.Redact(e.Info.Proxy), ProxySource: e.Info.ProxySource, Models: []string{}, ConsecutiveFails: e.ConsecutiveFails, LastError: e.LastError, LastUsed: timePointer(e.LastUsed), ReadyAt: timePointer(e.ReadyAt), LastErrorAt: timePointer(e.LastErrorAt), } + switch e.Info.ProxySource { + case "fixed": + status.FixedProxy = e.Info.Proxy + case "direct": + status.FixedProxy = proxypool.Direct + } if e.State == StateReady && now.Before(e.Cooldown.until) { status.State = StateCooldown status.CooldownUntil, status.CooldownReason = timePointer(e.Cooldown.until), e.Cooldown.reason @@ -1002,18 +1009,6 @@ func timePointer(value time.Time) *time.Time { return © } -// maskProxy 隐藏代理 URL 中的密码 -func maskProxy(value string) string { - parsed, err := url.Parse(value) - if err != nil || parsed.User == nil { - return value - } - if _, ok := parsed.User.Password(); ok { - parsed.User = url.UserPassword(parsed.User.Username(), "***") - } - return parsed.String() -} - func errorText(err error) string { if err == nil { return "" diff --git a/internal/chromeauth/auth.go b/internal/chromeauth/auth.go index fe4a602..03a902a 100644 --- a/internal/chromeauth/auth.go +++ b/internal/chromeauth/auth.go @@ -5,13 +5,13 @@ import ( "context" "crypto/aes" "crypto/cipher" - "encoding/json" "fmt" "net/url" "path/filepath" "regexp" "sort" "strings" + "sync" "time" "github.com/Mag1cFall/Gemini-Web2API/internal/auth" @@ -36,6 +36,14 @@ type Account struct { Importable bool `json:"importable"` // Reason 是账号不可导入的原因,不可导入且为空时表示该账号未在 Chrome 登录 Reason string `json:"reason,omitempty"` + // token 是 token_service 中该账号的续签材料 + token tokenRecord +} + +// tokenRecord 是 token_service 中一个账号的加密 refresh token 与 wrapped binding key +type tokenRecord struct { + encryptedToken []byte + bindingKey []byte } const ( @@ -47,8 +55,7 @@ const ( // ImportOptions 保存批量导入参数 type ImportOptions struct { - ChromeRoot string - Output string + Output string // Proxy 是写入账号的固定代理,RequestProxy 是没有固定代理时导入请求使用的代理 Proxy string RequestProxy string @@ -56,20 +63,10 @@ type ImportOptions struct { Emails []string } -// ImportResult 描述单个账号导入结果 -type ImportResult struct { - Profile string `json:"profile"` - Email string `json:"email"` - Imported bool `json:"imported"` - CookieCount int `json:"cookieCount"` - Path string `json:"path"` - CompletedAt string `json:"completedAt"` -} - +// pendingImport 是通过验证后写入 path 的认证状态与验证请求使用的代理 type pendingImport struct { - state auth.StorageState - result ImportResult - // requestProxy 是验证请求使用的代理 + state auth.StorageState + path string requestProxy string } @@ -85,105 +82,90 @@ type CookieImportOptions struct { AuthUser int } +// Chrome 是一次读取的本机 Chrome 账号,多次导入共用账号发现结果与 App-Bound 主密钥 +type Chrome struct { + root string + accounts []Account + // masterKey 在首次导入时读取 App-Bound v20 主密钥 + masterKey func() ([]byte, error) +} + // Discover 只读列出本机 Chrome Google 账号 func Discover(chromeRoot string) ([]Account, error) { return discoverPlatform(chromeRoot) } -// Refresh 使用已保存的 OAuth 续签材料重新签发 Google Cookie -func Refresh(ctx context.Context, material auth.OAuthMaterial, proxy string) ([]gemini.Cookie, error) { - if err := ensurePlatformImport(); err != nil { - return nil, err - } - proxyURL, err := ValidateProxy(proxy) +// OpenChrome 发现 chromeRoot 中的 Google 账号 +func OpenChrome(chromeRoot string) (*Chrome, error) { + accounts, err := Discover(chromeRoot) if err != nil { return nil, err } - cookies, err := fetchGoogleCookies(ctx, material.GaiaID, material.RefreshToken, material.WrappedBindingKey, proxyURL) + return &Chrome{ + root: chromeRoot, accounts: accounts, + masterKey: sync.OnceValues(func() ([]byte, error) { return retrieveV20Key(chromeRoot) }), + }, nil +} + +// Accounts 返回发现的账号 +func (c *Chrome) Accounts() []Account { + return c.accounts +} + +// Refresh 使用已保存的 OAuth 续签材料重新签发 Google Cookie +func Refresh(ctx context.Context, material auth.OAuthMaterial, proxy string) ([]gemini.Cookie, error) { + cookies, err := fetchGoogleCookies(ctx, material.GaiaID, material.RefreshToken, material.WrappedBindingKey, proxy) if err != nil { return nil, err } - return toGeminiCookies(cookies), nil + return auth.GeminiCookies(toStorageCookies(cookies)), nil } -// ImportCookies 从 Cookie 文本生成短期账号状态 -func ImportCookies(ctx context.Context, options CookieImportOptions) (ImportResult, int, error) { - proxyURL, requestProxy, err := importProxies(options.Proxy, options.RequestProxy) - if err != nil { - return ImportResult{}, 0, err - } +// ImportCookies 从 Cookie 文本生成账号状态,返回状态文件路径与模型数 +func ImportCookies(ctx context.Context, options CookieImportOptions) (string, int, error) { id := AccountID(options.ID) if id == "" { - return ImportResult{}, 0, fmt.Errorf("id 必须包含英文字母或数字") + return "", 0, fmt.Errorf("id 必须包含英文字母或数字") } if options.AuthUser < 0 { - return ImportResult{}, 0, fmt.Errorf("authuser 不能为负数") + return "", 0, fmt.Errorf("authuser 不能为负数") } cookies, err := parseCookies(options.Cookies) if err != nil { - return ImportResult{}, 0, err + return "", 0, err } path := filepath.Join(options.Output, id, storageFileName) - state := auth.StorageState{ - Cookies: cookies, - Origins: []json.RawMessage{}, - Metadata: auth.Metadata{ - ID: id, Proxy: proxyURL, - Source: auth.ImportSource{Browser: "cookie-header"}, - Language: defaultImportLanguage, - AuthUser: options.AuthUser, - }, - } - absPath, err := filepath.Abs(path) + metadata, requestProxy, err := importIdentity(path, auth.Metadata{ + ID: id, Proxy: options.Proxy, Source: auth.ImportSource{Browser: "cookie-header"}, + Language: defaultImportLanguage, AuthUser: options.AuthUser, + }, options.RequestProxy) if err != nil { - return ImportResult{}, 0, fmt.Errorf("解析认证状态路径: %w", err) + return "", 0, err } - pending := pendingImport{ - state: state, - result: ImportResult{ - Profile: "cookie-header", Imported: true, CookieCount: len(cookies), Path: absPath, - }, - requestProxy: requestProxy, - } - results, modelCount, err := validateAndWriteImports(ctx, []pendingImport{pending}) + pending := pendingImport{state: auth.StorageState{Cookies: cookies, Metadata: metadata}, path: path, requestProxy: requestProxy} + paths, modelCount, err := validateAndWriteImports(ctx, []pendingImport{pending}) if err != nil { - return ImportResult{}, 0, err + return "", 0, err } - return results[0], modelCount, nil + return paths[0], modelCount, nil } -// Import 用本机 Chrome 的 OAuth 续签材料生成协议账号状态 -func Import(ctx context.Context, options ImportOptions) ([]ImportResult, int, error) { - if err := ensurePlatformImport(); err != nil { - return nil, 0, err - } - proxyURL, requestProxy, err := importProxies(options.Proxy, options.RequestProxy) - if err != nil { - return nil, 0, err - } - accounts, err := Discover(options.ChromeRoot) +// Import 用 OAuth 续签材料为选中的账号生成协议账号状态,返回各状态文件的路径与模型数 +func (c *Chrome) Import(ctx context.Context, options ImportOptions) ([]string, int, error) { + selected, err := selectAccounts(c.accounts, options.Profiles, options.Emails) if err != nil { return nil, 0, err } - selected, err := selectAccounts(accounts, options.Profiles, options.Emails) - if err != nil { - return nil, 0, err - } - masterKey, err := retrieveV20Key(options.ChromeRoot) - if err != nil { - return nil, 0, err + if len(selected) == 0 { + return nil, 0, fmt.Errorf("没有选择要导入的账号") } - if len(masterKey) != 32 { - return nil, 0, fmt.Errorf("Chrome v20 主密钥长度异常") - } - pending := make([]pendingImport, 0, len(selected)) for _, account := range selected { - result, err := importAccount(ctx, account, options, proxyURL, requestProxy, masterKey) + item, err := c.importAccount(ctx, account, options) if err != nil { return nil, 0, fmt.Errorf("导入 %s: %w", account.Email, err) } - pending = append(pending, result) + pending = append(pending, item) } return validateAndWriteImports(ctx, pending) } @@ -246,6 +228,15 @@ func missingValues(requested map[string]struct{}, found map[string]struct{}) []s return missing } +// importIdentity 返回写入 path 的账号身份与导入请求使用的代理:账号已导入过时沿用它的停用标记与未重新填写的固定代理 +func importIdentity(path string, metadata auth.Metadata, fallback string) (auth.Metadata, string, error) { + metadata.Proxy = strings.TrimSpace(metadata.Proxy) + metadata = auth.Inherit(path, metadata) + fixed, requestProxy, err := importProxies(metadata.Proxy, fallback) + metadata.Proxy = fixed + return metadata, requestProxy, err +} + // importProxies 校验导入时填写的账号代理,返回写入账号的固定代理与导入请求使用的代理:direct 为直连,未填写时使用 fallback func importProxies(proxy string, fallback string) (string, string, error) { proxy = strings.TrimSpace(proxy) @@ -293,71 +284,51 @@ func decryptV20Token(masterKey []byte, encrypted []byte) (string, error) { if err != nil { return "", fmt.Errorf("refresh token 解密失败") } - token := string(plaintext) - if len(token) != 103 || !strings.HasPrefix(token, "1//0") { - return "", fmt.Errorf("refresh token 解密结果格式异常") - } - return token, nil + return string(plaintext), nil } -func importAccount(ctx context.Context, account Account, options ImportOptions, proxyURL string, requestProxy string, masterKey []byte) (pendingImport, error) { - gaiaID, encryptedToken, wrappedKey, err := readTokenService(options.ChromeRoot, account.Profile, account.GaiaID) +// importAccount 为一个账号签发 Cookie 并组成待验证的认证状态 +func (c *Chrome) importAccount(ctx context.Context, account Account, options ImportOptions) (pendingImport, error) { + id := AccountID(account.Email) + path := filepath.Join(options.Output, id, storageFileName) + metadata, requestProxy, err := importIdentity(path, auth.Metadata{ + ID: id, Proxy: options.Proxy, Source: auth.ImportSource{Browser: "chrome", Profile: account.Profile}, + }, options.RequestProxy) if err != nil { return pendingImport{}, err } - token, err := decryptV20Token(masterKey, encryptedToken) + masterKey, err := c.masterKey() if err != nil { return pendingImport{}, err } - cookies, err := fetchGoogleCookies(ctx, gaiaID, token, wrappedKey, requestProxy) + token, err := decryptV20Token(masterKey, account.token.encryptedToken) if err != nil { return pendingImport{}, err } - language, err := readProfileLanguage(options.ChromeRoot, account.Profile) + cookies, err := fetchGoogleCookies(ctx, account.GaiaID, token, account.token.bindingKey, requestProxy) if err != nil { return pendingImport{}, err } - - email := strings.ToLower(strings.TrimSpace(account.Email)) - path := filepath.Join(options.Output, AccountID(email), storageFileName) - state := auth.StorageState{ - Cookies: toStorageCookies(cookies), - Origins: []json.RawMessage{}, - Metadata: auth.Metadata{ - ID: AccountID(email), - Proxy: proxyURL, - Source: auth.ImportSource{Browser: "chrome", Profile: account.Profile}, - OAuth: &auth.OAuthMaterial{ - GaiaID: gaiaID, RefreshToken: token, WrappedBindingKey: wrappedKey, - }, - Language: language, - }, + if metadata.Language, err = readProfileLanguage(c.root, account.Profile); err != nil { + return pendingImport{}, err } - absPath, err := filepath.Abs(path) - if err != nil { - return pendingImport{}, fmt.Errorf("解析认证状态路径: %w", err) - } - return pendingImport{ - state: state, - result: ImportResult{ - Profile: account.Profile, Email: email, Imported: true, - CookieCount: len(cookies), Path: absPath, - }, - requestProxy: requestProxy, - }, nil + metadata.OAuth = &auth.OAuthMaterial{GaiaID: account.GaiaID, RefreshToken: token, WrappedBindingKey: account.token.bindingKey} + state := auth.StorageState{Cookies: toStorageCookies(cookies), Metadata: metadata} + return pendingImport{state: state, path: path, requestProxy: requestProxy}, nil } -func validateAndWriteImports(ctx context.Context, pending []pendingImport) ([]ImportResult, int, error) { +// validateAndWriteImports 以首页、模型目录与用量请求验证每个账号,全部通过后写入认证状态,返回各状态文件的路径与模型数 +func validateAndWriteImports(ctx context.Context, pending []pendingImport) ([]string, int, error) { models := make(map[string]struct{}) for index := range pending { item := &pending[index] source := gemini.AccountSource{ - ID: item.state.Metadata.ID, Cookies: stateCookiesToGemini(item.state.Cookies), + ID: item.state.Metadata.ID, Cookies: auth.GeminiCookies(item.state.Cookies), Language: item.state.Metadata.Language, AuthUser: item.state.Metadata.AuthUser, - } - source.Save = func(cookies []gemini.Cookie) error { - item.state.Cookies = geminiCookiesToState(cookies) - return nil + Save: func(cookies []gemini.Cookie) error { + item.state.Cookies = auth.StateCookies(cookies) + return nil + }, } client, err := gemini.NewClient(source, item.requestProxy, false) if err != nil { @@ -377,20 +348,18 @@ func validateAndWriteImports(ctx context.Context, pending []pendingImport) ([]Im models[model.ID] = struct{}{} } } - results := make([]ImportResult, len(pending)) - completedAt := time.Now().UTC().Format(time.RFC3339Nano) - for index := range pending { - file, err := auth.New(pending[index].result.Path, pending[index].state) + paths := make([]string, len(pending)) + for index, item := range pending { + file, err := auth.New(item.path, item.state) if err != nil { return nil, 0, err } if err := file.Write(); err != nil { return nil, 0, err } - pending[index].result.CompletedAt = completedAt - results[index] = pending[index].result + paths[index] = file.Path() } - return results, len(models), nil + return paths, len(models), nil } // AccountID 返回邮箱对应的账号 ID @@ -431,64 +400,3 @@ func toStorageCookies(cookies []multiloginCookie) []auth.StateCookie { } return result } - -func toGeminiCookies(cookies []multiloginCookie) []gemini.Cookie { - return stateCookiesToGemini(toStorageCookies(cookies)) -} - -func stateCookiesToGemini(cookies []auth.StateCookie) []gemini.Cookie { - result := make([]gemini.Cookie, 0, len(cookies)) - for _, cookie := range cookies { - result = append(result, gemini.Cookie{ - Name: cookie.Name, Value: cookie.Value, Domain: cookie.Domain, Path: cookie.Path, - Expires: int64(cookie.Expires), HTTPOnly: cookie.HTTPOnly, Secure: cookie.Secure, SameSite: cookie.SameSite, - }) - } - return result -} - -func geminiCookiesToState(cookies []gemini.Cookie) []auth.StateCookie { - result := make([]auth.StateCookie, 0, len(cookies)) - for _, cookie := range cookies { - result = append(result, auth.StateCookie{ - Name: cookie.Name, Value: cookie.Value, Domain: cookie.Domain, Path: cookie.Path, - Expires: float64(cookie.Expires), HTTPOnly: cookie.HTTPOnly, Secure: cookie.Secure, SameSite: cookie.SameSite, - }) - } - return result -} - -func parseCookieHeader(header string) ([]auth.StateCookie, error) { - header = strings.TrimSpace(header) - if len(header) >= len("Cookie:") && strings.EqualFold(header[:len("Cookie:")], "Cookie:") { - header = strings.TrimSpace(header[len("Cookie:"):]) - } - if header == "" || strings.ContainsAny(header, "\r\n") { - return nil, fmt.Errorf("cookie 必须是一行 Cookie Header") - } - cookies := make([]auth.StateCookie, 0) - names := make(map[string]struct{}) - for _, item := range strings.Split(header, ";") { - parts := strings.SplitN(strings.TrimSpace(item), "=", 2) - if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" { - return nil, fmt.Errorf("Cookie Header 项 %q 格式无效", strings.TrimSpace(item)) - } - name := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - if value == "" { - return nil, fmt.Errorf("Cookie %s 的值为空", name) - } - if _, exists := names[name]; exists { - continue - } - names[name] = struct{}{} - domain := ".google.com" - if strings.HasPrefix(name, "__Host-") { - domain = "gemini.google.com" - } - cookies = append(cookies, auth.StateCookie{ - Name: name, Value: value, Domain: domain, Path: "/", Secure: true, - }) - } - return cookies, nil -} diff --git a/internal/chromeauth/cookies.go b/internal/chromeauth/cookies.go index 45f826f..2007b37 100644 --- a/internal/chromeauth/cookies.go +++ b/internal/chromeauth/cookies.go @@ -37,6 +37,42 @@ func parseCookies(input string) ([]auth.StateCookie, error) { return cookies, nil } +// parseCookieHeader 解析一行 Cookie Header,__Host- 前缀的 Cookie 归 gemini.google.com,其余归 .google.com +func parseCookieHeader(header string) ([]auth.StateCookie, error) { + header = strings.TrimSpace(header) + if len(header) >= len("Cookie:") && strings.EqualFold(header[:len("Cookie:")], "Cookie:") { + header = strings.TrimSpace(header[len("Cookie:"):]) + } + if header == "" || strings.ContainsAny(header, "\r\n") { + return nil, fmt.Errorf("cookie 必须是一行 Cookie Header") + } + cookies := make([]auth.StateCookie, 0) + names := make(map[string]struct{}) + for _, item := range strings.Split(header, ";") { + parts := strings.SplitN(strings.TrimSpace(item), "=", 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" { + return nil, fmt.Errorf("Cookie Header 项 %q 格式无效", strings.TrimSpace(item)) + } + name := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + if value == "" { + return nil, fmt.Errorf("Cookie %s 的值为空", name) + } + if _, exists := names[name]; exists { + continue + } + names[name] = struct{}{} + domain := ".google.com" + if strings.HasPrefix(name, "__Host-") { + domain = "gemini.google.com" + } + cookies = append(cookies, auth.StateCookie{ + Name: name, Value: value, Domain: domain, Path: "/", Secure: true, + }) + } + return cookies, nil +} + // googleDomain 报告 Cookie 域是否为 google.com 或其子域 func googleDomain(domain string) bool { domain = strings.ToLower(strings.TrimPrefix(domain, ".")) diff --git a/internal/chromeauth/discover.go b/internal/chromeauth/discover.go index 93c2bae..2ea8012 100644 --- a/internal/chromeauth/discover.go +++ b/internal/chromeauth/discover.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/Mag1cFall/Gemini-Web2API/internal/auth" "github.com/Mag1cFall/Gemini-Web2API/internal/gemini" ) @@ -32,7 +33,7 @@ func DiscoverCookieAccounts(ctx context.Context, text string, proxy string, fall var accounts []CookieAccount for authUser := range maxCookieAuthUsers { source := gemini.AccountSource{ - ID: "cookie", Cookies: stateCookiesToGemini(cookies), Language: defaultImportLanguage, AuthUser: authUser, + ID: "cookie", Cookies: auth.GeminiCookies(cookies), Language: defaultImportLanguage, AuthUser: authUser, Save: func([]gemini.Cookie) error { return nil }, } client, err := gemini.NewClient(source, requestProxy, false) diff --git a/internal/chromeauth/platform_other.go b/internal/chromeauth/platform_other.go index 71e2daf..d7ffcaf 100644 --- a/internal/chromeauth/platform_other.go +++ b/internal/chromeauth/platform_other.go @@ -9,18 +9,10 @@ func DefaultChromeRoot() (string, error) { return "", fmt.Errorf("Chrome OAuth 导入仅支持 Windows") } -func ensurePlatformImport() error { - return fmt.Errorf("Chrome OAuth 导入仅支持 Windows") -} - func discoverPlatform(string) ([]Account, error) { return nil, fmt.Errorf("Chrome OAuth 导入仅支持 Windows") } -func readTokenService(string, string, string) (string, []byte, []byte, error) { - return "", nil, nil, fmt.Errorf("Chrome OAuth 导入仅支持 Windows") -} - func readProfileLanguage(string, string) (string, error) { return "", fmt.Errorf("Chrome OAuth 导入仅支持 Windows") } diff --git a/internal/chromeauth/platform_windows.go b/internal/chromeauth/platform_windows.go index 8384084..c750bdd 100644 --- a/internal/chromeauth/platform_windows.go +++ b/internal/chromeauth/platform_windows.go @@ -27,10 +27,6 @@ func DefaultChromeRoot() (string, error) { return filepath.Join(localAppData, "Google", "Chrome", "User Data"), nil } -func ensurePlatformImport() error { - return nil -} - func discoverPlatform(chromeRoot string) ([]Account, error) { data, err := os.ReadFile(filepath.Join(chromeRoot, "Local State")) if err != nil { @@ -81,7 +77,7 @@ func discoverPlatform(chromeRoot string) ([]Account, error) { case found && !strings.HasPrefix(string(record.encryptedToken), "v20"): account.Reason = ReasonNeedsReauth default: - account.Importable = found + account.Importable, account.token = found, record } accounts = append(accounts, account) } @@ -156,12 +152,6 @@ func openWebData(chromeRoot string, profile string) (database *sql.DB, closeData }, nil } -// tokenRecord 是 token_service 中一个账号的加密 refresh token 与 wrapped binding key -type tokenRecord struct { - encryptedToken []byte - bindingKey []byte -} - // readTokenServices 读取 Profile 中全部 Google 账号的 token_service 记录,键为 Gaia ID func readTokenServices(chromeRoot string, profile string) (map[string]tokenRecord, error) { database, closeDatabase, err := openWebData(chromeRoot, profile) @@ -189,22 +179,6 @@ func readTokenServices(chromeRoot string, profile string) (map[string]tokenRecor return records, nil } -// readTokenService 返回一个账号的 Gaia ID、加密 refresh token 与 wrapped binding key -func readTokenService(chromeRoot string, profile string, gaiaID string) (string, []byte, []byte, error) { - records, err := readTokenServices(chromeRoot, profile) - if err != nil { - return "", nil, nil, err - } - record, found := records[gaiaID] - if !found { - return "", nil, nil, fmt.Errorf("%s 的 token_service 没有账号 %s", profile, gaiaID) - } - if len(record.encryptedToken) == 0 { - return "", nil, nil, fmt.Errorf("%s 的 token_service 缺少认证材料", profile) - } - return gaiaID, record.encryptedToken, record.bindingKey, nil -} - func readProfileLanguage(chromeRoot string, profile string) (string, error) { data, err := os.ReadFile(filepath.Join(chromeRoot, profile, "Preferences")) if err != nil { diff --git a/internal/chromeauth/reimport.go b/internal/chromeauth/reimport.go index 840a5d6..00ac1df 100644 --- a/internal/chromeauth/reimport.go +++ b/internal/chromeauth/reimport.go @@ -26,20 +26,19 @@ func FindChromeMaterial(gaiaID string, profile string) (ChromeMaterial, error) { if err != nil { return ChromeMaterial{}, err } - selected := "" - for _, account := range accounts { - if account.Importable && account.GaiaID == gaiaID && (selected == "" || account.Profile == profile) { - selected = account.Profile + var selected *Account + for index, account := range accounts { + if account.Importable && account.GaiaID == gaiaID && (selected == nil || account.Profile == profile) { + selected = &accounts[index] } } - if selected == "" { + if selected == nil { return ChromeMaterial{}, fmt.Errorf("Chrome 中没有可导入的同一账号") } - _, encrypted, wrappedKey, err := readTokenService(root, selected, gaiaID) - if err != nil { - return ChromeMaterial{}, err - } - return ChromeMaterial{Profile: selected, Encrypted: encrypted, chromeRoot: root, gaiaID: gaiaID, wrappedKey: wrappedKey}, nil + return ChromeMaterial{ + Profile: selected.Profile, Encrypted: selected.token.encryptedToken, + chromeRoot: root, gaiaID: gaiaID, wrappedKey: selected.token.bindingKey, + }, nil } // Decrypt 以 Chrome App-Bound 主密钥解密 refresh token,返回可保存的续签材料 @@ -48,9 +47,6 @@ func (m ChromeMaterial) Decrypt() (auth.OAuthMaterial, error) { if err != nil { return auth.OAuthMaterial{}, err } - if len(masterKey) != 32 { - return auth.OAuthMaterial{}, fmt.Errorf("Chrome v20 主密钥长度异常") - } token, err := decryptV20Token(masterKey, m.Encrypted) if err != nil { return auth.OAuthMaterial{}, err diff --git a/internal/chromeauth/setup.go b/internal/chromeauth/setup.go index 044bb01..b0c0176 100644 --- a/internal/chromeauth/setup.go +++ b/internal/chromeauth/setup.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "slices" "strconv" "strings" @@ -31,8 +32,8 @@ func (values *stringList) Set(value string) error { return nil } -// RunSetup 执行首次账号配置 -func RunSetup(ctx context.Context, args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, interactive bool) error { +// RunSetup 执行账号配置,servedAuth 是同一数据目录中运行的服务使用的认证路径,没有服务在运行时为空 +func RunSetup(ctx context.Context, args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, interactive bool, servedAuth []string) error { stdin = bufio.NewReader(stdin) flags := flag.NewFlagSet("setup", flag.ContinueOnError) flags.SetOutput(stderr) @@ -73,6 +74,8 @@ func RunSetup(ctx context.Context, args []string, stdin io.Reader, stdout io.Wri return fmt.Errorf("创建临时目录: %w", err) } defer os.RemoveAll(outputPath) + } else if slices.ContainsFunc(servedAuth, func(served string) bool { return sameDirectory(outputPath, served) }) { + return fmt.Errorf("服务正在运行并使用认证目录 %s:在管理端“导入账号”页导入,或停止服务后再运行 setup", outputPath) } finish := func(paths []string, modelCount int) error { if origin == "" { @@ -103,54 +106,51 @@ func RunSetup(ctx context.Context, args []string, stdin io.Reader, stdout io.Wri if len(profiles) != 0 || len(emails) != 0 || strings.TrimSpace(*chromeRoot) != "" { return fmt.Errorf("--cookie、--cookie-file 不能与 Chrome 账号选择参数同时使用") } - result, modelCount, err := ImportCookies(ctx, CookieImportOptions{ + path, modelCount, err := ImportCookies(ctx, CookieImportOptions{ Cookies: cookies, ID: *accountID, Output: outputPath, Proxy: proxyURL, RequestProxy: requestProxy, AuthUser: *authUser, }) if err != nil { return err } - return finish([]string{result.Path}, modelCount) + return finish([]string{path}, modelCount) } if strings.TrimSpace(*accountID) != "" || *authUser != 0 { return fmt.Errorf("--id、--authuser 只与 --cookie、--cookie-file 一起使用") } + selecting := len(profiles) == 0 && len(emails) == 0 + if selecting && !interactive { + return fmt.Errorf("非交互模式请通过 --profile 或 --email 选择账号") + } root, err := resolveChromeRoot(*chromeRoot) if err != nil { return err } - if len(profiles) == 0 && len(emails) == 0 { - if !interactive { - return fmt.Errorf("非交互模式请通过 --profile 或 --email 选择账号") - } - accounts, err := Discover(root) - if err != nil { - return err - } - emails, err = promptAccounts(accounts, stdin, stderr) - if err != nil { + chrome, err := OpenChrome(root) + if err != nil { + return err + } + if selecting { + if emails, err = promptAccounts(chrome.Accounts(), stdin, stderr); err != nil { return err } } - - results, modelCount, err := Import(ctx, ImportOptions{ - ChromeRoot: root, - Output: outputPath, - Proxy: proxyURL, - RequestProxy: requestProxy, - Profiles: profiles, - Emails: emails, + paths, modelCount, err := chrome.Import(ctx, ImportOptions{ + Output: outputPath, Proxy: proxyURL, RequestProxy: requestProxy, Profiles: profiles, Emails: emails, }) if err != nil { return err } - paths := make([]string, 0, len(results)) - for _, result := range results { - paths = append(paths, result.Path) - } return finish(paths, modelCount) } +// sameDirectory 报告两个路径是否为同一个已存在的目录 +func sameDirectory(left string, right string) bool { + leftInfo, leftErr := os.Stat(left) + rightInfo, rightErr := os.Stat(right) + return leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo) +} + // promptAccounts 列出可导入账号并按输入的编号返回选中账号的邮箱 func promptAccounts(accounts []Account, input io.Reader, output io.Writer) (stringList, error) { available := make([]Account, 0, len(accounts)) diff --git a/web/src/features/accounts/AccountPage.tsx b/web/src/features/accounts/AccountPage.tsx index 52a3a94..6b7f2f7 100644 --- a/web/src/features/accounts/AccountPage.tsx +++ b/web/src/features/accounts/AccountPage.tsx @@ -23,7 +23,6 @@ import { cooldownReason, cooldownReasonMessages, } from '@/features/accounts/messages' -import { fixedProxy } from '@/features/accounts/proxy' import { ProxyDialog } from '@/features/accounts/ProxyDialog' import { ProxyValue } from '@/features/accounts/ProxyValue' import { accountsKey, useAccounts, useClearCooldown } from '@/features/accounts/queries' @@ -404,10 +403,10 @@ export function AccountPage() { {proxyOpen && ( { - saveProxy.mutate({ proxy, current: fixedProxy(account) }) + saveProxy.mutate({ proxy, current: account.fixed_proxy ?? '' }) }} onClose={() => { setProxyOpen(false) diff --git a/web/src/features/accounts/AccountsPage.tsx b/web/src/features/accounts/AccountsPage.tsx index b284200..aa9cea8 100644 --- a/web/src/features/accounts/AccountsPage.tsx +++ b/web/src/features/accounts/AccountsPage.tsx @@ -13,7 +13,6 @@ import { Tooltip } from '@/components/ui/tooltip' import { AccountTasks } from '@/features/accounts/AccountTasks' import { runBatch } from '@/features/accounts/batch' import { accountsMessages, accountStateMessages } from '@/features/accounts/messages' -import { fixedProxy } from '@/features/accounts/proxy' import { ProxyDialog } from '@/features/accounts/ProxyDialog' import { ProxyValue } from '@/features/accounts/ProxyValue' import { accountsKey, useAccounts } from '@/features/accounts/queries' @@ -45,7 +44,7 @@ function applyBatch({ action, proxy = '' }: BatchRequest, account: Account) { case 'verify': return api(path + '/verify', { method: 'POST' }) case 'proxy': - return proxy === fixedProxy(account) + return proxy === (account.fixed_proxy ?? '') ? Promise.resolve() : api(path, { method: 'PUT', json: { proxy } }) default: diff --git a/web/src/features/accounts/proxy.ts b/web/src/features/accounts/proxy.ts deleted file mode 100644 index 40c44c9..0000000 --- a/web/src/features/accounts/proxy.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Account } from '@/features/accounts/types' - -// fixedProxy 返回账号的固定代理,direct 表示直连,从代理池或系统代理分配时为空 -export function fixedProxy(account: Account) { - if (account.proxy_source === 'direct') return 'direct' - return account.proxy_source === 'fixed' ? (account.proxy ?? '') : '' -} diff --git a/web/src/features/accounts/types.ts b/web/src/features/accounts/types.ts index 894b200..0abaca4 100644 --- a/web/src/features/accounts/types.ts +++ b/web/src/features/accounts/types.ts @@ -39,6 +39,8 @@ export interface Account { profile?: string proxy?: string proxy_source?: 'fixed' | 'direct' | 'pool' | 'system' + // fixed_proxy 是账号固定代理的原文,direct 表示直连,从代理池或系统代理分配时没有 + fixed_proxy?: string tier?: string context_window?: number models: string[]