from __future__ import annotations import ipaddress from typing import Any import httpx from app.core.errors import ExternalServiceError, NotFoundError, ValidationAppError _EXPECTED_CURRENT_UNSET = object() class CloudflareClient: API_BASE = "https://api.cloudflare.com/client/v4" DNS_TTL_SECONDS = 60 def __init__( self, *, token: str, zone_name: str, record_name: str, zone_id: str = "", ) -> None: self.token = token self.zone_name = zone_name self.record_name = record_name self.zone_id = zone_id def _request(self, method: str, path: str, **kwargs: Any) -> Any: try: with httpx.Client( base_url=self.API_BASE, headers={"Authorization": f"Bearer {self.token}"}, timeout=httpx.Timeout(20, connect=10), ) as client: response = client.request(method, path, **kwargs) except httpx.HTTPError as exc: raise ExternalServiceError( "无法连接 Cloudflare API", service="cloudflare", code="CLOUDFLARE_CONNECTION_FAILED", ) from exc try: payload = response.json() except ValueError as exc: raise ExternalServiceError( "Cloudflare API 返回了无效响应", service="cloudflare", code="CLOUDFLARE_INVALID_RESPONSE", ) from exc if response.is_error or not payload.get("success"): errors = payload.get("errors") or [] message = errors[0].get("message") if errors else f"HTTP {response.status_code}" code = str(errors[0].get("code")) if errors else "CLOUDFLARE_API_ERROR" raise ExternalServiceError( f"Cloudflare 返回错误:{message}", service="cloudflare", code=code ) return payload.get("result") def resolve_zone_id(self) -> str: if self.zone_id: result = self._request("GET", f"/zones/{self.zone_id}") if str(result.get("name", "")).lower() != self.zone_name.lower(): raise ValidationAppError("Cloudflare Zone ID 与根域名不匹配") return self.zone_id result = self._request( "GET", "/zones", params={"name": self.zone_name, "status": "active", "per_page": 50} ) if len(result) != 1: raise NotFoundError( f"未找到唯一的 Cloudflare Zone:{self.zone_name}", code="CLOUDFLARE_ZONE_NOT_FOUND", ) self.zone_id = result[0]["id"] return self.zone_id def list_name_records(self) -> list[dict[str, Any]]: zone_id = self.resolve_zone_id() result = self._request( "GET", f"/zones/{zone_id}/dns_records", params={"name": self.record_name, "per_page": 100}, ) return [ record for record in result if str(record.get("name", "")).lower() == self.record_name.lower() ] @staticmethod def _validate_records(records: list[dict[str, Any]]) -> dict[str, Any] | None: conflicts = [record for record in records if record.get("type") in {"AAAA", "CNAME"}] if conflicts: types = ", ".join(sorted({str(record["type"]) for record in conflicts})) raise ValidationAppError( f"同名 {types} 记录会让客户端绕过 IPv4 轮换,请先移除", code="CONFLICTING_DNS_RECORD", ) a_records = [record for record in records if record.get("type") == "A"] if len(a_records) > 1: raise ValidationAppError( "同名 A 记录超过一条,请先在 Cloudflare 合并", code="MULTIPLE_A_RECORDS", ) return a_records[0] if a_records else None def get_a_record(self) -> dict[str, Any] | None: return self._validate_records(self.list_name_records()) def upsert_a_record( self, ip: str, *, expected_current: str | None | object = _EXPECTED_CURRENT_UNSET, ) -> dict[str, Any]: ipaddress.IPv4Address(ip) zone_id = self.resolve_zone_id() record = self.get_a_record() if expected_current is not _EXPECTED_CURRENT_UNSET: allowed_current = {ip} if expected_current is None else {expected_current, ip} current = record.get("content") if record else None externally_modified = ( record is not None and current not in allowed_current if expected_current is None else record is None or current not in allowed_current ) else: externally_modified = False if externally_modified: raise ValidationAppError( "DNS 记录已被外部修改,为避免覆盖人工操作已停止更新", code="DNS_EXTERNALLY_MODIFIED", ) body = { "type": "A", "name": self.record_name, "content": ip, "ttl": self.DNS_TTL_SECONDS, "proxied": False, } if record: written = self._request( "PATCH", f"/zones/{zone_id}/dns_records/{record['id']}", json=body, ) else: written = self._request( "POST", f"/zones/{zone_id}/dns_records", json=body ) verified = self.get_a_record() expected_id = str(written.get("id", "")) if ( verified is None or (expected_id and str(verified.get("id", "")) != expected_id) or verified.get("content") != ip or bool(verified.get("proxied")) or verified.get("ttl") != self.DNS_TTL_SECONDS ): raise ValidationAppError( "DNS 写入后的回读校验失败,请检查是否存在并发修改", code="DNS_WRITE_NOT_VERIFIED", ) return verified def test_connection(self) -> dict[str, object]: zone_id = self.resolve_zone_id() record = self.get_a_record() return { "ok": True, "service": "cloudflare", "message": "Cloudflare 连接正常", "zone_id": zone_id, "zone_name": self.zone_name, "record_name": self.record_name, "record_exists": record is not None, "record_ip": record.get("content") if record else None, "proxied": bool(record.get("proxied")) if record else False, "ttl": record.get("ttl") if record else self.DNS_TTL_SECONDS, }