feat(cloudflare): replace tunnel helper with dns manager

This commit is contained in:
Eduardo David Paredes Vara
2026-08-09 17:30:14 +00:00
parent 587495c195
commit ac82477aee
4 changed files with 348 additions and 321 deletions
+63 -159
View File
@@ -1,196 +1,100 @@
# Cloudflare Tunnel (cloudflared)
# Cloudflare DNS y cloudflared
Conecta el servidor a Cloudflare sin abrir puertos. Todo el tráfico entra por la red de Cloudflare,
llega al contenedor `cloudflared`, y este lo reenvía a Traefik.
Esta carpeta contiene dos piezas independientes:
```
Internet → Cloudflare Edge → cloudflared → Traefik → servicios
```
- `cloudflare_dns.py`: administra registros DNS `A` que apuntan directamente a este servidor.
- `docker-compose.yml`: definición opcional de Cloudflare Tunnel, actualmente no desplegada.
**Ventajas sobre exposición directa:**
- Tu IP pública nunca es visible — CrowdSec solo ve IPs de Cloudflare (allowlisteadas)
- Funciona desde redes con CGNAT, CG-NAT corporativo o sin IPv4 fija
- Cloudflare absorbe DDoS y filtra bots antes de que lleguen al servidor
La herramienta DNS no crea, activa, consulta ni modifica túneles.
> **Estado actual**: el tunnel está **parado** (`docker stop cloudflared`).
> Los DNS apuntan directo a `193.70.84.224` (registros A, sin proxy Cloudflare).
> Ver sección [Activar / desactivar el tunnel](#activar--desactivar-el-tunnel).
## Configuración DNS
---
## Configuración inicial (una sola vez)
### 1. Crear el tunnel en Cloudflare
1. Ve a [dash.cloudflare.com](https://dash.cloudflare.com) → **Zero Trust****Networks****Conectores**
2. **Create connector** → nombre: `homeserver`
3. Selecciona **Docker** → copia el **token** (cadena larga tras `--token`)
### 2. Crear un API Token
En [dash.cloudflare.com](https://dash.cloudflare.com) → **My Profile****API Tokens****Create Token**
Permisos mínimos necesarios:
| Scope | Recurso | Permiso |
|---------|----------------------------|---------|
| Account | Cloudflare Tunnel | Edit |
| Zone | DNS (sherlockhomeless.net) | Edit |
> El API Token también lo usa `add-domain.sh` en modo directo (solo necesita Zone > DNS > Edit).
### 3. Configurar el `.env`
```bash
nano .env
```
Variables obligatorias para **ambos modos**:
`cloudflare_dns.py` lee el `.env` local de esta carpeta. Necesita:
```env
CLOUDFLARE_API_TOKEN=<token del paso 2>
CLOUDFLARE_ZONE_ID=f7c4e16f9a434a947ad0f266b2e1f894
CLOUDFLARE_API_TOKEN=<token con Zone DNS Edit>
CLOUDFLARE_ZONE_ID=<id de la zona>
CLOUDFLARE_DOMAIN=sherlockhomeless.net
CLOUDFLARE_SERVER_IP=193.70.84.224 # IP para registros A directos
# Opcional: destino predeterminado para create/update
CLOUDFLARE_SERVER_IP=<IPv4 pública de este servidor>
```
Variables adicionales solo necesarias **con tunnel activo**:
El API token sólo necesita permiso `Zone > DNS > Edit` sobre la zona indicada. No necesita permisos de cuenta ni de Cloudflare Tunnel.
```env
CLOUDFLARE_TUNNEL_TOKEN=<token del paso 1>
CLOUDFLARE_ACCOUNT_ID=328f4b942827169b11f7d1f11c640522
CLOUDFLARE_TUNNEL_ID=d405a2a2-69a1-40f1-b939-04cea5d60a50
```
`CLOUDFLARE_SERVER_IP` es opcional. Si se configura, se usa como destino predeterminado; si se omite, `create` y `update` exigen `--server-ip`. El script nunca autodetecta la IP: así evita modificar registros hacia una dirección inesperada por un fallo de red o de un servicio externo.
---
## Operaciones
## Añadir un nuevo subdominio
### Modo directo (sin tunnel, estado actual)
Crea un registro `A` apuntando a la IP del servidor, sin proxy de Cloudflare:
Lista todos los registros `A` e indica cuáles apuntan a `CLOUDFLARE_SERVER_IP`:
```bash
cd cloudflared
./add-domain.sh immich
./add-domain.sh immich recipes calibre # varios a la vez
./cloudflare_dns.py list
```
El script:
1. Detecta la IP del servidor desde `CLOUDFLARE_SERVER_IP` (o la autodetecta via `api.ipify.org`)
2. Crea o actualiza el registro DNS `A` con proxy desactivado (nube gris)
### Modo tunnel (cuando el tunnel esté activo)
Crea un registro `CNAME` al tunnel con proxy naranja y añade el hostname al ingress:
Crea registros nuevos:
```bash
cd cloudflared
./add-domain.sh --tunnel immich
./add-domain.sh --tunnel immich recipes calibre
./cloudflare_dns.py create immich recipes
```
El script:
1. Lee el ingress actual del tunnel desde la API de Cloudflare
2. Añade el hostname apuntando a `https://traefik:443` (noTLSVerify)
3. Guarda el ingress actualizado
4. Crea o actualiza el registro DNS `CNAME` con proxy naranja
---
## Activar / desactivar el tunnel
### Activar
Sin IP predeterminada en `.env`:
```bash
# 1. Levantar el contenedor
cd cloudflared
docker compose --env-file .env up -d
# 2. Migrar los DNS al tunnel (convierte A records → CNAME proxied)
./add-domain.sh --tunnel auth traefik gitea n8n nextcloud paperless paperless-ai \
notas memos vikunja karakeep kasm remote beszel dozzle crowdsec grafana llm \
ocode adblock onlyoffice www cockpit coolify kopia notes portainer \
uptimekuma webtop wg
# 3. Verificar conexión
docker logs cloudflared | grep "Registered tunnel"
./cloudflare_dns.py --server-ip 203.0.113.10 create immich recipes
```
### Desactivar
`--server-ip` siempre tiene prioridad sobre el valor del `.env`, lo que permite un cambio puntual sin editar el archivo local.
Actualiza registros `A` existentes para que apunten a este servidor:
```bash
# 1. Parar el contenedor (no se levantará solo al reiniciar)
docker stop cloudflared
# 2. Revertir DNS a registros A directos
./add-domain.sh auth traefik gitea n8n nextcloud paperless paperless-ai \
notas memos vikunja karakeep kasm remote beszel dozzle crowdsec grafana llm \
ocode adblock onlyoffice www cockpit coolify kopia notes portainer \
uptimekuma webtop wg
./cloudflare_dns.py update immich recipes
```
---
## Subdominios registrados
| Subdominio | Servicio |
|------------|----------|
| `auth` | Authentik SSO |
| `traefik` | Traefik Dashboard |
| `gitea` | Gitea |
| `n8n` | n8n |
| `nextcloud` | Nextcloud |
| `paperless` | Paperless-ngx |
| `paperless-ai` | Paperless AI |
| `notas` | Trilium |
| `memos` | Memos |
| `vikunja` | Vikunja |
| `karakeep` | Karakeep |
| `kasm` | Kasm Workspaces |
| `remote` | Guacamole |
| `beszel` | Beszel |
| `dozzle` | Dozzle |
| `crowdsec` | CrowdSec Grafana |
| `grafana` | Grafana |
| `llm` | LiteLLM |
| `ocode` | OpenCode |
| `adblock` | AdGuard |
| `onlyoffice` | OnlyOffice |
| `www` | Homepage |
| `cockpit` | Cockpit |
| `coolify` | Coolify |
| `kopia` | Kopia |
| `notes` | Notes |
| `portainer` | Portainer |
| `uptimekuma` | Uptime Kuma |
| `webtop` | Webtop |
| `wg` | WireGuard |
---
## Comandos útiles
Elimina registros `A`; requiere escribir `DELETE`:
```bash
# Ver estado del tunnel
docker logs cloudflared
# Ver todos los subdominios en DNS de Cloudflare
curl -s "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records?per_page=100" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" | \
python3 -c "import sys,json; [print(r['type'].ljust(6), r['name']) for r in json.load(sys.stdin)['result']]"
# Ver ingress del tunnel configurado en Cloudflare
curl -s "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/cfd_tunnel/${CLOUDFLARE_TUNNEL_ID}/configurations" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" | \
python3 -c "import sys,json; [print(r.get('hostname','*'), '->', r['service']) for r in json.load(sys.stdin)['result']['config']['ingress']]"
./cloudflare_dns.py delete immich
```
---
Para automatización no interactiva:
```bash
./cloudflare_dns.py delete --yes immich
```
Antes de crear, modificar o eliminar, puede revisarse la intención sin llamar a la API:
```bash
./cloudflare_dns.py --dry-run create immich
./cloudflare_dns.py --dry-run update immich
./cloudflare_dns.py --dry-run delete immich
```
También se acepta el dominio completo o `@` para el dominio raíz. Se rechazan nombres fuera de `CLOUDFLARE_DOMAIN`.
## Comportamiento deliberado
- Sólo gestiona registros `A`.
- Usa `--server-ip` como destino o, si no se pasa, `CLOUDFLARE_SERVER_IP` como valor predeterminado.
- Siempre crea registros DNS-only (`proxied: false`, nube gris).
- `create` falla si el nombre ya existe; no sobrescribe silenciosamente un CNAME u otro registro.
- `update` y `delete` fallan si no hay exactamente un registro `A` para el nombre.
- Las operaciones múltiples continúan con el resto de nombres y terminan con código distinto de cero si alguno falla.
Estas restricciones evitan convertir registros a túnel o proxy de Cloudflare accidentalmente.
## Compose de cloudflared
El Compose conserva la definición del agente de Cloudflare Tunnel, pero no hay actualmente ningún contenedor `cloudflared` desplegado. Si no se va a volver a usar el túnel, puede retirarse en un cambio separado junto con `CLOUDFLARE_TUNNEL_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` y `CLOUDFLARE_TUNNEL_ID`.
La utilidad DNS no necesita levantar este Compose.
## Seguridad
- **API Token**: permisos mínimos (Tunnel Edit + DNS Edit). Rótalo periódicamente.
- **Tunnel Token**: si se compromete, regénéralo en el dashboard y reinicia el contenedor con el nuevo token en `.env`.
- **CrowdSec con tunnel activo**: las IPs de Cloudflare están en la allowlist `cloudflare`. El bouncer usa `forwardedHeadersTrustedIPs` con los rangos de CF para ver la IP real del cliente.
- **CrowdSec sin tunnel**: el bouncer ve la IP pública directa del cliente. El bot de Telegram (`/allow <ip>`) puede desbanear y allowlistear en CrowdSec, fail2ban y firewalld simultáneamente.
- Mantén el token y la IP reales únicamente en `.env`, que está ignorado por Git.
- Usa un token dedicado limitado a edición DNS de esta zona.
- Revisa `--dry-run` antes de operaciones masivas.
- La salida nunca imprime el token, pero `list` sí muestra nombres e IP de los registros.
- Rota el token si aparece en logs, historial de shell o commits.
-157
View File
@@ -1,157 +0,0 @@
#!/usr/bin/env bash
# Registra subdominios en Cloudflare DNS y opcionalmente en el tunnel.
#
# Modos:
# ./add-domain.sh <sub> [sub2 ...] → A record directo (sin tunnel)
# ./add-domain.sh --tunnel <sub> [sub2 ...] → CNAME al tunnel + ingress
#
# Ejemplos:
# ./add-domain.sh immich → A 193.70.84.224, solo DNS
# ./add-domain.sh --tunnel immich obsidian → CNAME tunnel + ingress
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${SCRIPT_DIR}/.env"
if [ ! -f "$ENV_FILE" ]; then
echo "ERROR: No se encontró .env en $SCRIPT_DIR"
exit 1
fi
set -a; source "$ENV_FILE"; set +a
# ── Modo ────────────────────────────────────────────────────────────────────
MODE="direct"
if [ "${1:-}" = "--tunnel" ]; then
MODE="tunnel"
shift
fi
if [ $# -eq 0 ]; then
echo "Uso: $0 [--tunnel] <subdominio> [subdominio2 ...]"
exit 1
fi
# ── Validar variables obligatorias ──────────────────────────────────────────
REQUIRED_VARS="CLOUDFLARE_API_TOKEN CLOUDFLARE_ZONE_ID CLOUDFLARE_DOMAIN"
[ "$MODE" = "tunnel" ] && REQUIRED_VARS="$REQUIRED_VARS CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_TUNNEL_ID"
for VAR in $REQUIRED_VARS; do
if [ -z "${!VAR:-}" ] || [[ "${!VAR}" == change-me* ]]; then
echo "ERROR: $VAR no está configurado en .env"
exit 1
fi
done
SERVER_IP="${CLOUDFLARE_SERVER_IP:-}"
if [ "$MODE" = "direct" ] && [ -z "$SERVER_IP" ]; then
# Autodetectar IP pública del servidor
SERVER_IP=$(curl -s --max-time 5 https://api.ipify.org 2>/dev/null || true)
if [ -z "$SERVER_IP" ]; then
echo "ERROR: No se pudo detectar la IP del servidor. Define CLOUDFLARE_SERVER_IP en .env"
exit 1
fi
echo "→ IP del servidor autodetectada: $SERVER_IP"
fi
# ── Modo tunnel: obtener ingress actual ─────────────────────────────────────
TUNNEL_CNAME="${CLOUDFLARE_TUNNEL_ID:-}.cfargotunnel.com"
CURRENT=""
if [ "$MODE" = "tunnel" ]; then
echo "→ Obteniendo configuración actual del tunnel..."
CURRENT=$(curl -s \
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/cfd_tunnel/${CLOUDFLARE_TUNNEL_ID}/configurations" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}")
if ! echo "$CURRENT" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('success') else 1)" 2>/dev/null; then
echo "ERROR: No se pudo obtener la config del tunnel"
echo "$CURRENT"
exit 1
fi
fi
# ── Por cada subdominio ──────────────────────────────────────────────────────
for SUB in "$@"; do
FQDN="${SUB}.${CLOUDFLARE_DOMAIN}"
echo ""
echo "=== $FQDN ==="
# Tunnel: añadir al ingress
if [ "$MODE" = "tunnel" ]; then
CURRENT=$(echo "$CURRENT" | python3 -c "
import sys, json
data = json.load(sys.stdin)
ingress = data['result']['config']['ingress']
hostname = '${FQDN}'
if any(r.get('hostname') == hostname for r in ingress):
print(json.dumps(data), end='')
print(' tunnel: ya existe, sin cambios', file=sys.stderr)
sys.exit(0)
catch_all = ingress.pop()
ingress.append({'hostname': hostname, 'service': 'https://traefik:443', 'originRequest': {'noTLSVerify': True}})
ingress.append(catch_all)
data['result']['config']['ingress'] = ingress
print(json.dumps(data), end='')
print(' tunnel: añadido', file=sys.stderr)
")
DNS_PAYLOAD="{\"type\":\"CNAME\",\"name\":\"${SUB}\",\"content\":\"${TUNNEL_CNAME}\",\"ttl\":1,\"proxied\":true,\"comment\":\"Cloudflare Tunnel\"}"
DNS_TARGET="$TUNNEL_CNAME (CNAME proxied)"
else
DNS_PAYLOAD="{\"type\":\"A\",\"name\":\"${SUB}\",\"content\":\"${SERVER_IP}\",\"ttl\":1,\"proxied\":false,\"comment\":\"Direct\"}"
DNS_TARGET="$SERVER_IP (A record)"
fi
# DNS: crear o actualizar
EXISTING=$(curl -s \
"https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records?name=${FQDN}" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}")
RECORD_ID=$(echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['result'][0]['id'])" 2>/dev/null || true)
if [ -n "$RECORD_ID" ]; then
RESULT=$(curl -s -X PUT \
"https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${RECORD_ID}" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$DNS_PAYLOAD")
MSG="actualizado"
else
RESULT=$(curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$DNS_PAYLOAD")
MSG="creado"
fi
if echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('success') else 1)" 2>/dev/null; then
echo " DNS: $MSG$DNS_TARGET"
else
echo " DNS ERROR:"
echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('errors'))"
fi
done
# ── Guardar ingress del tunnel ───────────────────────────────────────────────
if [ "$MODE" = "tunnel" ]; then
echo ""
echo "→ Guardando configuración del tunnel..."
NEW_CONFIG=$(echo "$CURRENT" | python3 -c "
import sys, json
data = json.load(sys.stdin)
print(json.dumps({'config': data['result']['config']}))
")
RESULT=$(curl -s -X PUT \
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/cfd_tunnel/${CLOUDFLARE_TUNNEL_ID}/configurations" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$NEW_CONFIG")
if echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('success') else 1)" 2>/dev/null; then
echo " Tunnel ingress actualizado ✓"
else
echo " ERROR al actualizar tunnel:"
echo "$RESULT"
exit 1
fi
fi
echo ""
echo "Listo. DNS activo en ~30s."
+280
View File
@@ -0,0 +1,280 @@
#!/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)
+5 -5
View File
@@ -3,8 +3,8 @@ CLOUDFLARED_IMAGE=cloudflare/cloudflared:latest
# Token del tunnel — dash.cloudflare.com → Zero Trust → Networks → Conectores
CLOUDFLARE_TUNNEL_TOKEN=change-me
# Credenciales API — dash.cloudflare.com → My Profile → API Tokens
# Permisos necesarios: Account > Cloudflare Tunnel > Edit, Zone > DNS > Edit
# Credenciales API para cloudflare_dns.py — My Profile → API Tokens
# Permiso mínimo: Zone > DNS > Edit para la zona indicada
CLOUDFLARE_API_TOKEN=change-me
CLOUDFLARE_ACCOUNT_ID=change-me
CLOUDFLARE_TUNNEL_ID=change-me
@@ -13,8 +13,8 @@ CLOUDFLARE_ZONE_ID=change-me
# Dominio raíz
CLOUDFLARE_DOMAIN=sherlockhomeless.net
# IP pública del servidor (para registros A directos sin tunnel)
# Si no se define, el script la autodetecta via api.ipify.org
CLOUDFLARE_SERVER_IP=1.2.3.4
# IP predeterminada opcional para create/update. Déjala vacía en plantillas.
# También puede pasarse puntualmente con --server-ip.
CLOUDFLARE_SERVER_IP=
TZ=America/Mexico_City