validate.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. """Validate backend auth files and optionally remove confirmed 401 entries.
  2. This validator relies on CPA management API to classify auth files when CPA backend is active.
  3. """
  4. from __future__ import annotations
  5. import argparse
  6. import json
  7. import shutil
  8. import tempfile
  9. from concurrent.futures import ThreadPoolExecutor, as_completed
  10. from dataclasses import asdict, dataclass
  11. from pathlib import Path
  12. from urllib.error import HTTPError, URLError
  13. from urllib.request import Request, urlopen
  14. from .common import CpaClient, DEFAULT_MANAGEMENT_BASE_URL, DEFAULT_POOL_DIR, get_management_key, now
  15. CPA_INVALID_KEYWORDS = ("unauthorized", "invalidated")
  16. @dataclass(frozen=True)
  17. class ValidateEntry:
  18. name: str
  19. status_code: int
  20. action: str
  21. detail: str = ""
  22. auth_index: str = ""
  23. account_id: str = ""
  24. def to_dict(self) -> dict[str, object]:
  25. return asdict(self)
  26. def _compact_text(value: str, limit: int = 200) -> str:
  27. return " ".join(str(value or "").split())[:limit]
  28. def _delete_cpa_file(name: str, client: object | None = None) -> bool:
  29. if client is None or not hasattr(client, "delete_auth_file"):
  30. return False
  31. return bool(getattr(client, "delete_auth_file")(name))
  32. def _delete_pool_backup(pool_dir: Path, name: str) -> None:
  33. (pool_dir / name).unlink(missing_ok=True)
  34. def _iter_auth_files(snapshot_dir: Path, limit: int | None = None) -> list[Path]:
  35. files = sorted(
  36. path
  37. for path in snapshot_dir.glob("*.json")
  38. if path.is_file() and "@" in path.name
  39. )
  40. if limit is not None and limit >= 0:
  41. return files[:limit]
  42. return files
  43. def _extract_account_id(data: dict[str, object]) -> str:
  44. return str(data.get("account_id") or "").strip()
  45. def _fetch_management_json(
  46. management_base_url: str,
  47. suffix: str,
  48. management_key: str | None = None,
  49. ) -> tuple[bool, dict[str, object] | None]:
  50. key = str(management_key or "").strip() or get_management_key()
  51. if not key:
  52. return False, None
  53. request = Request(
  54. f"{management_base_url.rstrip('/')}/{suffix.lstrip('/')}",
  55. headers={"Authorization": f"Bearer {key}"},
  56. )
  57. try:
  58. with urlopen(request, timeout=20) as response:
  59. payload = json.loads(response.read().decode("utf-8"))
  60. except (HTTPError, URLError, TimeoutError, json.JSONDecodeError, OSError):
  61. return False, None
  62. return isinstance(payload, dict), payload if isinstance(payload, dict) else None
  63. def _parse_management_status_message(status_message: str) -> tuple[int, str]:
  64. raw = str(status_message or "").strip()
  65. if not raw:
  66. return 200, "active"
  67. lowered = raw.lower()
  68. if any(keyword in lowered for keyword in CPA_INVALID_KEYWORDS):
  69. return 401, "unauthorized"
  70. try:
  71. payload = json.loads(raw)
  72. except json.JSONDecodeError:
  73. return 0, _compact_text(raw)
  74. if not isinstance(payload, dict):
  75. return 0, _compact_text(raw)
  76. err = payload.get("error")
  77. if isinstance(err, dict):
  78. err_type = str(err.get("type") or "").strip().lower()
  79. err_message = str(err.get("message") or "").strip()
  80. if err_type:
  81. if err_type in {"unauthorized", "invalidated"}:
  82. return 401, err_message or err_type
  83. if err_type in {"usage_limit_reached", "rate_limit_exceeded"}:
  84. return 429, err_message or err_type
  85. return 0, err_message or err_type
  86. return 0, _compact_text(raw)
  87. def _fetch_management_auth_files(
  88. management_base_url: str,
  89. management_key: str | None = None,
  90. ) -> tuple[bool, dict[str, dict[str, object]]]:
  91. ok, payload = _fetch_management_json(management_base_url, "auth-files", management_key)
  92. if not ok or payload is None:
  93. return False, {}
  94. files = payload.get("files")
  95. if not isinstance(files, list):
  96. return False, {}
  97. result: dict[str, dict[str, object]] = {}
  98. for item in files:
  99. if not isinstance(item, dict):
  100. continue
  101. name = str(item.get("name") or "").strip()
  102. if not name:
  103. continue
  104. result[name] = item
  105. return True, result
  106. def _fetch_used_auth_indexes(
  107. management_base_url: str,
  108. management_key: str | None = None,
  109. ) -> tuple[bool, set[str]]:
  110. ok, payload = _fetch_management_json(management_base_url, "usage", management_key)
  111. if not ok or payload is None:
  112. return False, set()
  113. usage = payload.get("usage")
  114. if not isinstance(usage, dict):
  115. return True, set()
  116. auth_indexes: set[str] = set()
  117. apis = usage.get("apis")
  118. if not isinstance(apis, dict):
  119. return True, auth_indexes
  120. for api_data in apis.values():
  121. if not isinstance(api_data, dict):
  122. continue
  123. models = api_data.get("models")
  124. if not isinstance(models, dict):
  125. continue
  126. for model_data in models.values():
  127. if not isinstance(model_data, dict):
  128. continue
  129. details = model_data.get("details")
  130. if not isinstance(details, list):
  131. continue
  132. for detail in details:
  133. if not isinstance(detail, dict):
  134. continue
  135. auth_index = str(detail.get("auth_index") or "").strip()
  136. if auth_index:
  137. auth_indexes.add(auth_index)
  138. return True, auth_indexes
  139. def _select_auth_files(
  140. auth_files: list[Path],
  141. *,
  142. scope: str,
  143. management_base_url: str,
  144. management_key: str | None = None,
  145. ) -> tuple[list[Path], bool, str | None]:
  146. if scope == "all":
  147. return auth_files, False, None
  148. auth_ok, auth_meta = _fetch_management_auth_files(management_base_url, management_key)
  149. usage_ok, used_auth_indexes = _fetch_used_auth_indexes(management_base_url, management_key)
  150. if not auth_ok or not usage_ok:
  151. return [], True, "management_data_unavailable"
  152. if not used_auth_indexes:
  153. return [], False, "no_active_auth_indexes"
  154. selected = [
  155. path
  156. for path in auth_files
  157. if str(auth_meta.get(path.name, {}).get("auth_index") or "").strip() in used_auth_indexes
  158. ]
  159. return selected, False, None
  160. def _validate_file(path: Path, auth_meta: dict[str, object] | None) -> ValidateEntry:
  161. try:
  162. data = json.loads(path.read_text(encoding="utf-8"))
  163. except Exception as exc:
  164. return ValidateEntry(name=path.name, status_code=0, action="error", detail=f"json decode failed: {exc}")
  165. account_id = _extract_account_id(data)
  166. if not isinstance(auth_meta, dict):
  167. return ValidateEntry(
  168. name=path.name,
  169. status_code=0,
  170. action="skip",
  171. detail="missing management metadata",
  172. account_id=account_id,
  173. )
  174. auth_index = str(auth_meta.get("auth_index") or "").strip()
  175. status_message = str(auth_meta.get("status_message") or "").strip()
  176. status = str(auth_meta.get("status") or "").strip().lower()
  177. status_code, parsed_detail = _parse_management_status_message(status_message)
  178. if status_code == 401:
  179. return ValidateEntry(
  180. name=path.name,
  181. status_code=401,
  182. action="delete",
  183. detail="unauthorized by CPA management",
  184. auth_index=auth_index,
  185. account_id=account_id,
  186. )
  187. detail = parsed_detail
  188. if status and status != "active":
  189. detail = f"{status} | {parsed_detail}"
  190. return ValidateEntry(
  191. name=path.name,
  192. status_code=status_code,
  193. action="keep",
  194. detail=detail,
  195. auth_index=auth_index,
  196. account_id=account_id,
  197. )
  198. def validate_once(
  199. proxy: str | None = None,
  200. dry_run: bool = False,
  201. max_workers: int = 8,
  202. limit: int | None = None,
  203. pool_dir: Path = DEFAULT_POOL_DIR,
  204. *,
  205. client: object | None = None,
  206. scope: str = "all",
  207. management_base_url: str = DEFAULT_MANAGEMENT_BASE_URL,
  208. management_key: str | None = None,
  209. ) -> dict[str, object]:
  210. del proxy
  211. pool_dir = Path(pool_dir).expanduser().resolve()
  212. pool_dir.mkdir(parents=True, exist_ok=True)
  213. summary = {
  214. "scope": scope,
  215. "checked": 0,
  216. "selected": 0,
  217. "kept": 0,
  218. "invalid": 0,
  219. "deleted": 0,
  220. "skipped": 0,
  221. "errors": 0,
  222. "dry_run": dry_run,
  223. "results": [],
  224. "validation_limited": False,
  225. "selection_reason": None,
  226. }
  227. backend_client = client or CpaClient(management_base_url, management_key=management_key)
  228. if not getattr(backend_client, "health_check")():
  229. summary["validation_limited"] = True
  230. summary["selection_reason"] = "cpa_unavailable"
  231. return summary
  232. snapshot_dir = Path(tempfile.mkdtemp(prefix="zhuce6_validate_", dir="/tmp"))
  233. try:
  234. for entry in getattr(backend_client, "list_auth_files")():
  235. name = str(entry.get("name") or "").strip()
  236. if not name or "@" not in name or not name.endswith(".json"):
  237. continue
  238. payload = getattr(backend_client, "get_auth_file")(name)
  239. if not isinstance(payload, dict):
  240. continue
  241. (snapshot_dir / name).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
  242. auth_files = _iter_auth_files(snapshot_dir, limit=limit)
  243. selected_files, limited, selection_reason = _select_auth_files(
  244. auth_files,
  245. scope=scope,
  246. management_base_url=management_base_url,
  247. management_key=management_key,
  248. )
  249. auth_ok, auth_meta = (True, {}) if scope == "all" else _fetch_management_auth_files(management_base_url, management_key)
  250. summary["selected"] = len(selected_files)
  251. summary["validation_limited"] = limited
  252. summary["selection_reason"] = selection_reason
  253. if limited:
  254. return summary
  255. if not auth_ok:
  256. summary["validation_limited"] = True
  257. summary["selection_reason"] = "management_data_unavailable"
  258. return summary
  259. if not selected_files:
  260. return summary
  261. with ThreadPoolExecutor(max_workers=max(1, max_workers)) as executor:
  262. future_map = {
  263. executor.submit(
  264. _validate_file,
  265. path,
  266. {} if scope == "all" else auth_meta.get(path.name),
  267. ): path
  268. for path in selected_files
  269. }
  270. for future in as_completed(future_map):
  271. entry = future.result()
  272. summary["checked"] = int(summary["checked"]) + 1
  273. cast_results = summary["results"]
  274. assert isinstance(cast_results, list)
  275. cast_results.append(entry.to_dict())
  276. if entry.action == "keep":
  277. summary["kept"] = int(summary["kept"]) + 1
  278. print(f"[{now()}] [validate] ✅ {entry.name} keep | {entry.status_code}")
  279. continue
  280. if entry.action == "skip":
  281. summary["skipped"] = int(summary["skipped"]) + 1
  282. print(f"[{now()}] [validate] ⏭️ {entry.name} skip | {entry.detail}")
  283. continue
  284. if entry.action == "error":
  285. summary["errors"] = int(summary["errors"]) + 1
  286. print(f"[{now()}] [validate] ⚠️ {entry.name} error | {entry.detail}")
  287. continue
  288. if entry.action == "delete":
  289. summary["invalid"] = int(summary["invalid"]) + 1
  290. if dry_run:
  291. print(f"[{now()}] [validate] 🧪 {entry.name} would delete | 401")
  292. continue
  293. deleted = _delete_cpa_file(entry.name, backend_client)
  294. if deleted:
  295. _delete_pool_backup(pool_dir, entry.name)
  296. summary["deleted"] = int(summary["deleted"]) + 1
  297. print(f"[{now()}] [validate] ❌ {entry.name} deleted")
  298. else:
  299. summary["errors"] = int(summary["errors"]) + 1
  300. print(f"[{now()}] [validate] ⚠️ {entry.name} delete failed")
  301. finally:
  302. shutil.rmtree(snapshot_dir, ignore_errors=True)
  303. return summary
  304. def print_validate_summary(summary: dict[str, object]) -> None:
  305. selection_reason = summary.get("selection_reason") or "-"
  306. print(
  307. f"[{now()}] [validate] summary"
  308. f" | scope={summary['scope']}"
  309. f" | selected={summary['selected']}"
  310. f" | checked={summary['checked']}"
  311. f" | kept={summary['kept']}"
  312. f" | invalid={summary['invalid']}"
  313. f" | deleted={summary['deleted']}"
  314. f" | skipped={summary['skipped']}"
  315. f" | errors={summary['errors']}"
  316. f" | dry_run={summary['dry_run']}"
  317. f" | validation_limited={summary['validation_limited']}"
  318. f" | selection_reason={selection_reason}"
  319. )
  320. def main() -> None:
  321. from core.settings import AppSettings
  322. env_settings = AppSettings.from_env()
  323. parser = argparse.ArgumentParser(description="Validate zhuce6 backend tokens and classify 401 files")
  324. parser.add_argument("--once", action="store_true", help="Compatibility flag. Validation runs once either way.")
  325. parser.add_argument("--dry-run", action="store_true", help="Do not delete files, only report them.")
  326. parser.add_argument("--proxy", default=None, help="Optional proxy URL")
  327. parser.add_argument("--management-base-url", default=env_settings.cpa_management_base_url or DEFAULT_MANAGEMENT_BASE_URL, help="CPA management base url")
  328. parser.add_argument("--management-key", default=env_settings.cpa_management_key, help="可选 CPA management key")
  329. parser.add_argument("--scope", choices=("all", "used"), default="all", help="all=full validate, used=fast validate using CPA management usage")
  330. parser.add_argument("--max-workers", type=int, default=8, help="Concurrent validation workers")
  331. parser.add_argument("--limit", type=int, default=None, help="Optional cap for scanned auth files")
  332. parser.add_argument("--pool-dir", default=str(env_settings.pool_dir or DEFAULT_POOL_DIR), help="本地 pool 目录")
  333. args = parser.parse_args()
  334. del args.once
  335. summary = validate_once(
  336. proxy=str(args.proxy or "").strip() or None,
  337. dry_run=args.dry_run,
  338. max_workers=args.max_workers,
  339. limit=args.limit,
  340. pool_dir=Path(args.pool_dir).expanduser().resolve(),
  341. scope=args.scope,
  342. management_base_url=str(args.management_base_url or "").strip() or DEFAULT_MANAGEMENT_BASE_URL,
  343. management_key=str(args.management_key or "").strip() or None,
  344. )
  345. print_validate_summary(summary)
  346. if __name__ == "__main__":
  347. main()