cleanup_stale_cf_resources.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. from __future__ import annotations
  2. import json
  3. import os
  4. from pathlib import Path
  5. import sys
  6. from typing import Any, TextIO
  7. from curl_cffi import requests as cffi_requests
  8. from core.cfmail import load_cfmail_accounts_from_file
  9. from core.env_loader import load_env_file
  10. from core.paths import DEFAULT_ENV_FILE, resolve_cfmail_config_path
  11. def _print(stdout: TextIO, message: str) -> None:
  12. print(message, file=stdout)
  13. def _normalize_domain(value: str) -> str:
  14. return str(value or "").strip().lower().rstrip(".")
  15. def _load_active_domain(config_path: Path) -> str:
  16. accounts = [
  17. item
  18. for item in load_cfmail_accounts_from_file(config_path, silent=False)
  19. if isinstance(item, dict) and item.get("enabled", True)
  20. ]
  21. for item in reversed(accounts):
  22. domain = _normalize_domain(str(item.get("email_domain") or ""))
  23. if domain:
  24. return domain
  25. raise RuntimeError(f"no active cfmail domain found in {config_path}")
  26. def _load_env(env_file: Path) -> None:
  27. env_values = _read_env_file(env_file)
  28. load_env_file(env_file)
  29. _override_env_from_file(env_file, env_values)
  30. cfmail_env_file = Path(
  31. str(env_values.get("ZHUCE6_CFMAIL_ENV_FILE") or env_file.parent / "config" / "cfmail_provision.env").strip()
  32. or str(env_file.parent / "config" / "cfmail_provision.env")
  33. ).expanduser().resolve()
  34. load_env_file(cfmail_env_file)
  35. _override_env_from_file(cfmail_env_file)
  36. def _read_env_file(path: Path) -> dict[str, str]:
  37. payload: dict[str, str] = {}
  38. if not path.is_file():
  39. return payload
  40. for raw_line in path.read_text(encoding="utf-8").splitlines():
  41. line = raw_line.strip()
  42. if not line or line.startswith("#"):
  43. continue
  44. if line.startswith("export "):
  45. line = line[7:]
  46. key, sep, value = line.partition("=")
  47. if not sep:
  48. continue
  49. key = key.strip()
  50. value = value.strip().strip('"').strip("'")
  51. if key:
  52. payload[key] = value
  53. return payload
  54. def _override_env_from_file(path: Path, payload: dict[str, str] | None = None) -> None:
  55. values = payload if payload is not None else _read_env_file(path)
  56. for key, value in values.items():
  57. os.environ[key] = value
  58. def _headers(auth_email: str, auth_key: str) -> dict[str, str]:
  59. return {
  60. "X-Auth-Email": auth_email,
  61. "X-Auth-Key": auth_key,
  62. "Content-Type": "application/json",
  63. }
  64. def _request(method: str, url: str, *, headers: dict[str, str]) -> dict[str, Any]:
  65. response = cffi_requests.request(
  66. method.upper(),
  67. url,
  68. headers=headers,
  69. timeout=30,
  70. impersonate="chrome",
  71. )
  72. payload = response.json() if response.content else {}
  73. if response.status_code >= 400 or not payload.get("success", False):
  74. raise RuntimeError(f"{method.upper()} {url} failed: HTTP {response.status_code} {json.dumps(payload, ensure_ascii=False)}")
  75. return payload
  76. def _request_paginated(url: str, *, headers: dict[str, str]) -> list[dict[str, Any]]:
  77. page = 1
  78. results: list[dict[str, Any]] = []
  79. while True:
  80. separator = "&" if "?" in url else "?"
  81. payload = _request("GET", f"{url}{separator}page={page}&per_page=100", headers=headers)
  82. items = payload.get("result") or []
  83. if isinstance(items, list):
  84. results.extend(item for item in items if isinstance(item, dict))
  85. info = payload.get("result_info") or {}
  86. total_pages = int(info.get("total_pages") or 1)
  87. if page >= total_pages:
  88. break
  89. page += 1
  90. return results
  91. def _routing_rule_domains(rule: dict[str, Any]) -> set[str]:
  92. domains: set[str] = set()
  93. for matcher in rule.get("matchers") or []:
  94. if not isinstance(matcher, dict):
  95. continue
  96. value = _normalize_domain(str(matcher.get("value") or ""))
  97. if "*@" in value:
  98. domains.add(value.split("*@", 1)[-1])
  99. return domains
  100. def run_cleanup(
  101. *,
  102. env_file: Path | None = None,
  103. config_path: Path | None = None,
  104. stdout: TextIO | None = None,
  105. ) -> dict[str, Any]:
  106. out = stdout or sys.stdout
  107. resolved_env_file = (env_file or DEFAULT_ENV_FILE).expanduser().resolve()
  108. resolved_config_path = (config_path or resolve_cfmail_config_path()).expanduser().resolve()
  109. _load_env(resolved_env_file)
  110. auth_email = str(os.getenv("ZHUCE6_CFMAIL_CF_AUTH_EMAIL", "")).strip()
  111. auth_key = str(os.getenv("ZHUCE6_CFMAIL_CF_AUTH_KEY", "")).strip()
  112. zone_id = str(os.getenv("ZHUCE6_CFMAIL_CF_ZONE_ID", "")).strip()
  113. zone_name = _normalize_domain(str(os.getenv("ZHUCE6_CFMAIL_ZONE_NAME", "")))
  114. missing = [
  115. name
  116. for name, value in (
  117. ("ZHUCE6_CFMAIL_CF_AUTH_EMAIL", auth_email),
  118. ("ZHUCE6_CFMAIL_CF_AUTH_KEY", auth_key),
  119. ("ZHUCE6_CFMAIL_CF_ZONE_ID", zone_id),
  120. )
  121. if not value
  122. ]
  123. if missing:
  124. raise RuntimeError(f"missing cleanup env: {', '.join(missing)}")
  125. active_domain = _load_active_domain(resolved_config_path)
  126. headers = _headers(auth_email, auth_key)
  127. base_url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}"
  128. zone_suffix = f".{zone_name}" if zone_name else ""
  129. _print(out, f"[cleanup] env: {resolved_env_file}")
  130. _print(out, f"[cleanup] config: {resolved_config_path}")
  131. _print(out, f"[cleanup] active domain: {active_domain}")
  132. rules = _request_paginated(f"{base_url}/email/routing/rules", headers=headers)
  133. _print(out, f"[cleanup] routing rules fetched: {len(rules)}")
  134. removed_routing_rules: list[str] = []
  135. for rule in rules:
  136. rule_id = str(rule.get("id") or "").strip()
  137. rule_name = str(rule.get("name") or "").strip()
  138. domains = _routing_rule_domains(rule)
  139. should_keep = active_domain in domains or "nova" in rule_name.lower()
  140. if should_keep or not domains:
  141. continue
  142. _print(out, f"[cleanup] delete routing rule: {rule_id} name={rule_name}")
  143. try:
  144. _request("DELETE", f"{base_url}/email/routing/rules/{rule_id}", headers=headers)
  145. removed_routing_rules.append(rule_id)
  146. except Exception as exc:
  147. _print(out, f"[cleanup] skip routing rule {rule_id}: {exc}")
  148. dns_records = _request_paginated(f"{base_url}/dns_records", headers=headers)
  149. _print(out, f"[cleanup] dns records fetched: {len(dns_records)}")
  150. removed_dns_records: list[str] = []
  151. for record in dns_records:
  152. record_id = str(record.get("id") or "").strip()
  153. record_type = str(record.get("type") or "").strip().upper()
  154. record_name = _normalize_domain(str(record.get("name") or ""))
  155. if record_type not in {"MX", "TXT"}:
  156. continue
  157. if not record_name.startswith("auto"):
  158. continue
  159. if zone_suffix and not record_name.endswith(zone_suffix):
  160. continue
  161. if record_name == active_domain:
  162. continue
  163. _print(out, f"[cleanup] delete dns record: {record_id} type={record_type} name={record_name}")
  164. try:
  165. _request("DELETE", f"{base_url}/dns_records/{record_id}", headers=headers)
  166. removed_dns_records.append(record_id)
  167. except Exception as exc:
  168. _print(out, f"[cleanup] skip dns record {record_id}: {exc}")
  169. summary = {
  170. "active_domain": active_domain,
  171. "removed_routing_rules": removed_routing_rules,
  172. "removed_dns_records": removed_dns_records,
  173. }
  174. _print(out, f"[cleanup] summary: {json.dumps(summary, ensure_ascii=False)}")
  175. return summary
  176. def main() -> int:
  177. run_cleanup()
  178. return 0
  179. if __name__ == "__main__":
  180. raise SystemExit(main())