From 0d8265de039e03f4adc4d7c647d222c59b6cc197 Mon Sep 17 00:00:00 2001 From: chaos Date: Sat, 11 Jul 2026 17:47:34 +0800 Subject: [PATCH] Add automatic CPA Management API import after mint Upload xai-*.json to POST /v0/management/auth-files when cpa_auto_import_remote is enabled. Failures are logged only and never block registration. --- .env.example | 7 + README.md | 50 ++++- config.example.json | 10 + cpa_export.py | 213 ++++++++++++++++++++++ scripts/backfill_cpa_xai_from_accounts.py | 20 ++ 5 files changed, 299 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index c0a2e44..4c80bc4 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,10 @@ GROK2API_APP_KEY= # 代理(config.json 的 proxy / cpa_proxy 为空时会回退到这些) # https_proxy=http://127.0.0.1:7890 # http_proxy=http://127.0.0.1:7890 + +# ===== CPA 远程自动导入(Management API)===== +# 写出 xai-*.json 后 POST 到 {CPA_REMOTE_BASE}/v0/management/auth-files +# 需同时在 config.json 设 cpa_auto_import_remote=true,或仅用环境变量时也要打开该开关 +# CPA_REMOTE_BASE=http://127.0.0.1:8317 +# CPA_REMOTE_PASSWORD= +# 兼容别名:CPA_MANAGEMENT_PASSWORD / MANAGEMENT_PASSWORD diff --git a/README.md b/README.md index 1a80a7e..b40489d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ ↓ cpa_auths/xai-email.json 【注册机主导出】 ↓ (cpa_copy_to_hotload=true 时) - CPA auth-dir 热加载 【可选】 + CPA auth-dir 热加载 【可选·本地拷贝】 + ↓ (cpa_auto_import_remote=true 时) + CPA Management API 导入 【可选·HTTP】 ↓ CLIProxyAPI :8317 model=grok-4.5 ``` @@ -128,6 +130,8 @@ cp config.example.json config.json | `GROK2API_APP_KEY` | 远端 grok2api Admin | | `API_REVERSE_TOOLS` | 可选:外部 `cpa_xai` 父目录 | | `CPA_EXPORT` | `auto_register` 是否导出 CPA(`0/false` 关) | +| `CPA_REMOTE_BASE` | 远程 CPA 根地址(配合 `cpa_auto_import_remote`) | +| `CPA_REMOTE_PASSWORD` | 远程 CPA 管理密码 | | `https_proxy` / `http_proxy` | 代理回退 | 模板见 `.env.example`。 @@ -220,6 +224,48 @@ cpa_proxy > proxy > 环境变量 https_proxy/http_proxy | `cpa_mint_cookie_inject` | `true` | 回退时注入注册 cookie | | `cpa_mint_workers` | `-1` | mint 并发:`-1` 自动;`0` 内联;`1-10` 固定 | | `cpa_mint_required` | `false` | mint 失败是否整号失败 | +| `cpa_auto_import_remote` | `false` | 写出后是否自动导入远程 CPA | +| `cpa_remote_base` | `""` | CPA **根地址**,如 `http://127.0.0.1:8317` | +| `cpa_remote_password` | `""` | CPA 管理密码(也可用环境变量) | +| `cpa_remote_import_retries` | `3` | 远程导入重试次数 | +| `cpa_remote_import_retry_delay` | `2` | 重试间隔(秒) | + +### 自动导入远程 CPA(Management API) + +写出 `cpa_auths/xai-*.json` 后,可自动调用 CLIProxyAPI 管理接口导入,无需再手动 `cp` 到 auth-dir。 + +```json +{ + "cpa_export_enabled": true, + "cpa_auto_import_remote": true, + "cpa_remote_base": "http://127.0.0.1:8317", + "cpa_remote_password": "你的管理密码" +} +``` + +或写在 `.env`: + +```bash +CPA_REMOTE_BASE=http://127.0.0.1:8317 +CPA_REMOTE_PASSWORD=你的管理密码 +``` + +说明: + +- **只填根地址**(到端口即可);程序会请求 + `POST {base}/v0/management/auth-files?name=xai-email.json` +- 认证头:`Authorization: Bearer <管理密码>`(同时附带 `X-Management-Key`) +- 管理密码对应 CPA 的 `MANAGEMENT_PASSWORD` 或 `remote-management.secret-key` **原文** +- 远程访问需 CPA 允许管理端(设置 `MANAGEMENT_PASSWORD` 或 `allow-remote: true`) +- **导入失败不阻断注册**:本地文件已写出仍算成功,只打日志 + `[cpa] remote import failed: ...` +- 成功日志:`[cpa] remote import ok -> http://host:8317 name=xai-....json` + +等价手动调用: + +```bash +curl -sS -X POST "http://127.0.0.1:8317/v0/management/auth-files?name=xai-user@domain.json" -H "Authorization: Bearer $CPA_REMOTE_PASSWORD" -H "Content-Type: application/json" --data-binary @./cpa_auths/xai-user@domain.json +``` ### 落盘约定 @@ -306,6 +352,7 @@ uv run python grok_register_ttk.py 2. 可选:推 grok2api 3. 若 `cpa_export_enabled`:协议 mint(失败则浏览器)→ `cpa_auths/xai-.json` 4. 若 `cpa_copy_to_hotload`:再拷到 `cpa_hotload_dir` +5. 若 `cpa_auto_import_remote`:POST 导入到 CPA Management API(失败只记日志) ### B. 存量号补 CPA(只 mint,不重新注册) @@ -392,6 +439,7 @@ curl -sS http://127.0.0.1:8317/v1/chat/completions \ | Hotmail 收不到码 | 检查四段凭证、ClientID/Token、IMAP 主机与 alias 计数 | | 有 token 但无 grok-4.5 | `cpa_base_url` 是否为 `cli-chat-proxy` | | 注册成功但无 `cpa_auths` | `cpa_export_enabled`?看 `cpa_auths/cpa_auth_failed.txt` | +| 远程导入失败 | 检查 `cpa_remote_base` / 管理密码、CPA 是否开启 management、`allow-remote`;本地文件仍在 `cpa_auths/` | 调试原则:以 **token 端点返回 `access_token` + refresh_token** 为准;probe 看 `/v1/models` 是否含 `grok-4.5`。 diff --git a/config.example.json b/config.example.json index c47d422..f10313e 100644 --- a/config.example.json +++ b/config.example.json @@ -131,6 +131,16 @@ "cpa_copy_to_hotload": false, "// cpa_hotload_dir": "CPA 容器/进程挂载的 auth-dir(copy_to_hotload=true 时生效)", "cpa_hotload_dir": "", + "// cpa_auto_import_remote": "写出 xai-*.json 后是否自动 POST 到 CPA Management API 导入", + "cpa_auto_import_remote": false, + "// cpa_remote_base": "CPA 根地址,只填到端口即可,如 http://127.0.0.1:8317(程序自动拼 /v0/management/auth-files)", + "cpa_remote_base": "", + "// cpa_remote_password": "CPA 管理密码(MANAGEMENT_PASSWORD / remote-management.secret-key 原文)。也可用环境变量 CPA_REMOTE_PASSWORD", + "cpa_remote_password": "", + "// cpa_remote_import_retries": "远程导入失败重试次数", + "cpa_remote_import_retries": 3, + "// cpa_remote_import_retry_delay": "远程导入重试间隔(秒)", + "cpa_remote_import_retry_delay": 2, "// cpa_base_url": "免费 Build 必须是 cli-chat-proxy,不要写成 api.x.ai", "cpa_base_url": "https://cli-chat-proxy.grok.com/v1", "// cpa_proxy": "OIDC mint 专用代理(device-code/token/probe/浏览器)。优先级: cpa_proxy > proxy > 环境 https_proxy。空=用 proxy。", diff --git a/cpa_export.py b/cpa_export.py index 5d2a9a3..588532e 100644 --- a/cpa_export.py +++ b/cpa_export.py @@ -3,6 +3,10 @@ OIDC package lives at ./cpa_xai (bundled with this project). Optional override: config `api_reverse_tools` / env `API_REVERSE_TOOLS` points at a directory that *contains* the `cpa_xai` package. + +After mint, optionally: + - copy into local CPA auth-dir (`cpa_copy_to_hotload`) + - POST into remote CPA Management API (`cpa_auto_import_remote`) """ from __future__ import annotations @@ -13,6 +17,7 @@ import sys import time from pathlib import Path from typing import Any, Callable +from urllib.parse import quote, urlparse, urlunparse _REG_DIR = Path(__file__).resolve().parent _DEFAULT_OUT = _REG_DIR / "cpa_auths" @@ -64,6 +69,191 @@ def export_cookies_from_page(page: Any) -> list[dict]: return [] +def _normalize_cpa_remote_base(base: str) -> str: + """Normalize user-provided CPA root URL to scheme://host[:port]. + + Accepts: + http://127.0.0.1:8317 + http://host:8317/ + http://host:8317/v0/management + http://host:8317/v0/management/auth-files + """ + raw = (base or "").strip() + if not raw: + return "" + if "://" not in raw: + raw = "http://" + raw + parsed = urlparse(raw) + if not parsed.scheme or not parsed.netloc: + return raw.rstrip("/") + # Keep only scheme + netloc (drop path/query/fragment) + return urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")).rstrip("/") + + +def resolve_cpa_remote_settings(cfg: dict | None = None) -> dict[str, Any]: + """Resolve remote CPA import settings from config + env.""" + cfg = cfg or {} + enabled = bool(cfg.get("cpa_auto_import_remote", False)) + base = ( + (cfg.get("cpa_remote_base") or "").strip() + or (os.environ.get("CPA_REMOTE_BASE") or "").strip() + ) + password = ( + (cfg.get("cpa_remote_password") or "").strip() + or (os.environ.get("CPA_REMOTE_PASSWORD") or "").strip() + or (os.environ.get("CPA_MANAGEMENT_PASSWORD") or "").strip() + or (os.environ.get("MANAGEMENT_PASSWORD") or "").strip() + ) + retries = int(cfg.get("cpa_remote_import_retries", 3) or 3) + delay = float(cfg.get("cpa_remote_import_retry_delay", 2) or 2) + return { + "enabled": enabled, + "base": _normalize_cpa_remote_base(base), + "password": password, + "retries": max(1, retries), + "delay": max(0.0, delay), + } + + +def import_cpa_auth_to_remote( + path: str | Path, + *, + base: str, + password: str, + retries: int = 3, + delay: float = 2.0, + log: Callable[[str], None] | None = None, + timeout: float = 12.0, +) -> dict[str, Any]: + """Upload one local xai-*.json to CPA Management API. + + POST {base}/v0/management/auth-files?name= + Authorization: Bearer + Body: raw JSON file content + + Failures are returned in the result dict; caller decides whether to ignore. + """ + _log = log or (lambda m: print(m, flush=True)) + src = Path(path) + root = _normalize_cpa_remote_base(base) + if not root: + return {"ok": False, "error": "empty cpa_remote_base"} + if not password: + return {"ok": False, "error": "empty cpa_remote_password"} + if not src.is_file(): + return {"ok": False, "error": f"auth file not found: {src}"} + + name = src.name + if not name.endswith(".json"): + return {"ok": False, "error": f"filename must end with .json: {name}"} + + try: + body = src.read_bytes() + except Exception as e: # noqa: BLE001 + return {"ok": False, "error": f"read file: {e}"} + + url = f"{root}/v0/management/auth-files?name={quote(name)}" + headers = { + "Authorization": f"Bearer {password}", + "X-Management-Key": password, + "Content-Type": "application/json", + } + + try: + from curl_cffi import requests as _req # type: ignore + except Exception as e: # noqa: BLE001 + return {"ok": False, "error": f"import curl_cffi failed: {e}"} + + last_err = "unknown" + attempts = max(1, int(retries or 1)) + for i in range(1, attempts + 1): + try: + # Admin API is local/remote management — do not use outbound proxy. + resp = _req.post( + url, + data=body, + headers=headers, + timeout=timeout, + proxies={}, + impersonate="chrome", + ) + status = int(getattr(resp, "status_code", 0) or 0) + text = "" + try: + text = (resp.text or "")[:500] + except Exception: + text = "" + if status in (200, 207): + # Prefer JSON status field when present + ok_status = True + try: + j = resp.json() + st = str((j or {}).get("status") or "").lower() + if st and st not in ("ok", "partial", "success"): + ok_status = False + last_err = f"status={st} body={text}" + except Exception: + pass + if ok_status: + _log(f"[cpa] remote import ok -> {root} name={name}") + return { + "ok": True, + "base": root, + "name": name, + "status_code": status, + "body": text, + } + else: + last_err = f"HTTP {status}: {text or '(empty)'}" + except Exception as e: # noqa: BLE001 + last_err = str(e) + + if i < attempts: + _log(f"[cpa] remote import retry {i}/{attempts}: {last_err}") + if delay > 0: + time.sleep(delay) + + _log(f"[cpa] remote import failed: {last_err}") + return { + "ok": False, + "base": root, + "name": name, + "error": last_err, + } + + +def maybe_import_cpa_auth_remote( + path: str | Path, + *, + config: dict | None = None, + log_callback: Callable[[str], None] | None = None, +) -> dict[str, Any]: + """Config-gated remote import helper (safe no-op when disabled).""" + cfg = config or {} + log = log_callback or (lambda m: print(m, flush=True)) + settings = resolve_cpa_remote_settings(cfg) + if not settings["enabled"]: + return {"ok": False, "skipped": True, "reason": "disabled"} + if not settings["base"] or not settings["password"]: + log( + "[cpa] remote import enabled but missing cpa_remote_base / " + "cpa_remote_password (or CPA_REMOTE_BASE / CPA_REMOTE_PASSWORD)" + ) + return { + "ok": False, + "skipped": True, + "reason": "missing_base_or_password", + } + return import_cpa_auth_to_remote( + path, + base=settings["base"], + password=settings["password"], + retries=settings["retries"], + delay=settings["delay"], + log=log, + ) + + def export_cpa_xai_for_account( email: str, password: str, @@ -220,6 +410,29 @@ def export_cpa_xai_for_account( log(f"[cpa] hotload copy failed: {e}") result["cpa_copy_error"] = str(e) + # Optional: push auth file into remote CPA via Management API. + # Failures never flip result["ok"] — local mint already succeeded. + if result.get("ok") and result.get("path"): + try: + remote = maybe_import_cpa_auth_remote( + result["path"], + config=cfg, + log_callback=log, + ) + if remote.get("skipped"): + result["remote_import_skipped"] = remote.get("reason") or "skipped" + elif remote.get("ok"): + result["remote_import_ok"] = True + result["remote_import_base"] = remote.get("base") + result["remote_import_name"] = remote.get("name") + else: + result["remote_import_ok"] = False + result["remote_import_error"] = remote.get("error") or "unknown" + except Exception as e: # noqa: BLE001 + log(f"[cpa] remote import unexpected error: {e}") + result["remote_import_ok"] = False + result["remote_import_error"] = str(e) + # failure log under register dir if not result.get("ok"): fail_path = out_dir / "cpa_auth_failed.txt" diff --git a/scripts/backfill_cpa_xai_from_accounts.py b/scripts/backfill_cpa_xai_from_accounts.py index 47062e7..46a596f 100644 --- a/scripts/backfill_cpa_xai_from_accounts.py +++ b/scripts/backfill_cpa_xai_from_accounts.py @@ -25,6 +25,7 @@ if str(_ROOT) not in sys.path: sys.path.insert(0, str(_ROOT)) from cpa_xai import existing_cpa_emails, mint_and_export, parse_accounts_file # noqa: E402 +from cpa_export import maybe_import_cpa_auth_remote # noqa: E402 def main() -> int: @@ -87,6 +88,7 @@ def main() -> int: help="Always open fresh Chromium (default)", ) args = ap.parse_args() + args._register_cfg = {} if args.headless: args.headed = False @@ -108,6 +110,7 @@ def main() -> int: args.proxy = (cfg.get("cpa_proxy") or cfg.get("proxy") or "").strip() if not args.cpa_dir: args.cpa_dir = (cfg.get("cpa_hotload_dir") or "").strip() + args._register_cfg = cfg except Exception as e: # noqa: BLE001 print(f"warn: read config proxy failed: {e}", flush=True) if not args.proxy: @@ -176,6 +179,23 @@ def main() -> int: shutil.copy2(src, dst) os.chmod(dst, 0o600) print(f"copied -> {dst}", flush=True) + # optional remote Management API import (config-gated; never fails the mint) + try: + reg_cfg = getattr(args, "_register_cfg", None) or {} + remote = maybe_import_cpa_auth_remote( + r["path"], + config=reg_cfg, + log_callback=lambda m: print(m, flush=True), + ) + if remote.get("ok"): + print( + f"remote import ok -> {remote.get('base')} name={remote.get('name')}", + flush=True, + ) + elif not remote.get("skipped"): + print(f"remote import failed: {remote.get('error')}", flush=True) + except Exception as e: # noqa: BLE001 + print(f"remote import unexpected error: {e}", flush=True) else: fail_n += 1 if args.fail_log: