feat(cloudflare): replace tunnel helper with dns manager
This commit is contained in:
+63
-159
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
Executable
+280
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user