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.
This commit is contained in:
chaos committed 2026-07-11 17:47:34 +08:00
1 parent d8e65ae4ef
commit 0d8265de03
5 files changed
+299 -1

No files matched your search

+213
View File
@@ -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=<filename>
Authorization: Bearer <password>
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"