281 lines
10 KiB
Python
Executable File
281 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Manage direct Cloudflare A records for this server.
|
|
|
|
Credentials and zone settings are read from cloudflared/.env. This utility does
|
|
not create, configure or inspect Cloudflare Tunnels.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import urlencode
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
API_BASE = "https://api.cloudflare.com/client/v4"
|
|
ENV_FILE = Path(__file__).resolve().with_name(".env")
|
|
DNS_NAME_RE = re.compile(
|
|
r"^(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*"
|
|
r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$"
|
|
)
|
|
|
|
|
|
class CloudflareError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def load_env(path: Path) -> dict[str, str]:
|
|
if not path.is_file():
|
|
raise CloudflareError(f"No existe el archivo de configuración: {path}")
|
|
|
|
values: dict[str, str] = {}
|
|
for number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if line.startswith("export "):
|
|
line = line[7:].lstrip()
|
|
if "=" not in line:
|
|
raise CloudflareError(f"Línea inválida en {path}:{number}")
|
|
key, value = line.split("=", 1)
|
|
key = key.strip()
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
value = value[1:-1]
|
|
values[key] = value
|
|
return values
|
|
|
|
|
|
def required(env: dict[str, str], key: str) -> str:
|
|
value = env.get(key, "").strip()
|
|
if not value or value.lower().startswith(("change-me", "changeme")):
|
|
raise CloudflareError(f"{key} no está configurado en {ENV_FILE}")
|
|
return value
|
|
|
|
|
|
def optional(env: dict[str, str], key: str) -> str | None:
|
|
value = env.get(key, "").strip()
|
|
if not value or value.lower().startswith(("change-me", "changeme")):
|
|
return None
|
|
return value
|
|
|
|
|
|
def normalize_name(value: str, domain: str) -> str:
|
|
value = value.strip().lower().rstrip(".")
|
|
if value == "@":
|
|
return domain
|
|
if "." not in value:
|
|
value = f"{value}.{domain}"
|
|
if value != domain and not value.endswith(f".{domain}"):
|
|
raise CloudflareError(f"{value!r} está fuera de la zona {domain}")
|
|
if len(value) > 253 or not DNS_NAME_RE.fullmatch(value):
|
|
raise CloudflareError(f"Nombre DNS inválido: {value!r}")
|
|
return value
|
|
|
|
|
|
class CloudflareDNS:
|
|
def __init__(self, token: str, zone_id: str) -> None:
|
|
self.token = token
|
|
self.zone_id = zone_id
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
payload: dict[str, Any] | None = None,
|
|
query: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
url = f"{API_BASE}{path}"
|
|
if query:
|
|
url = f"{url}?{urlencode(query)}"
|
|
body = json.dumps(payload).encode() if payload is not None else None
|
|
request = Request(
|
|
url,
|
|
data=body,
|
|
method=method,
|
|
headers={
|
|
"Authorization": f"Bearer {self.token}",
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "portainer-repo-cloudflare-dns/1.0",
|
|
},
|
|
)
|
|
try:
|
|
with urlopen(request, timeout=30) as response:
|
|
result = json.load(response)
|
|
except HTTPError as exc:
|
|
try:
|
|
detail = json.load(exc)
|
|
errors = detail.get("errors") or []
|
|
except (ValueError, AttributeError):
|
|
errors = []
|
|
message = "; ".join(str(item.get("message", item)) for item in errors)
|
|
raise CloudflareError(
|
|
f"Cloudflare respondió HTTP {exc.code}" + (f": {message}" if message else "")
|
|
) from exc
|
|
except (URLError, TimeoutError) as exc:
|
|
raise CloudflareError(f"No se pudo conectar con Cloudflare: {exc}") from exc
|
|
|
|
if not result.get("success"):
|
|
errors = result.get("errors") or []
|
|
message = "; ".join(str(item.get("message", item)) for item in errors)
|
|
raise CloudflareError(message or "Cloudflare rechazó la operación")
|
|
return result
|
|
|
|
def list_records(self) -> list[dict[str, Any]]:
|
|
records: list[dict[str, Any]] = []
|
|
page = 1
|
|
while True:
|
|
response = self.request(
|
|
"GET",
|
|
f"/zones/{self.zone_id}/dns_records",
|
|
query={"type": "A", "page": page, "per_page": 100},
|
|
)
|
|
records.extend(response.get("result") or [])
|
|
info = response.get("result_info") or {}
|
|
if page >= int(info.get("total_pages", 1)):
|
|
return records
|
|
page += 1
|
|
|
|
def records_for_name(self, name: str) -> list[dict[str, Any]]:
|
|
response = self.request(
|
|
"GET",
|
|
f"/zones/{self.zone_id}/dns_records",
|
|
query={"name": name, "per_page": 100},
|
|
)
|
|
return response.get("result") or []
|
|
|
|
def create(self, name: str, server_ip: str) -> None:
|
|
existing = self.records_for_name(name)
|
|
if existing:
|
|
types = ", ".join(sorted({str(record.get("type")) for record in existing}))
|
|
raise CloudflareError(f"{name} ya existe ({types}); usa update")
|
|
self.request(
|
|
"POST",
|
|
f"/zones/{self.zone_id}/dns_records",
|
|
payload=record_payload(name, server_ip),
|
|
)
|
|
|
|
def update(self, name: str, server_ip: str) -> None:
|
|
records = self.records_for_name(name)
|
|
a_records = [record for record in records if record.get("type") == "A"]
|
|
if not a_records:
|
|
raise CloudflareError(f"No existe un registro A para {name}; usa create")
|
|
if len(a_records) != 1:
|
|
raise CloudflareError(f"Hay {len(a_records)} registros A para {name}; corrígelos manualmente")
|
|
self.request(
|
|
"PUT",
|
|
f"/zones/{self.zone_id}/dns_records/{a_records[0]['id']}",
|
|
payload=record_payload(name, server_ip),
|
|
)
|
|
|
|
def delete(self, name: str) -> None:
|
|
records = self.records_for_name(name)
|
|
a_records = [record for record in records if record.get("type") == "A"]
|
|
if not a_records:
|
|
raise CloudflareError(f"No existe un registro A para {name}")
|
|
if len(a_records) != 1:
|
|
raise CloudflareError(f"Hay {len(a_records)} registros A para {name}; corrígelos manualmente")
|
|
self.request("DELETE", f"/zones/{self.zone_id}/dns_records/{a_records[0]['id']}")
|
|
|
|
|
|
def record_payload(name: str, server_ip: str) -> dict[str, Any]:
|
|
return {
|
|
"type": "A",
|
|
"name": name,
|
|
"content": server_ip,
|
|
"ttl": 1,
|
|
"proxied": False,
|
|
"comment": "Direct to homelab server",
|
|
}
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description="Gestiona registros A directos de Cloudflare para este servidor."
|
|
)
|
|
parser.add_argument("--dry-run", action="store_true", help="muestra cambios sin enviarlos")
|
|
parser.add_argument(
|
|
"--server-ip",
|
|
metavar="IP",
|
|
help="IPv4 de destino; reemplaza CLOUDFLARE_SERVER_IP del .env",
|
|
)
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
subparsers.add_parser("list", help="lista los registros A de la zona")
|
|
for command in ("create", "update"):
|
|
child = subparsers.add_parser(command, help=f"{command} registros A")
|
|
child.add_argument("names", nargs="+", metavar="NAME")
|
|
delete = subparsers.add_parser("delete", help="elimina registros A")
|
|
delete.add_argument("names", nargs="+", metavar="NAME")
|
|
delete.add_argument("--yes", action="store_true", help="no solicita confirmación")
|
|
return parser
|
|
|
|
|
|
def main() -> int:
|
|
args = build_parser().parse_args()
|
|
env = load_env(ENV_FILE)
|
|
token = required(env, "CLOUDFLARE_API_TOKEN")
|
|
zone_id = required(env, "CLOUDFLARE_ZONE_ID")
|
|
domain = required(env, "CLOUDFLARE_DOMAIN").lower().rstrip(".")
|
|
server_ip = args.server_ip or optional(env, "CLOUDFLARE_SERVER_IP")
|
|
if server_ip:
|
|
try:
|
|
ipaddress.IPv4Address(server_ip)
|
|
except ipaddress.AddressValueError as exc:
|
|
source = "--server-ip" if args.server_ip else "CLOUDFLARE_SERVER_IP"
|
|
raise CloudflareError(f"{source} debe ser una dirección IPv4 válida") from exc
|
|
elif args.command in ("create", "update"):
|
|
raise CloudflareError(
|
|
"Falta la IP de destino: configura CLOUDFLARE_SERVER_IP en .env "
|
|
"o usa --server-ip"
|
|
)
|
|
|
|
client = CloudflareDNS(token, zone_id)
|
|
if args.command == "list":
|
|
for record in sorted(client.list_records(), key=lambda item: item.get("name", "")):
|
|
if server_ip:
|
|
marker = "this-server" if record.get("content") == server_ip else "other-target"
|
|
print(f"{record.get('name')}\t{record.get('content')}\t{marker}")
|
|
else:
|
|
print(f"{record.get('name')}\t{record.get('content')}")
|
|
return 0
|
|
|
|
names = [normalize_name(value, domain) for value in args.names]
|
|
if args.command == "delete" and not args.yes and not args.dry_run:
|
|
print("Se eliminarán estos registros A:")
|
|
for name in names:
|
|
print(f" - {name}")
|
|
if input("Escribe DELETE para continuar: ").strip() != "DELETE":
|
|
print("Cancelado")
|
|
return 1
|
|
|
|
failed = False
|
|
for name in names:
|
|
try:
|
|
if args.dry_run:
|
|
target = "" if args.command == "delete" else f" -> {server_ip}"
|
|
print(f"DRY-RUN {args.command} {name}{target}")
|
|
else:
|
|
getattr(client, args.command)(name, server_ip) if args.command != "delete" else client.delete(name)
|
|
target = "" if args.command == "delete" else f" -> {server_ip}"
|
|
print(f"OK {args.command} {name}{target}")
|
|
except CloudflareError as exc:
|
|
failed = True
|
|
print(f"ERROR {name}: {exc}", file=sys.stderr)
|
|
return 1 if failed else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except (CloudflareError, KeyboardInterrupt) as exc:
|
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
raise SystemExit(1)
|