Files
Portainer/cloudflared/add-domain.sh
T
Eduardo David Paredes Vara 38587699f9 feat(cloudflared): añadir stack de Cloudflare Tunnel + gestión DNS
- docker-compose.yml: tunnel de Cloudflare para exponer servicios sin
  abrir puertos en el router
- add-domain.sh: script para añadir subdominios al tunnel y crear
  registros DNS en Cloudflare vía API
- stack.env: plantilla con tokens y configuración del tunnel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 05:48:58 +00:00

158 lines
6.2 KiB
Bash
Executable File

#!/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."