FluxIP/app/integrations/cloudflare_client.py

199 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
}