199 lines
7.4 KiB
Python
199 lines
7.4 KiB
Python
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
|
||
PROXIED_DNS_TTL = 1
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
token: str,
|
||
zone_name: str,
|
||
record_name: str,
|
||
zone_id: str = "",
|
||
proxied: bool = False,
|
||
) -> None:
|
||
self.token = token
|
||
self.zone_name = zone_name
|
||
self.record_name = record_name
|
||
self.zone_id = zone_id
|
||
self.proxied = bool(proxied)
|
||
|
||
@property
|
||
def expected_ttl(self) -> int:
|
||
return self.PROXIED_DNS_TTL if self.proxied else self.DNS_TTL_SECONDS
|
||
|
||
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,
|
||
expected_proxied: bool | 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 expected_proxied is not _EXPECTED_CURRENT_UNSET:
|
||
expected_mode = bool(expected_proxied)
|
||
expected_ttl = self.PROXIED_DNS_TTL if expected_mode else self.DNS_TTL_SECONDS
|
||
externally_modified = externally_modified or bool(
|
||
record
|
||
and (
|
||
bool(record.get("proxied")) != expected_mode
|
||
or record.get("ttl") != expected_ttl
|
||
)
|
||
)
|
||
if externally_modified:
|
||
raise ValidationAppError(
|
||
"DNS 记录已被外部修改,为避免覆盖人工操作已停止更新",
|
||
code="DNS_EXTERNALLY_MODIFIED",
|
||
)
|
||
body = {
|
||
"type": "A",
|
||
"name": self.record_name,
|
||
"content": ip,
|
||
"ttl": self.expected_ttl,
|
||
"proxied": self.proxied,
|
||
}
|
||
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")) != self.proxied
|
||
or verified.get("ttl") != self.expected_ttl
|
||
):
|
||
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,
|
||
"expected_proxied": self.proxied,
|
||
"ttl": record.get("ttl") if record else self.expected_ttl,
|
||
}
|