setup_cfmail.py 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049
  1. from __future__ import annotations
  2. import argparse
  3. import json
  4. import os
  5. from dataclasses import dataclass
  6. from pathlib import Path
  7. import secrets
  8. import shutil
  9. import subprocess
  10. import sys
  11. from typing import Any
  12. from core.cfmail_provisioner import CfmailProvisioner, ProvisioningSettings
  13. DEFAULT_WORKER_REPO = "https://github.com/dreamhunter2333/cloudflare_temp_email.git"
  14. DEFAULT_WORKER_NAME = "zhuce6-cfmail"
  15. DEFAULT_D1_NAME = "zhuce6-cfmail-db"
  16. DEFAULT_VENDOR_DIR = Path("vendor")
  17. DEFAULT_WORKER_DIR = DEFAULT_VENDOR_DIR / "cfmail-worker"
  18. DEFAULT_CONFIG_DIR = Path("config")
  19. DEFAULT_CFMAIL_ACCOUNTS_PATH = DEFAULT_CONFIG_DIR / "cfmail_accounts.json"
  20. DEFAULT_CFMAIL_ENV_PATH = DEFAULT_CONFIG_DIR / "cfmail_provision.env"
  21. DEFAULT_COMPATIBILITY_DATE = "2025-04-01"
  22. EMAIL_ROUTING_FALLBACK_MX_RECORDS = (
  23. ("amir.mx.cloudflare.net", 13),
  24. ("isaac.mx.cloudflare.net", 24),
  25. ("linda.mx.cloudflare.net", 86),
  26. )
  27. EMAIL_ROUTING_FALLBACK_SPF = "v=spf1 include:_spf.mx.cloudflare.net ~all"
  28. RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
  29. class SetupError(RuntimeError):
  30. def __init__(self, message: str, *, hint: str = "") -> None:
  31. super().__init__(message)
  32. self.hint = hint
  33. @dataclass(frozen=True)
  34. class WorkerLayout:
  35. repo_dir: Path
  36. worker_dir: Path
  37. schema_path: Path
  38. migration_paths: tuple[Path, ...]
  39. wrangler_template_path: Path | None
  40. @dataclass(frozen=True)
  41. class DNSRecordSpec:
  42. record_type: str
  43. name: str
  44. content: str
  45. priority: int | None = None
  46. ttl: int = 1
  47. proxied: bool | None = None
  48. @dataclass(frozen=True)
  49. class CfmailRuntimeConfig:
  50. api_token: str
  51. account_id: str
  52. zone_id: str
  53. worker_name: str
  54. worker_domain: str
  55. zone_name: str
  56. email_domain: str
  57. admin_password: str
  58. d1_name: str
  59. d1_database_id: str
  60. class CloudflareClient:
  61. def __init__(
  62. self,
  63. api_token: str,
  64. *,
  65. auth_email: str = "",
  66. auth_key: str = "",
  67. timeout: float = 30.0,
  68. ) -> None:
  69. api_token = str(api_token or "").strip()
  70. auth_email = str(auth_email or "").strip()
  71. auth_key = str(auth_key or "").strip()
  72. if not api_token and not (auth_email and auth_key):
  73. raise SetupError(
  74. "缺少 Cloudflare 凭据。",
  75. hint="请提供 Cloudflare API Token, 或提供 CF_AUTH_EMAIL + CF_AUTH_KEY。",
  76. )
  77. try:
  78. import httpx # type: ignore
  79. except ImportError as exc:
  80. raise SetupError(
  81. "当前 Python 环境缺少 httpx。",
  82. hint="请先执行 `uv sync` 或 `uv pip install httpx`。",
  83. ) from exc
  84. self._httpx = httpx
  85. headers = {
  86. "Content-Type": "application/json",
  87. "Accept": "application/json",
  88. "User-Agent": "zhuce6/setup_cfmail",
  89. }
  90. self._uses_api_token = bool(api_token)
  91. if api_token:
  92. headers["Authorization"] = f"Bearer {api_token}"
  93. else:
  94. headers["X-Auth-Email"] = auth_email
  95. headers["X-Auth-Key"] = auth_key
  96. self._client = httpx.Client(
  97. base_url="https://api.cloudflare.com/client/v4",
  98. timeout=timeout,
  99. trust_env=True,
  100. headers=headers,
  101. )
  102. def close(self) -> None:
  103. self._client.close()
  104. def __enter__(self) -> "CloudflareClient":
  105. return self
  106. def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
  107. self.close()
  108. def request(
  109. self,
  110. method: str,
  111. path: str,
  112. *,
  113. params: dict[str, Any] | None = None,
  114. json_body: dict[str, Any] | None = None,
  115. ) -> dict[str, Any]:
  116. last_error: Exception | None = None
  117. for attempt in range(1, 4):
  118. try:
  119. response = self._client.request(method, path, params=params, json=json_body)
  120. except self._httpx.HTTPError as exc:
  121. last_error = exc
  122. if attempt < 3:
  123. continue
  124. raise SetupError(
  125. f"Cloudflare API 请求失败: {method.upper()} {path}",
  126. hint=f"请检查网络连通性后重试。原始错误: {exc}",
  127. ) from exc
  128. if response.status_code in RETRYABLE_STATUS_CODES and attempt < 3:
  129. continue
  130. try:
  131. payload = response.json()
  132. except ValueError as exc:
  133. raise SetupError(
  134. f"Cloudflare API 返回了无法解析的 JSON: {method.upper()} {path}",
  135. hint=f"HTTP {response.status_code}, 响应片段: {response.text[:300]}",
  136. ) from exc
  137. if response.is_success and payload.get("success") is True:
  138. return payload
  139. errors = payload.get("errors") or []
  140. message = "; ".join(
  141. str(item.get("message") or item.get("code") or item)
  142. for item in errors
  143. if item
  144. ).strip()
  145. if not message:
  146. message = response.text[:300].strip() or f"HTTP {response.status_code}"
  147. raise SetupError(
  148. f"Cloudflare API 调用失败: {method.upper()} {path} -> {message}",
  149. hint=self._build_api_hint(path, response.status_code),
  150. )
  151. if last_error is not None:
  152. raise SetupError(str(last_error))
  153. raise SetupError(f"Cloudflare API 调用失败: {method.upper()} {path}")
  154. def _build_api_hint(self, path: str, status_code: int) -> str:
  155. if status_code in {401, 403}:
  156. return (
  157. "请确认 API Token 具备 Zone Read, DNS Edit, Workers Scripts Write, D1 Edit, "
  158. "以及 Email Routing 写权限。"
  159. )
  160. if "/email/routing" in path:
  161. return "请先在 Cloudflare Dashboard 手动开启 Email Routing, 然后重新执行脚本。"
  162. return "请根据 Cloudflare 返回信息检查配置后重试。"
  163. def verify_token(self) -> dict[str, Any]:
  164. if self._uses_api_token:
  165. payload = self.request("GET", "/user/tokens/verify")
  166. result = payload.get("result")
  167. if not isinstance(result, dict):
  168. raise SetupError("Token 校验响应缺少 result 字段。")
  169. return result
  170. payload = self.request("GET", "/user")
  171. result = payload.get("result")
  172. if not isinstance(result, dict):
  173. raise SetupError("Cloudflare 用户校验响应缺少 result 字段。")
  174. if not result.get("status"):
  175. result = {**result, "status": "active"}
  176. return result
  177. def resolve_zone(self, zone_name: str) -> dict[str, Any]:
  178. payload = self.request("GET", "/zones", params={"name": zone_name})
  179. result = payload.get("result") or []
  180. matches = [item for item in result if isinstance(item, dict) and item.get("name") == zone_name]
  181. if not matches:
  182. raise SetupError(
  183. f"未找到 zone: {zone_name}",
  184. hint="请确认该域名已接入当前 Cloudflare 账号, 且 API Token 有 Zone Read 权限。",
  185. )
  186. zone = matches[0]
  187. account = zone.get("account") if isinstance(zone.get("account"), dict) else {}
  188. account_id = str(account.get("id") or "").strip()
  189. zone_id = str(zone.get("id") or "").strip()
  190. if not account_id or not zone_id:
  191. raise SetupError("Zone 信息中缺少 account_id 或 zone_id。")
  192. return zone
  193. def list_d1_databases(self, account_id: str, *, database_name: str = "") -> list[dict[str, Any]]:
  194. payload = self.request("GET", f"/accounts/{account_id}/d1/database")
  195. result = payload.get("result") or []
  196. items = [item for item in result if isinstance(item, dict)]
  197. if database_name:
  198. items = [item for item in items if str(item.get("name") or "") == database_name]
  199. return items
  200. def ensure_d1_database(self, account_id: str, database_name: str) -> dict[str, Any]:
  201. existing = self.list_d1_databases(account_id, database_name=database_name)
  202. if existing:
  203. return existing[0]
  204. payload = self.request(
  205. "POST",
  206. f"/accounts/{account_id}/d1/database",
  207. json_body={"name": database_name},
  208. )
  209. result = payload.get("result")
  210. if not isinstance(result, dict):
  211. raise SetupError("创建 D1 数据库成功, 但响应缺少 result。")
  212. return result
  213. def get_workers_subdomain(self, account_id: str) -> str:
  214. payload = self.request("GET", f"/accounts/{account_id}/workers/subdomain")
  215. result = payload.get("result")
  216. if not isinstance(result, dict):
  217. return ""
  218. return str(result.get("subdomain") or "").strip()
  219. def get_email_routing_status(self, zone_id: str) -> dict[str, Any]:
  220. payload = self.request("GET", f"/zones/{zone_id}/email/routing")
  221. result = payload.get("result")
  222. if not isinstance(result, dict):
  223. raise SetupError("Email Routing 状态接口返回格式异常。")
  224. return result
  225. def get_email_routing_dns_requirements(self, zone_id: str) -> list[DNSRecordSpec]:
  226. payload = self.request("GET", f"/zones/{zone_id}/email/routing/dns")
  227. result = payload.get("result")
  228. items: list[dict[str, Any]] = []
  229. if isinstance(result, list):
  230. items = [item for item in result if isinstance(item, dict)]
  231. elif isinstance(result, dict):
  232. for key in ("records", "items", "dns_records", "dns"):
  233. value = result.get(key)
  234. if isinstance(value, list):
  235. items = [item for item in value if isinstance(item, dict)]
  236. break
  237. records: list[DNSRecordSpec] = []
  238. for item in items:
  239. record_type = str(item.get("type") or item.get("record_type") or "").upper()
  240. name = str(item.get("name") or item.get("hostname") or "").strip()
  241. content = str(item.get("content") or item.get("value") or "").strip()
  242. if not record_type or not name or not content:
  243. continue
  244. priority = item.get("priority")
  245. try:
  246. parsed_priority = int(priority) if priority is not None else None
  247. except (TypeError, ValueError):
  248. parsed_priority = None
  249. records.append(
  250. DNSRecordSpec(
  251. record_type=record_type,
  252. name=name,
  253. content=content,
  254. priority=parsed_priority,
  255. ttl=int(item.get("ttl") or 1),
  256. proxied=item.get("proxied") if isinstance(item.get("proxied"), bool) else None,
  257. )
  258. )
  259. return records
  260. def list_dns_records(self, zone_id: str, *, name: str = "", record_type: str = "") -> list[dict[str, Any]]:
  261. params: dict[str, Any] = {}
  262. if name:
  263. params["name"] = name
  264. if record_type:
  265. params["type"] = record_type
  266. payload = self.request("GET", f"/zones/{zone_id}/dns_records", params=params)
  267. result = payload.get("result") or []
  268. return [item for item in result if isinstance(item, dict)]
  269. def ensure_dns_record(self, zone_id: str, spec: DNSRecordSpec) -> dict[str, Any]:
  270. existing = self.list_dns_records(zone_id, name=spec.name, record_type=spec.record_type)
  271. for record in existing:
  272. if self._dns_record_matches(record, spec):
  273. return record
  274. updatable = self._select_updatable_dns_record(existing, spec)
  275. payload = {
  276. "type": spec.record_type,
  277. "name": spec.name,
  278. "content": spec.content,
  279. "ttl": spec.ttl,
  280. }
  281. if spec.priority is not None:
  282. payload["priority"] = spec.priority
  283. if spec.proxied is not None and spec.record_type not in {"MX", "TXT"}:
  284. payload["proxied"] = spec.proxied
  285. if updatable is not None:
  286. response = self.request(
  287. "PUT",
  288. f"/zones/{zone_id}/dns_records/{updatable['id']}",
  289. json_body=payload,
  290. )
  291. else:
  292. response = self.request("POST", f"/zones/{zone_id}/dns_records", json_body=payload)
  293. result = response.get("result")
  294. if not isinstance(result, dict):
  295. raise SetupError(f"DNS 记录写入成功, 但响应格式异常: {spec.record_type} {spec.name}")
  296. return result
  297. def _dns_record_matches(self, record: dict[str, Any], spec: DNSRecordSpec) -> bool:
  298. if str(record.get("type") or "").upper() != spec.record_type:
  299. return False
  300. if str(record.get("name") or "").strip().lower() != spec.name.lower():
  301. return False
  302. if str(record.get("content") or "").strip().lower() != spec.content.lower():
  303. return False
  304. if spec.priority is not None and int(record.get("priority") or 0) != spec.priority:
  305. return False
  306. return True
  307. def _select_updatable_dns_record(self, existing: list[dict[str, Any]], spec: DNSRecordSpec) -> dict[str, Any] | None:
  308. if spec.record_type == "TXT":
  309. spf_like = [
  310. record for record in existing
  311. if str(record.get("content") or "").strip().lower().startswith("v=spf1")
  312. ]
  313. if len(spf_like) == 1:
  314. return spf_like[0]
  315. return None
  316. for record in existing:
  317. if str(record.get("content") or "").strip().lower() == spec.content.lower():
  318. return record
  319. return None
  320. def get_catch_all_rule(self, zone_id: str) -> dict[str, Any] | None:
  321. try:
  322. payload = self.request("GET", f"/zones/{zone_id}/email/routing/rules/catch_all")
  323. except SetupError as exc:
  324. if "not found" in str(exc).lower():
  325. return None
  326. raise
  327. result = payload.get("result")
  328. return result if isinstance(result, dict) else None
  329. def ensure_catch_all_worker(self, zone_id: str, worker_name: str) -> dict[str, Any]:
  330. current = self.get_catch_all_rule(zone_id) or {}
  331. desired_actions = [{"type": "worker", "value": [worker_name]}]
  332. desired_matchers = [{"type": "all"}]
  333. if (
  334. bool(current.get("enabled", True))
  335. and self._normalize_actions(current.get("actions")) == desired_actions
  336. and self._normalize_matchers(current.get("matchers")) == desired_matchers
  337. ):
  338. return current
  339. payload = {
  340. "enabled": True,
  341. "name": str(current.get("name") or f"{worker_name} catch-all"),
  342. "matchers": desired_matchers,
  343. "actions": desired_actions,
  344. }
  345. response = self.request(
  346. "PUT",
  347. f"/zones/{zone_id}/email/routing/rules/catch_all",
  348. json_body=payload,
  349. )
  350. result = response.get("result")
  351. if not isinstance(result, dict):
  352. raise SetupError("Catch-all 规则更新成功, 但响应格式异常。")
  353. return result
  354. def _normalize_actions(self, actions: Any) -> list[dict[str, Any]]:
  355. normalized: list[dict[str, Any]] = []
  356. if not isinstance(actions, list):
  357. return normalized
  358. for item in actions:
  359. if not isinstance(item, dict):
  360. continue
  361. values = item.get("value")
  362. if isinstance(values, list):
  363. value_list = [str(v) for v in values]
  364. elif values is None:
  365. value_list = []
  366. else:
  367. value_list = [str(values)]
  368. normalized.append({"type": str(item.get("type") or ""), "value": value_list})
  369. return normalized
  370. def _normalize_matchers(self, matchers: Any) -> list[dict[str, Any]]:
  371. normalized: list[dict[str, Any]] = []
  372. if not isinstance(matchers, list):
  373. return normalized
  374. for item in matchers:
  375. if not isinstance(item, dict):
  376. continue
  377. normalized.append({"type": str(item.get("type") or "")})
  378. return normalized
  379. def build_parser() -> argparse.ArgumentParser:
  380. parser = argparse.ArgumentParser(description="一键部署 cfmail Worker 并生成 zhuce6 配置。")
  381. parser.add_argument("--api-token", default="", help="Cloudflare API Token")
  382. parser.add_argument("--auth-email", default="", help="Cloudflare 认证邮箱, 与 --auth-key 成对使用")
  383. parser.add_argument("--auth-key", default="", help="Cloudflare Global API Key, 与 --auth-email 成对使用")
  384. parser.add_argument("--zone-name", required=True, help="Cloudflare Zone 名称, 例如 example.com")
  385. parser.add_argument("--worker-name", default=DEFAULT_WORKER_NAME, help=f"Worker 名称, 默认 {DEFAULT_WORKER_NAME}")
  386. parser.add_argument("--d1-name", default=DEFAULT_D1_NAME, help=f"D1 数据库名称, 默认 {DEFAULT_D1_NAME}")
  387. parser.add_argument("--mail-domain", help="邮箱域名, 默认等于 --zone-name")
  388. parser.add_argument(
  389. "--skip-clone",
  390. action="store_true",
  391. help="若 vendor/cfmail-worker 已存在, 跳过 clone 并直接复用现有目录",
  392. )
  393. return parser
  394. def print_step(number: int, total: int, title: str) -> None:
  395. print(f"[{number}/{total}] {title}")
  396. def ensure_command(name: str, *, install_hint: str) -> None:
  397. if shutil.which(name):
  398. return
  399. raise SetupError(f"缺少必要命令: {name}", hint=install_hint)
  400. def ensure_required_tools() -> None:
  401. ensure_command("git", install_hint="请先安装 git, 然后重新执行脚本。")
  402. ensure_command("node", install_hint="请先安装 Node.js 18+。")
  403. ensure_command("npm", install_hint="请先安装 npm。")
  404. ensure_command("npx", install_hint="请先安装 npm, 确保 npx 可用。")
  405. def ensure_mail_domain(zone_name: str, mail_domain: str) -> str:
  406. zone_name = str(zone_name or "").strip().lower()
  407. mail_domain = str(mail_domain or zone_name).strip().lower()
  408. if not mail_domain:
  409. raise SetupError("mail_domain 不能为空。")
  410. if mail_domain != zone_name and not mail_domain.endswith(f".{zone_name}"):
  411. raise SetupError(
  412. f"mail_domain 必须等于 zone_name 或属于其子域: {mail_domain}",
  413. hint=f"当前 zone_name 为 {zone_name}, 请改用 {zone_name} 或其子域。",
  414. )
  415. return mail_domain
  416. def clone_worker_source(target_dir: Path, *, skip_clone: bool) -> Path:
  417. target_dir.parent.mkdir(parents=True, exist_ok=True)
  418. if target_dir.exists():
  419. if not (target_dir / ".git").exists():
  420. raise SetupError(
  421. f"目标目录已存在但不是 git 仓库: {target_dir}",
  422. hint="请删除该目录后重试, 或改用干净的 vendor/cfmail-worker 路径。",
  423. )
  424. if skip_clone:
  425. print(f" 复用现有源码目录: {target_dir}")
  426. return target_dir
  427. print(f" 检测到现有源码目录, 直接复用: {target_dir}")
  428. return target_dir
  429. run_command(
  430. ["git", "clone", "--depth", "1", DEFAULT_WORKER_REPO, str(target_dir)],
  431. cwd=Path.cwd(),
  432. step="clone Worker 源码",
  433. )
  434. return target_dir
  435. def resolve_worker_layout(repo_dir: Path) -> WorkerLayout:
  436. worker_dir = repo_dir / "worker"
  437. if not worker_dir.exists():
  438. raise SetupError(
  439. f"上游源码缺少 worker 目录: {worker_dir}",
  440. hint="请检查上游仓库结构是否变化。",
  441. )
  442. wrangler_template_path: Path | None = None
  443. for candidate in (worker_dir / "wrangler.toml.template", worker_dir / "wrangler.toml"):
  444. if candidate.exists():
  445. wrangler_template_path = candidate
  446. break
  447. schema_candidates = (
  448. repo_dir / "db" / "schema.sql",
  449. worker_dir / "schema.sql",
  450. )
  451. schema_path = next((path for path in schema_candidates if path.exists()), None)
  452. if schema_path is None:
  453. raise SetupError("未找到 schema.sql。", hint="请检查上游仓库结构是否变化。")
  454. migration_paths: list[Path] = []
  455. migration_dirs = (repo_dir / "db", worker_dir / "migrations")
  456. for directory in migration_dirs:
  457. if not directory.exists():
  458. continue
  459. for path in sorted(directory.glob("*.sql")):
  460. if path.resolve() == schema_path.resolve():
  461. continue
  462. migration_paths.append(path)
  463. if migration_paths:
  464. break
  465. return WorkerLayout(
  466. repo_dir=repo_dir,
  467. worker_dir=worker_dir,
  468. schema_path=schema_path,
  469. migration_paths=tuple(migration_paths),
  470. wrangler_template_path=wrangler_template_path,
  471. )
  472. def read_wrangler_template_defaults(template_path: Path | None) -> dict[str, Any]:
  473. defaults = {
  474. "main": "src/worker.ts",
  475. "compatibility_date": DEFAULT_COMPATIBILITY_DATE,
  476. "compatibility_flags": ["nodejs_compat"],
  477. "keep_vars": True,
  478. }
  479. if template_path is None or not template_path.exists():
  480. return defaults
  481. try:
  482. import tomllib
  483. parsed = tomllib.loads(template_path.read_text(encoding="utf-8"))
  484. except Exception:
  485. return defaults
  486. defaults["main"] = str(parsed.get("main") or defaults["main"])
  487. defaults["compatibility_date"] = str(parsed.get("compatibility_date") or defaults["compatibility_date"])
  488. flags = parsed.get("compatibility_flags")
  489. if isinstance(flags, list) and flags:
  490. defaults["compatibility_flags"] = [str(item) for item in flags]
  491. defaults["keep_vars"] = bool(parsed.get("keep_vars", defaults["keep_vars"]))
  492. return defaults
  493. def toml_string(value: str) -> str:
  494. return json.dumps(str(value), ensure_ascii=False)
  495. def toml_array(values: list[str]) -> str:
  496. return json.dumps([str(value) for value in values], ensure_ascii=False)
  497. def write_worker_wrangler(
  498. *,
  499. worker_dir: Path,
  500. worker_name: str,
  501. account_id: str,
  502. database_id: str,
  503. database_name: str,
  504. email_domain: str,
  505. admin_password: str,
  506. jwt_secret: str,
  507. compatibility_date: str = DEFAULT_COMPATIBILITY_DATE,
  508. main: str = "src/worker.ts",
  509. compatibility_flags: list[str] | None = None,
  510. keep_vars: bool = True,
  511. ) -> Path:
  512. wrangler_path = worker_dir / "wrangler.toml"
  513. flags = compatibility_flags or ["nodejs_compat"]
  514. content = "\n".join(
  515. [
  516. f"name = {toml_string(worker_name)}",
  517. f"account_id = {toml_string(account_id)}",
  518. f"main = {toml_string(main)}",
  519. f"compatibility_date = {toml_string(compatibility_date)}",
  520. f"compatibility_flags = {toml_array(flags)}",
  521. f"keep_vars = {'true' if keep_vars else 'false'}",
  522. "",
  523. "[vars]",
  524. f"PREFIX = {toml_string('tmp')}",
  525. f"DEFAULT_DOMAINS = {toml_array([email_domain])}",
  526. f"DOMAINS = {toml_array([email_domain])}",
  527. f"ADMIN_PASSWORDS = {toml_array([admin_password])}",
  528. f"JWT_SECRET = {toml_string(jwt_secret)}",
  529. "ENABLE_USER_CREATE_EMAIL = true",
  530. "ENABLE_USER_DELETE_EMAIL = true",
  531. "ENABLE_AUTO_REPLY = false",
  532. "",
  533. "[[d1_databases]]",
  534. f"binding = {toml_string('DB')}",
  535. f"database_name = {toml_string(database_name)}",
  536. f"database_id = {toml_string(database_id)}",
  537. "",
  538. ]
  539. )
  540. wrangler_path.write_text(content, encoding="utf-8")
  541. return wrangler_path
  542. def render_cfmail_accounts_payload(
  543. *,
  544. worker_domain: str,
  545. email_domain: str,
  546. worker_name: str,
  547. admin_password: str,
  548. ) -> list[dict[str, Any]]:
  549. return [
  550. {
  551. "name": worker_name,
  552. "worker_domain": worker_domain,
  553. "email_domain": email_domain,
  554. "admin_password": admin_password,
  555. "enabled": True,
  556. }
  557. ]
  558. def write_cfmail_accounts_json(
  559. output_path: Path,
  560. *,
  561. worker_domain: str,
  562. email_domain: str,
  563. worker_name: str,
  564. admin_password: str,
  565. ) -> Path:
  566. payload = render_cfmail_accounts_payload(
  567. worker_domain=worker_domain,
  568. email_domain=email_domain,
  569. worker_name=worker_name,
  570. admin_password=admin_password,
  571. )
  572. output_path.parent.mkdir(parents=True, exist_ok=True)
  573. output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
  574. return output_path
  575. def shell_quote(value: str) -> str:
  576. return str(value).replace("\\", "\\\\").replace('"', '\\"')
  577. def write_cfmail_provision_env(
  578. output_path: Path,
  579. *,
  580. api_token: str = "",
  581. auth_email: str = "",
  582. auth_key: str = "",
  583. account_id: str,
  584. zone_id: str,
  585. worker_name: str,
  586. zone_name: str,
  587. d1_database_id: str = "",
  588. ) -> Path:
  589. content = "\n".join(
  590. [
  591. f'export ZHUCE6_CFMAIL_API_TOKEN="{shell_quote(api_token)}"',
  592. f'export ZHUCE6_CFMAIL_CF_AUTH_EMAIL="{shell_quote(auth_email)}"',
  593. f'export ZHUCE6_CFMAIL_CF_AUTH_KEY="{shell_quote(auth_key)}"',
  594. f'export ZHUCE6_CFMAIL_CF_ACCOUNT_ID="{shell_quote(account_id)}"',
  595. f'export ZHUCE6_CFMAIL_CF_ZONE_ID="{shell_quote(zone_id)}"',
  596. f'export ZHUCE6_CFMAIL_WORKER_NAME="{shell_quote(worker_name)}"',
  597. f'export ZHUCE6_CFMAIL_ZONE_NAME="{shell_quote(zone_name)}"',
  598. f'export ZHUCE6_D1_DATABASE_ID="{shell_quote(d1_database_id)}"',
  599. "",
  600. ]
  601. )
  602. output_path.parent.mkdir(parents=True, exist_ok=True)
  603. output_path.write_text(content, encoding="utf-8")
  604. return output_path
  605. def prepare_runtime_cfmail_config(
  606. *,
  607. api_token: str,
  608. auth_email: str = "",
  609. auth_key: str = "",
  610. worker_domain: str | None = None,
  611. zone_name: str,
  612. worker_name: str = DEFAULT_WORKER_NAME,
  613. d1_name: str = DEFAULT_D1_NAME,
  614. mail_domain: str | None = None,
  615. admin_password: str | None = None,
  616. accounts_path: Path = DEFAULT_CFMAIL_ACCOUNTS_PATH,
  617. provision_env_path: Path = DEFAULT_CFMAIL_ENV_PATH,
  618. ) -> CfmailRuntimeConfig:
  619. normalized_zone_name = str(zone_name or "").strip().lower()
  620. if not normalized_zone_name:
  621. raise SetupError("zone_name 不能为空。")
  622. normalized_mail_domain = ensure_mail_domain(normalized_zone_name, mail_domain or normalized_zone_name)
  623. normalized_worker_name = str(worker_name or DEFAULT_WORKER_NAME).strip() or DEFAULT_WORKER_NAME
  624. normalized_worker_domain = str(worker_domain or "").strip().lower()
  625. resolved_admin_password = str(admin_password or "").strip() or secrets.token_urlsafe(24)
  626. with CloudflareClient(api_token, auth_email=auth_email, auth_key=auth_key) as client:
  627. client.verify_token()
  628. zone = client.resolve_zone(normalized_zone_name)
  629. zone_id = str(zone.get("id") or "").strip()
  630. account = zone.get("account") if isinstance(zone.get("account"), dict) else {}
  631. account_id = str(account.get("id") or "").strip()
  632. database = client.ensure_d1_database(account_id, d1_name)
  633. d1_database_id = str(database.get("uuid") or database.get("id") or "").strip()
  634. if not normalized_worker_domain:
  635. worker_subdomain = client.get_workers_subdomain(account_id)
  636. normalized_worker_domain = build_worker_domain(normalized_worker_name, worker_subdomain)
  637. write_cfmail_accounts_json(
  638. accounts_path,
  639. worker_domain=normalized_worker_domain,
  640. email_domain=normalized_mail_domain,
  641. worker_name=normalized_worker_name,
  642. admin_password=resolved_admin_password,
  643. )
  644. write_cfmail_provision_env(
  645. provision_env_path,
  646. api_token=api_token,
  647. auth_email=auth_email,
  648. auth_key=auth_key,
  649. account_id=account_id,
  650. zone_id=zone_id,
  651. worker_name=normalized_worker_name,
  652. zone_name=normalized_zone_name,
  653. d1_database_id=d1_database_id,
  654. )
  655. if normalized_worker_domain:
  656. provisioner = CfmailProvisioner(
  657. config_path=accounts_path,
  658. settings=ProvisioningSettings(
  659. auth_email=auth_email,
  660. auth_key=auth_key,
  661. account_id=account_id,
  662. zone_id=zone_id,
  663. worker_name=normalized_worker_name,
  664. zone_name=normalized_zone_name,
  665. ),
  666. )
  667. try:
  668. provisioner.smoke_test(normalized_worker_domain, resolved_admin_password, normalized_mail_domain)
  669. except Exception as exc:
  670. error_text = str(exc)
  671. if "无效的域名" not in error_text and "invalid" not in error_text.lower():
  672. raise
  673. rotation = provisioner.rotate_active_domain()
  674. if not rotation.success or not rotation.new_domain:
  675. raise SetupError(
  676. "cfmail 域名校验失败, 且自动轮换未成功。",
  677. hint=rotation.error or error_text or "请确认 worker_domain 与 zone_name 配置正确。",
  678. )
  679. normalized_mail_domain = rotation.new_domain
  680. return CfmailRuntimeConfig(
  681. api_token=api_token,
  682. account_id=account_id,
  683. zone_id=zone_id,
  684. worker_name=normalized_worker_name,
  685. worker_domain=normalized_worker_domain,
  686. zone_name=normalized_zone_name,
  687. email_domain=normalized_mail_domain,
  688. admin_password=resolved_admin_password,
  689. d1_name=d1_name,
  690. d1_database_id=d1_database_id,
  691. )
  692. def run_command(
  693. args: list[str],
  694. *,
  695. cwd: Path,
  696. step: str,
  697. env: dict[str, str] | None = None,
  698. input_text: str | None = None,
  699. ) -> subprocess.CompletedProcess[str]:
  700. merged_env = os.environ.copy()
  701. if env:
  702. merged_env.update(env)
  703. process = subprocess.run(
  704. args,
  705. cwd=str(cwd),
  706. env=merged_env,
  707. input=input_text,
  708. text=True,
  709. capture_output=True,
  710. check=False,
  711. )
  712. if process.returncode != 0:
  713. detail = (process.stderr or process.stdout or "").strip()
  714. raise SetupError(
  715. f"{step} 失败: {' '.join(args)}",
  716. hint=detail[:1200] or "请根据命令输出检查后重试。",
  717. )
  718. return process
  719. def is_benign_migration_error(detail: str) -> bool:
  720. normalized = str(detail or "").lower()
  721. markers = (
  722. "duplicate column name",
  723. "already exists",
  724. "no such table",
  725. "duplicate index name",
  726. )
  727. return any(marker in normalized for marker in markers)
  728. def run_wrangler_sql_file(
  729. *,
  730. database_name: str,
  731. sql_path: Path,
  732. cwd: Path,
  733. env: dict[str, str],
  734. step: str,
  735. tolerate_already_applied: bool = False,
  736. ) -> subprocess.CompletedProcess[str]:
  737. try:
  738. return run_command(
  739. ["npx", "wrangler", "d1", "execute", database_name, "--remote", "--file", str(sql_path)],
  740. cwd=cwd,
  741. env=env,
  742. step=step,
  743. )
  744. except SetupError as exc:
  745. if tolerate_already_applied and is_benign_migration_error(exc.hint):
  746. print(f" 跳过已存在或历史补丁不再适用的 migration: {sql_path.name}")
  747. return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=exc.hint)
  748. raise
  749. def build_wrangler_env(api_token: str) -> dict[str, str]:
  750. return {
  751. "CLOUDFLARE_API_TOKEN": api_token,
  752. "CF_API_TOKEN": api_token,
  753. "CI": "1",
  754. "NO_UPDATE_NOTIFIER": "1",
  755. "npm_config_update_notifier": "false",
  756. }
  757. def ensure_email_routing_dns(client: CloudflareClient, zone_id: str, mail_domain: str) -> list[DNSRecordSpec]:
  758. specs = client.get_email_routing_dns_requirements(zone_id)
  759. filtered = [spec for spec in specs if spec.record_type in {"MX", "TXT"}]
  760. if not filtered:
  761. filtered = [
  762. DNSRecordSpec("MX", mail_domain, host, priority=priority)
  763. for host, priority in EMAIL_ROUTING_FALLBACK_MX_RECORDS
  764. ]
  765. filtered.append(DNSRecordSpec("TXT", mail_domain, EMAIL_ROUTING_FALLBACK_SPF))
  766. normalized: list[DNSRecordSpec] = []
  767. for spec in filtered:
  768. normalized_name = spec.name.rstrip(".")
  769. if normalized_name == "@":
  770. normalized_name = mail_domain
  771. if not normalized_name:
  772. normalized_name = mail_domain
  773. normalized.append(
  774. DNSRecordSpec(
  775. record_type=spec.record_type,
  776. name=normalized_name,
  777. content=spec.content.rstrip("."),
  778. priority=spec.priority,
  779. ttl=spec.ttl,
  780. proxied=spec.proxied,
  781. )
  782. )
  783. for spec in normalized:
  784. client.ensure_dns_record(zone_id, spec)
  785. return normalized
  786. def email_routing_enabled(status: dict[str, Any]) -> bool:
  787. for key in ("enabled", "active"):
  788. value = status.get(key)
  789. if isinstance(value, bool):
  790. return value
  791. state = str(status.get("status") or status.get("state") or "").strip().lower()
  792. return state in {"active", "enabled", "ready", "verified", "success"}
  793. def build_worker_domain(worker_name: str, account_subdomain: str) -> str:
  794. worker_name = str(worker_name or "").strip()
  795. account_subdomain = str(account_subdomain or "").strip()
  796. if not worker_name or not account_subdomain:
  797. raise SetupError(
  798. "无法推导 workers.dev 域名。",
  799. hint="请确认账号已启用 workers.dev 子域, 或在 Cloudflare Dashboard 中先完成一次 Worker 初始化。",
  800. )
  801. return f"{worker_name}.{account_subdomain}.workers.dev"
  802. def main(argv: list[str] | None = None) -> int:
  803. parser = build_parser()
  804. args = parser.parse_args(argv)
  805. zone_name = str(args.zone_name).strip().lower()
  806. worker_name = str(args.worker_name).strip()
  807. d1_name = str(args.d1_name).strip()
  808. mail_domain = ensure_mail_domain(zone_name, args.mail_domain or zone_name)
  809. api_token = str(args.api_token).strip()
  810. auth_email = str(args.auth_email).strip()
  811. auth_key = str(args.auth_key).strip()
  812. if not api_token and not (auth_email and auth_key):
  813. raise SystemExit("ERROR: 请提供 --api-token, 或同时提供 --auth-email 和 --auth-key。")
  814. vendor_dir = Path.cwd() / DEFAULT_WORKER_DIR
  815. cfmail_accounts_path = Path.cwd() / DEFAULT_CFMAIL_ACCOUNTS_PATH
  816. cfmail_env_path = Path.cwd() / DEFAULT_CFMAIL_ENV_PATH
  817. total_steps = 13
  818. try:
  819. print_step(1, total_steps, "检查本地依赖")
  820. ensure_required_tools()
  821. with CloudflareClient(api_token, auth_email=auth_email, auth_key=auth_key) as client:
  822. print_step(2, total_steps, "验证 Cloudflare 凭据")
  823. token_info = client.verify_token()
  824. print(f" token_status={token_info.get('status', 'unknown')}")
  825. print_step(3, total_steps, "解析 zone/account 信息")
  826. zone = client.resolve_zone(zone_name)
  827. zone_id = str(zone.get("id") or "").strip()
  828. account = zone.get("account") if isinstance(zone.get("account"), dict) else {}
  829. account_id = str(account.get("id") or "").strip()
  830. print(f" account_id={account_id}")
  831. print(f" zone_id={zone_id}")
  832. print_step(4, total_steps, "准备 Worker 源码")
  833. repo_dir = clone_worker_source(vendor_dir, skip_clone=bool(args.skip_clone))
  834. layout = resolve_worker_layout(repo_dir)
  835. print(f" worker_dir={layout.worker_dir}")
  836. print_step(5, total_steps, "创建或复用 D1 数据库")
  837. database = client.ensure_d1_database(account_id, d1_name)
  838. database_id = str(database.get("uuid") or database.get("id") or "").strip()
  839. if not database_id:
  840. raise SetupError("D1 数据库响应缺少 database_id。")
  841. print(f" database_id={database_id}")
  842. print_step(6, total_steps, "写入 wrangler.toml")
  843. defaults = read_wrangler_template_defaults(layout.wrangler_template_path)
  844. admin_password = secrets.token_urlsafe(24)
  845. jwt_secret = secrets.token_urlsafe(48)
  846. wrangler_path = write_worker_wrangler(
  847. worker_dir=layout.worker_dir,
  848. worker_name=worker_name,
  849. account_id=account_id,
  850. database_id=database_id,
  851. database_name=d1_name,
  852. email_domain=mail_domain,
  853. admin_password=admin_password,
  854. jwt_secret=jwt_secret,
  855. compatibility_date=str(defaults.get("compatibility_date") or DEFAULT_COMPATIBILITY_DATE),
  856. main=str(defaults.get("main") or "src/worker.ts"),
  857. compatibility_flags=list(defaults.get("compatibility_flags") or ["nodejs_compat"]),
  858. keep_vars=bool(defaults.get("keep_vars", True)),
  859. )
  860. print(f" wrote {wrangler_path}")
  861. wrangler_env = build_wrangler_env(api_token)
  862. print_step(7, total_steps, "安装 Worker 依赖")
  863. run_command(["npm", "install", "--no-fund", "--no-audit"], cwd=layout.worker_dir, step="npm install")
  864. print_step(8, total_steps, "执行 D1 schema 与 migration")
  865. run_wrangler_sql_file(
  866. database_name=d1_name,
  867. sql_path=layout.schema_path,
  868. cwd=layout.worker_dir,
  869. env=wrangler_env,
  870. step="执行 schema.sql",
  871. )
  872. for migration_path in layout.migration_paths:
  873. run_wrangler_sql_file(
  874. database_name=d1_name,
  875. sql_path=migration_path,
  876. cwd=layout.worker_dir,
  877. env=wrangler_env,
  878. step=f"执行 migration {migration_path.name}",
  879. tolerate_already_applied=True,
  880. )
  881. print_step(9, total_steps, "部署 Worker")
  882. run_command(
  883. ["npx", "wrangler", "deploy", "--minify"],
  884. cwd=layout.worker_dir,
  885. env=wrangler_env,
  886. step="wrangler deploy",
  887. )
  888. print_step(10, total_steps, "配置 Email Routing 所需 DNS")
  889. dns_specs = ensure_email_routing_dns(client, zone_id, mail_domain)
  890. print(f" ensured_records={len(dns_specs)}")
  891. print_step(11, total_steps, "检查 Email Routing 状态并配置 catch-all")
  892. routing_status = client.get_email_routing_status(zone_id)
  893. if not email_routing_enabled(routing_status):
  894. raise SetupError(
  895. "当前 Zone 尚未开启 Email Routing。",
  896. hint=(
  897. "请先在 Cloudflare Dashboard > Email > Email Routing 中完成启用, "
  898. "确认状态变为 active/enabled 后重新执行脚本。"
  899. ),
  900. )
  901. client.ensure_catch_all_worker(zone_id, worker_name)
  902. print_step(12, total_steps, "生成 config/cfmail_accounts.json")
  903. account_subdomain = client.get_workers_subdomain(account_id)
  904. worker_domain = build_worker_domain(worker_name, account_subdomain)
  905. write_cfmail_accounts_json(
  906. cfmail_accounts_path,
  907. worker_domain=worker_domain,
  908. email_domain=mail_domain,
  909. worker_name=worker_name,
  910. admin_password=admin_password,
  911. )
  912. print(f" wrote {cfmail_accounts_path}")
  913. print_step(13, total_steps, "生成 config/cfmail_provision.env")
  914. write_cfmail_provision_env(
  915. cfmail_env_path,
  916. api_token=api_token,
  917. auth_email=auth_email,
  918. auth_key=auth_key,
  919. account_id=account_id,
  920. zone_id=zone_id,
  921. worker_name=worker_name,
  922. zone_name=zone_name,
  923. d1_database_id=database_id,
  924. )
  925. print(f" wrote {cfmail_env_path}")
  926. print("部署完成。")
  927. return 0
  928. except SetupError as exc:
  929. print(f"ERROR: {exc}", file=sys.stderr)
  930. if exc.hint:
  931. print(f"HINT: {exc.hint}", file=sys.stderr)
  932. return 1
  933. except KeyboardInterrupt:
  934. print("ERROR: 用户中断执行。", file=sys.stderr)
  935. return 130
  936. if __name__ == "__main__":
  937. raise SystemExit(main())