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,
|
Esta carpeta contiene dos piezas independientes:
|
||||||
llega al contenedor `cloudflared`, y este lo reenvía a Traefik.
|
|
||||||
|
|
||||||
```
|
- `cloudflare_dns.py`: administra registros DNS `A` que apuntan directamente a este servidor.
|
||||||
Internet → Cloudflare Edge → cloudflared → Traefik → servicios
|
- `docker-compose.yml`: definición opcional de Cloudflare Tunnel, actualmente no desplegada.
|
||||||
```
|
|
||||||
|
|
||||||
**Ventajas sobre exposición directa:**
|
La herramienta DNS no crea, activa, consulta ni modifica túneles.
|
||||||
- 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
|
|
||||||
|
|
||||||
> **Estado actual**: el tunnel está **parado** (`docker stop cloudflared`).
|
## Configuración DNS
|
||||||
> 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).
|
|
||||||
|
|
||||||
---
|
`cloudflare_dns.py` lee el `.env` local de esta carpeta. Necesita:
|
||||||
|
|
||||||
## 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**:
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
CLOUDFLARE_API_TOKEN=<token del paso 2>
|
CLOUDFLARE_API_TOKEN=<token con Zone DNS Edit>
|
||||||
CLOUDFLARE_ZONE_ID=f7c4e16f9a434a947ad0f266b2e1f894
|
CLOUDFLARE_ZONE_ID=<id de la zona>
|
||||||
CLOUDFLARE_DOMAIN=sherlockhomeless.net
|
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_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.
|
||||||
CLOUDFLARE_TUNNEL_TOKEN=<token del paso 1>
|
|
||||||
CLOUDFLARE_ACCOUNT_ID=328f4b942827169b11f7d1f11c640522
|
|
||||||
CLOUDFLARE_TUNNEL_ID=d405a2a2-69a1-40f1-b939-04cea5d60a50
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
## Operaciones
|
||||||
|
|
||||||
## Añadir un nuevo subdominio
|
Lista todos los registros `A` e indica cuáles apuntan a `CLOUDFLARE_SERVER_IP`:
|
||||||
|
|
||||||
### Modo directo (sin tunnel, estado actual)
|
|
||||||
|
|
||||||
Crea un registro `A` apuntando a la IP del servidor, sin proxy de Cloudflare:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd cloudflared
|
cd cloudflared
|
||||||
./add-domain.sh immich
|
./cloudflare_dns.py list
|
||||||
./add-domain.sh immich recipes calibre # varios a la vez
|
|
||||||
```
|
```
|
||||||
|
|
||||||
El script:
|
Crea registros nuevos:
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd cloudflared
|
./cloudflare_dns.py create immich recipes
|
||||||
./add-domain.sh --tunnel immich
|
|
||||||
./add-domain.sh --tunnel immich recipes calibre
|
|
||||||
```
|
```
|
||||||
|
|
||||||
El script:
|
Sin IP predeterminada en `.env`:
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Levantar el contenedor
|
./cloudflare_dns.py --server-ip 203.0.113.10 create immich recipes
|
||||||
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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
# 1. Parar el contenedor (no se levantará solo al reiniciar)
|
./cloudflare_dns.py update immich recipes
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Elimina registros `A`; requiere escribir `DELETE`:
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ver estado del tunnel
|
./cloudflare_dns.py delete immich
|
||||||
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']]"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
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
|
## Seguridad
|
||||||
|
|
||||||
- **API Token**: permisos mínimos (Tunnel Edit + DNS Edit). Rótalo periódicamente.
|
- Mantén el token y la IP reales únicamente en `.env`, que está ignorado por Git.
|
||||||
- **Tunnel Token**: si se compromete, regénéralo en el dashboard y reinicia el contenedor con el nuevo token en `.env`.
|
- Usa un token dedicado limitado a edición DNS de esta zona.
|
||||||
- **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.
|
- Revisa `--dry-run` antes de operaciones masivas.
|
||||||
- **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.
|
- 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
|
# Token del tunnel — dash.cloudflare.com → Zero Trust → Networks → Conectores
|
||||||
CLOUDFLARE_TUNNEL_TOKEN=change-me
|
CLOUDFLARE_TUNNEL_TOKEN=change-me
|
||||||
|
|
||||||
# Credenciales API — dash.cloudflare.com → My Profile → API Tokens
|
# Credenciales API para cloudflare_dns.py — My Profile → API Tokens
|
||||||
# Permisos necesarios: Account > Cloudflare Tunnel > Edit, Zone > DNS > Edit
|
# Permiso mínimo: Zone > DNS > Edit para la zona indicada
|
||||||
CLOUDFLARE_API_TOKEN=change-me
|
CLOUDFLARE_API_TOKEN=change-me
|
||||||
CLOUDFLARE_ACCOUNT_ID=change-me
|
CLOUDFLARE_ACCOUNT_ID=change-me
|
||||||
CLOUDFLARE_TUNNEL_ID=change-me
|
CLOUDFLARE_TUNNEL_ID=change-me
|
||||||
@@ -13,8 +13,8 @@ CLOUDFLARE_ZONE_ID=change-me
|
|||||||
# Dominio raíz
|
# Dominio raíz
|
||||||
CLOUDFLARE_DOMAIN=sherlockhomeless.net
|
CLOUDFLARE_DOMAIN=sherlockhomeless.net
|
||||||
|
|
||||||
# IP pública del servidor (para registros A directos sin tunnel)
|
# IP predeterminada opcional para create/update. Déjala vacía en plantillas.
|
||||||
# Si no se define, el script la autodetecta via api.ipify.org
|
# También puede pasarse puntualmente con --server-ip.
|
||||||
CLOUDFLARE_SERVER_IP=1.2.3.4
|
CLOUDFLARE_SERVER_IP=
|
||||||
|
|
||||||
TZ=America/Mexico_City
|
TZ=America/Mexico_City
|
||||||
|
|||||||
Reference in New Issue
Block a user