- Clear CF block errors with egress label and config hint - Log mint exit (direct/proxy); probe local proxy ports on 403 - Retry mint_proxy/proxy/pool candidates without browser mint
362 lines
12 KiB
Python
362 lines
12 KiB
Python
"""注册成功钩子:SSO → OAuth Authorization Code(PKCE, referrer=grok-build)
|
||
→ 写出 CPA (CLIProxyAPI) 的 xai-<email>.json → 推送到远端 CLIProxyAPI 导入。
|
||
|
||
- 本地写盘目录:config['cpa_auth_dir'](默认 ./cpa_auths)
|
||
- 远端推送:POST config['cpa_remote_base'] + /v0/management/auth-files?name=...
|
||
认证 X-Management-Key: config['cpa_remote_secret']
|
||
|
||
2026-07 起:设备码铸造的 token 缺 referrer=grok-build,cli-chat-proxy 不可用。
|
||
默认优先走 SSO cookie 的授权码流程;仅在无 sso 且允许时才回退设备码。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any, Callable
|
||
|
||
_REG_DIR = Path(__file__).resolve().parent
|
||
_DEFAULT_OUT = _REG_DIR / "cpa_auths"
|
||
|
||
|
||
def _resolve_out_dir(cfg: dict) -> Path:
|
||
raw = str(cfg.get("cpa_auth_dir") or _DEFAULT_OUT).strip()
|
||
p = Path(raw).expanduser()
|
||
if not p.is_absolute():
|
||
p = (_REG_DIR / p).resolve()
|
||
return p
|
||
|
||
|
||
def _record_failure(out_dir: Path, email: str, reason: str) -> None:
|
||
try:
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
with open(out_dir / "cpa_auth_failed.txt", "a", encoding="utf-8") as f:
|
||
f.write(f"{email}----{reason}----{int(time.time())}\n")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _resolve_proxy(cfg: dict) -> str | None:
|
||
from oidc_mint import resolve_proxy, set_runtime_proxy
|
||
|
||
# 1) 显式 mint_proxy / proxy
|
||
proxy = (cfg.get("mint_proxy") or cfg.get("proxy") or "").strip()
|
||
# 2) 注册机代理池线程绑定(与浏览器同出口)
|
||
if not proxy:
|
||
try:
|
||
import proxy_pool
|
||
proxy = (proxy_pool.get_thread_proxy() or "").strip()
|
||
except Exception:
|
||
proxy = ""
|
||
# 3) 环境变量
|
||
if not proxy:
|
||
proxy = (
|
||
os.environ.get("https_proxy")
|
||
or os.environ.get("HTTPS_PROXY")
|
||
or os.environ.get("http_proxy")
|
||
or ""
|
||
).strip()
|
||
resolved = resolve_proxy(proxy or None)
|
||
set_runtime_proxy(resolved or None)
|
||
return resolved or None
|
||
|
||
|
||
def _proxy_label(proxy: str | None) -> str:
|
||
try:
|
||
from oidc_mint.proxyutil import proxy_log_label
|
||
|
||
return proxy_log_label(proxy or "") or "(direct)"
|
||
except Exception:
|
||
return proxy or "(direct)"
|
||
|
||
|
||
def _port_open(host: str, port: int, timeout: float = 0.25) -> bool:
|
||
import socket
|
||
|
||
try:
|
||
with socket.create_connection((host, port), timeout=timeout):
|
||
return True
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def _local_proxy_candidates(cfg: dict) -> list[str]:
|
||
"""直连被 CF 拦时尝试的本机/配置代理列表(去重)。"""
|
||
cands: list[str] = []
|
||
seen: set[str] = set()
|
||
|
||
def _add(raw: str | None) -> None:
|
||
p = (raw or "").strip()
|
||
if not p or p in seen:
|
||
return
|
||
seen.add(p)
|
||
cands.append(p)
|
||
|
||
for key in ("mint_proxy", "proxy"):
|
||
_add(str(cfg.get(key) or ""))
|
||
raw_list = cfg.get("proxy_pool") or []
|
||
if isinstance(raw_list, str):
|
||
for line in raw_list.replace(",", "\n").splitlines():
|
||
_add(line)
|
||
elif isinstance(raw_list, (list, tuple)):
|
||
for x in raw_list:
|
||
_add(str(x))
|
||
try:
|
||
import proxy_pool
|
||
|
||
for p in proxy_pool.pool_snapshot()[:8]:
|
||
_add(p)
|
||
except Exception:
|
||
pass
|
||
# 仅探测本机已监听的常见代理端口,避免空转超时
|
||
for port in (7890, 7897, 10809, 10808, 7891, 20171, 6152, 1080):
|
||
if _port_open("127.0.0.1", port):
|
||
_add(f"http://127.0.0.1:{port}")
|
||
return cands
|
||
|
||
|
||
def _is_cf_mint_error(exc: BaseException | str) -> bool:
|
||
try:
|
||
from oidc_mint.oauth_code import is_cloudflare_block
|
||
|
||
return is_cloudflare_block(exc=exc)
|
||
except Exception:
|
||
text = str(exc or "").lower()
|
||
return "403" in text and (
|
||
"cloudflare" in text or "<!doctype" in text or "oldie" in text
|
||
)
|
||
|
||
|
||
def _mint_tokens(
|
||
*,
|
||
email: str,
|
||
password: str,
|
||
sso: str,
|
||
page: Any | None,
|
||
cfg: dict,
|
||
log: Callable[[str], None],
|
||
proxy: str | None,
|
||
) -> dict[str, Any]:
|
||
"""优先 SSO 授权码;可选回退设备码。"""
|
||
from oidc_mint import set_runtime_proxy
|
||
from oidc_mint.oauth_code import OAuthCodeError, mint_from_sso, normalize_sso_cookie
|
||
|
||
sso_token = normalize_sso_cookie(sso or "")
|
||
prefer_sso = bool(cfg.get("cpa_prefer_sso_oauth", True))
|
||
allow_device = bool(cfg.get("cpa_allow_device_fallback", False))
|
||
timeout = float(cfg.get("mint_timeout_sec", 300) or 300)
|
||
max_proxy_tries = int(cfg.get("mint_proxy_retries", 4) or 4)
|
||
|
||
if prefer_sso and sso_token:
|
||
log("[cpa] 使用 SSO→OAuth(PKCE, referrer=grok-build)")
|
||
tried: set[str] = set()
|
||
proxies_to_try: list[str | None] = [proxy]
|
||
last_exc: Exception | None = None
|
||
|
||
def _expand_proxy_candidates() -> None:
|
||
for p in _local_proxy_candidates(cfg):
|
||
if p and p not in tried and p not in {
|
||
x for x in proxies_to_try if x
|
||
}:
|
||
proxies_to_try.append(p)
|
||
|
||
# 直连时先挂上本机已开端口,减少首次 403 后的空等
|
||
if not proxy:
|
||
_expand_proxy_candidates()
|
||
|
||
idx = 0
|
||
while idx < len(proxies_to_try) and idx < max(1, max_proxy_tries):
|
||
use_proxy = proxies_to_try[idx]
|
||
label = _proxy_label(use_proxy)
|
||
if idx == 0:
|
||
log(f"[cpa] mint 出口={label}")
|
||
else:
|
||
log(f"[cpa] CF/403 换出口重试 ({idx + 1}/{max_proxy_tries}): {label}")
|
||
set_runtime_proxy(use_proxy)
|
||
tried.add(use_proxy or "")
|
||
try:
|
||
return mint_from_sso(
|
||
sso_token,
|
||
proxy=use_proxy,
|
||
log=lambda m: log(f"[Debug] {m}"),
|
||
require_referrer=bool(cfg.get("cpa_require_referrer", True)),
|
||
)
|
||
except OAuthCodeError as exc:
|
||
last_exc = exc
|
||
log(f"[!] SSO→OAuth 失败: {exc}")
|
||
if _is_cf_mint_error(exc):
|
||
if use_proxy:
|
||
try:
|
||
import proxy_pool
|
||
|
||
proxy_pool.report_failure(
|
||
use_proxy, reason=f"mint CF: {str(exc)[:120]}"
|
||
)
|
||
except Exception:
|
||
pass
|
||
_expand_proxy_candidates()
|
||
idx += 1
|
||
continue
|
||
if not allow_device:
|
||
raise
|
||
log("[cpa] 回退设备码铸造(可能缺 referrer)")
|
||
break
|
||
except Exception as exc: # noqa: BLE001
|
||
last_exc = exc
|
||
log(f"[!] SSO→OAuth 异常: {exc}")
|
||
if _is_cf_mint_error(exc):
|
||
_expand_proxy_candidates()
|
||
idx += 1
|
||
continue
|
||
if not allow_device:
|
||
raise
|
||
log("[cpa] 回退设备码铸造(可能缺 referrer)")
|
||
break
|
||
# 成功已 return;失败已 continue/break
|
||
idx += 1 # pragma: no cover
|
||
else:
|
||
if last_exc is not None and not allow_device:
|
||
raise last_exc
|
||
if last_exc is not None and not allow_device:
|
||
raise last_exc
|
||
|
||
if not allow_device and not sso_token:
|
||
raise RuntimeError("无 sso cookie,且已禁用设备码回退;无法铸造带 referrer 的 token")
|
||
|
||
# 设备码回退(旧路径,通常无 referrer)
|
||
from oidc_mint import mint_with_browser
|
||
|
||
if page is None and not (email and password):
|
||
raise RuntimeError("设备码回退需要 page 或 email/password")
|
||
|
||
log("[cpa] 使用设备码铸造(兼容路径)")
|
||
tokens = mint_with_browser(
|
||
email=email,
|
||
password=password,
|
||
page=page,
|
||
proxy=proxy,
|
||
browser_timeout_sec=timeout,
|
||
force_standalone=(page is None),
|
||
cookies=None,
|
||
poll_log=lambda m: log(f"[Debug] {m}"),
|
||
)
|
||
return tokens
|
||
|
||
|
||
# ── 主入口 ──
|
||
|
||
def export_cpa_for_account(
|
||
email: str,
|
||
password: str = "",
|
||
*,
|
||
page: Any | None = None,
|
||
sso: str = "",
|
||
config: dict | None = None,
|
||
log_callback: Callable[[str], None] | None = None,
|
||
) -> dict:
|
||
"""铸造 OIDC → 写本地 xai-<email>.json → 推送远端 CLIProxyAPI。
|
||
|
||
优先使用 sso cookie 走 Authorization Code + referrer=grok-build。
|
||
返回 {ok, email, path, pushed, push_status?, error?}。
|
||
"""
|
||
cfg = config or {}
|
||
log = log_callback or (lambda m: print(m, flush=True))
|
||
|
||
if not cfg.get("cpa_export_enabled", True):
|
||
log("[cpa] 已关闭导出,跳过")
|
||
return {"ok": False, "skipped": True, "reason": "disabled"}
|
||
email = (email or "").strip()
|
||
if not email:
|
||
return {"ok": False, "error": "缺少 email", "email": email}
|
||
|
||
import cpa
|
||
from oidc_mint.oauth_code import normalize_sso_cookie
|
||
|
||
out_dir = _resolve_out_dir(cfg)
|
||
proxy = _resolve_proxy(cfg)
|
||
base_url = cfg.get("cpa_base_url") or cpa.CLI_BASE_URL
|
||
sso_token = normalize_sso_cookie(sso or "")
|
||
|
||
try:
|
||
tokens = _mint_tokens(
|
||
email=email,
|
||
password=password or "",
|
||
sso=sso_token,
|
||
page=page,
|
||
cfg=cfg,
|
||
log=log,
|
||
proxy=proxy,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
log(f"[!] 铸造失败: {exc}")
|
||
_record_failure(out_dir, email, str(exc))
|
||
if cfg.get("mint_required", False):
|
||
raise
|
||
return {"ok": False, "error": str(exc), "email": email}
|
||
|
||
try:
|
||
payload = cpa.build_cpa_xai_auth(
|
||
email=email,
|
||
access_token=tokens["access_token"],
|
||
refresh_token=tokens["refresh_token"],
|
||
id_token=tokens.get("id_token"),
|
||
expires_in=tokens.get("expires_in"),
|
||
base_url=base_url,
|
||
sso=tokens.get("sso") or sso_token or None,
|
||
)
|
||
path = cpa.write_cpa_xai_auth(out_dir, payload)
|
||
filename = Path(path).name
|
||
except Exception as exc: # noqa: BLE001
|
||
log(f"[!] 写本地文件失败: {exc}")
|
||
_record_failure(out_dir, email, f"write: {exc}")
|
||
if cfg.get("mint_required", False):
|
||
raise
|
||
return {"ok": False, "error": str(exc), "email": email}
|
||
|
||
ref = payload.get("referrer") or tokens.get("referrer") or ""
|
||
log(f"[Debug] 已写本地: {path} referrer={ref or '(empty)'}")
|
||
result: dict[str, Any] = {
|
||
"ok": True,
|
||
"email": email,
|
||
"path": str(path),
|
||
"pushed": False,
|
||
"referrer": ref,
|
||
}
|
||
|
||
# 推送远端 CLIProxyAPI(失败记入 cpa_push_pending.txt,下次推送时一并重试)
|
||
if cfg.get("cpa_push_enabled", False):
|
||
remote_base = str(cfg.get("cpa_remote_base") or "").strip()
|
||
secret = str(cfg.get("cpa_remote_secret") or "").strip()
|
||
if not remote_base or not secret:
|
||
log("[!] 推送已开启但未配置 cpa_remote_base/cpa_remote_secret,跳过推送")
|
||
cpa.record_push_failure(out_dir, filename, "remote not configured")
|
||
else:
|
||
push_proxy = str(cfg.get("cpa_push_proxy") or "").strip() or None
|
||
verify_tls = bool(cfg.get("cpa_remote_verify_tls", True))
|
||
push_res = cpa.push_with_queue(
|
||
out_dir,
|
||
filename,
|
||
payload,
|
||
remote_base=remote_base,
|
||
secret=secret,
|
||
proxy=push_proxy,
|
||
verify_tls=verify_tls,
|
||
flush_first=True,
|
||
log=log,
|
||
)
|
||
result["pushed"] = bool(push_res.get("pushed"))
|
||
if "push_status" in push_res:
|
||
result["push_status"] = push_res["push_status"]
|
||
if push_res.get("push_error"):
|
||
result["push_error"] = push_res["push_error"]
|
||
if push_res.get("flush"):
|
||
result["push_flush"] = push_res["flush"]
|
||
if not result["pushed"] and cfg.get("cpa_push_required", False):
|
||
result["ok"] = False
|
||
result["error"] = (
|
||
f"push: {result.get('push_error') or result.get('push_status')}"
|
||
)
|
||
|
||
return result
|