"""Cloudflare 适配器(REST API v4,Bearer Token) API 文档:https://developers.cloudflare.com/api/ 所需配置:{"api_token": "..."} Token 权限建议:Zone:Read / Zone:Edit(DNS 记录写操作需要)/ Account:Read / Registrar:Read Cloudflare 无传统 VPS,主要同步托管域名(zones);Workers/R2/Tunnel 子资产留待后续阶段。 域名到期日通过 Registrar API(/accounts/{id}/registrar/domains)补充获取。 DNS 的 zone_id 即 zone uuid(与 list_domains 返回的 external_id 一致)。 """ import httpx from app.adapters.base import ( BaseAdapter, NormalizedDNSRecord, NormalizedDomain, parse_iso_date, ) from app.adapters.registry import register @register("cloudflare-api") class CloudflareAdapter(BaseAdapter): required_config = ["api_token"] BASE = "https://api.cloudflare.com/client/v4" def _headers(self) -> dict: return {"Authorization": f"Bearer {self.config.get('api_token', '')}"} def _request(self, method: str, path: str, json_body: dict = None) -> dict: with httpx.Client(timeout=30) as client: resp = client.request(method, self.BASE + path, headers=self._headers(), json=json_body) resp.raise_for_status() data = resp.json() if resp.content else {} if not data.get("success", True): errors = data.get("errors", []) raise RuntimeError(errors[0].get("message") if errors else "Cloudflare API 返回失败") return data def _get(self, path: str) -> dict: return self._request("GET", path) def test_connection(self) -> dict: try: data = self._get("/user/tokens/verify") status = data.get("result", {}).get("status") if status == "active": return {"ok": True, "message": "API Token 有效"} return {"ok": False, "message": f"Token 状态异常:{status}"} except httpx.HTTPStatusError as e: return {"ok": False, "message": f"HTTP {e.response.status_code}:Token 无效或权限不足"} except Exception as e: # noqa: BLE001 return {"ok": False, "message": str(e)} def list_domains(self) -> list: # 先拉取 Registrar 域名到期日映射(domain_name -> date) expiry_map = self._registrar_expiry_map() result = [] page = 1 while True: data = self._get(f"/zones?per_page=50&page={page}") for z in data.get("result", []): name = z.get("name") result.append( NormalizedDomain( external_id=z.get("id"), domain_name=name, registrar="cloudflare", expiry_date=expiry_map.get(name), status="active" if z.get("status") == "active" else (z.get("status") or "unknown"), raw=z, ) ) info = data.get("result_info", {}) if page >= info.get("total_pages", 1): break page += 1 return result def _registrar_expiry_map(self) -> dict: """拉取 Cloudflare Registrar 域名到期日映射 {domain_name: date} 流程:/accounts → 对每个 account 调 /accounts/{id}/registrar/domains。 若 Token 无 Registrar 权限或账号无 Registrar 域名,静默返回空映射。 """ expiry = {} try: accounts = self._get("/accounts?per_page=50").get("result", []) except Exception: # noqa: BLE001 return expiry for acc in accounts: acc_id = acc.get("id") if not acc_id: continue try: domains = self._get(f"/accounts/{acc_id}/registrar/domains").get("result", []) except Exception: # noqa: BLE001 continue for d in domains: # Registrar API 返回的 id 是域名本身(如 example.com) name = d.get("id") or d.get("domain_name") or "" expires_at = d.get("expires_at") if name and expires_at: # expires_at 为 ISO8601 时间戳,必须解析成 date 再交给上层: # 直接塞截取的字符串会在写库时触发 SQLite Date 类型错误,整批同步失败 parsed = parse_iso_date(expires_at) if parsed: expiry[name] = parsed return expiry # ---------------- DNS 记录管理 ---------------- def list_dns_records(self, zone_id: str) -> list: result = [] page = 1 while True: data = self._get(f"/zones/{zone_id}/dns_records?per_page=100&page={page}") for r in data.get("result", []): result.append( NormalizedDNSRecord( record_id=str(r.get("id")), zone_id=zone_id, name=r.get("name"), type=r.get("type"), content=r.get("content"), ttl=r.get("ttl"), proxied=r.get("proxied"), priority=r.get("priority"), comment=r.get("comment"), raw=r, ) ) info = data.get("result_info", {}) if page >= info.get("total_pages", 1): break page += 1 return result @staticmethod def _record_body(record: dict) -> dict: body = { "type": record.get("type", "A"), "name": record.get("name", ""), "content": record.get("content", ""), "ttl": record.get("ttl") or 1, # 1 = Cloudflare 的「自动」TTL } # proxied 只对 A/AAAA/CNAME 有意义,其余类型传了会被 API 拒绝 if record.get("proxied") is not None and body["type"] in ("A", "AAAA", "CNAME"): body["proxied"] = bool(record["proxied"]) if record.get("priority") is not None: body["priority"] = record["priority"] if record.get("comment"): body["comment"] = record["comment"] return body def create_dns_record(self, zone_id: str, record: dict) -> dict: try: data = self._request( "POST", f"/zones/{zone_id}/dns_records", self._record_body(record) ) return { "ok": True, "message": "记录已创建", "record_id": str(data.get("result", {}).get("id", "")), "raw": data, } except httpx.HTTPStatusError as e: return {"ok": False, "message": f"HTTP {e.response.status_code}:创建失败(Token 可能缺 Zone:Edit)"} except Exception as e: # noqa: BLE001 return {"ok": False, "message": f"创建失败:{e}"} def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict: try: self._request( "PATCH", f"/zones/{zone_id}/dns_records/{record_id}", self._record_body(record) ) return {"ok": True, "message": "记录已更新"} except httpx.HTTPStatusError as e: return {"ok": False, "message": f"HTTP {e.response.status_code}:更新失败(Token 可能缺 Zone:Edit)"} except Exception as e: # noqa: BLE001 return {"ok": False, "message": f"更新失败:{e}"} def delete_dns_record(self, zone_id: str, record_id: str) -> dict: try: self._request("DELETE", f"/zones/{zone_id}/dns_records/{record_id}") return {"ok": True, "message": "记录已删除"} except httpx.HTTPStatusError as e: return {"ok": False, "message": f"HTTP {e.response.status_code}:删除失败(Token 可能缺 Zone:Edit)"} except Exception as e: # noqa: BLE001 return {"ok": False, "message": f"删除失败:{e}"}