From 978eac5ed9f5372b9c106b01c36ff73e05b50b4b Mon Sep 17 00:00:00 2001 From: Wilman Yesid Farfan Diaz Date: Sun, 12 Jul 2026 23:03:53 -0500 Subject: [PATCH] feat: add automation script for Cloudflare DNS provisioning and Dokploy deployment integration --- scripts/setup-domains.sh | 513 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 513 insertions(+) create mode 100644 scripts/setup-domains.sh diff --git a/scripts/setup-domains.sh b/scripts/setup-domains.sh new file mode 100644 index 0000000..fd19b15 --- /dev/null +++ b/scripts/setup-domains.sh @@ -0,0 +1,513 @@ +#!/usr/bin/env bash +# ============================================================================= +# setup-domains.sh — Script de Automatización de Dominios (Cloudflare y Dokploy) +# ============================================================================= +# +# Propósito: +# Este script automatiza el flujo completo de enrutamiento y despliegue de +# los subdominios requeridos por la plataforma OmniOil en un entorno de producción. +# +# Realiza dos tareas principales: +# 1. Crea o actualiza registros DNS tipo "A" en Cloudflare apuntando a la +# IP pública del servidor VPS, con el Proxy (CDN) activo. +# 2. Registra los dominios en la API de Dokploy asociándolos a cada servicio +# específico dentro de la pila Docker Compose (con soporte SSL automático +# mediante Let's Encrypt), e inicia un re-despliegue de la pila para aplicar +# los cambios de enrutamiento en Traefik. +# +# Requisitos del Sistema: +# - curl: Para realizar peticiones HTTP/REST a las APIs. +# - jq: Para parsear e inyectar payloads JSON. El script tiene un buscador +# inteligente de jq en rutas locales (por si no está instalado a nivel global). +# +# Autenticación y Credenciales: +# Las credenciales se pueden pasar por línea de comandos o configurar en un +# archivo .env o .env.production local (el script los carga automáticamente). +# +# ============================================================================= + +set -euo pipefail + +# ----------------------------------------------------------------------------- +# 1. Configuración por Defecto y Variables de Estado +# ----------------------------------------------------------------------------- + +DOMAIN="" # Dominio base (ej. omnioil.app). Se solicita de forma interactiva si falta. +SERVER_IP="" # IP pública del VPS. Se solicita de forma interactiva si falta. +PROJECT_NAME="omnioil"# Nombre del proyecto dentro del panel de Dokploy. +COMPOSE_NAME="anh" # Nombre del Docker Compose en Dokploy (corresponde a la pila unificada). +CF_TOKEN="" # Token de API de Cloudflare con permisos de edición de registros DNS. +CF_ZONE_ID="" # ID de la Zona de Cloudflare asignada al dominio. +DOKPLOY_API_URL="" # URL base de la API de Dokploy. +DOKPLOY_API_KEY="" # Token/API Key generada en el perfil de administrador de Dokploy. +DRY_RUN=false # Si es true, corre en modo de simulación imprimiendo las acciones. +SKIP_REDEPLOY=false # Si es true, omite el comando de redeploy en Dokploy al finalizar. + +CLOUDFLARE_API="https://api.cloudflare.com/client/v4" + +# ----------------------------------------------------------------------------- +# 2. Función de Carga Segura de Archivos de Entorno (.env) +# ----------------------------------------------------------------------------- +# Esta función lee un archivo .env línea por línea de forma nativa en Bash. +# A diferencia de un "source" o "eval" directo en el archivo completo, esta lógica: +# - Evita la expansión accidental de caracteres comodín (*) comunes en expresiones +# cron como BACKUP_SCHEDULE="0 3 * * *". +# - Remueve de manera segura los comentarios al final de la línea. +# - Limpia comillas dobles y simples exteriores de los valores. +load_env_file() { + local env_file="$1" + if [ -f "$env_file" ]; then + echo "ℹ️ Cargando variables de entorno desde $env_file..." + while IFS= read -r line || [ -n "$line" ]; do + # Eliminar espacios en blanco al principio de la línea + line="${line##[[:space:]]}" + + # Saltar comentarios y líneas vacías + [[ "$line" =~ ^# ]] && continue + [[ -z "$line" ]] && continue + + # Procesar solo líneas que contengan una asignación '=' + if [[ "$line" == *=* ]]; then + local key="${line%%=*}" + local value="${line#*=}" + + # Quitar comentarios al final del valor (delimitados por '#') + value="${value%%#*}" + + # Recortar espacios sobrantes al inicio y final en Bash puro (sin subshells) + key="${key#"${key%%[![:space:]]*}"}" + key="${key%"${key##*[![:space:]]}"}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + # Quitar comillas exteriores del valor si las tuviera + if [[ "$value" =~ ^\".*\"$ ]] || [[ "$value" =~ ^\'.*\'$ ]]; then + value="${value:1:${#value}-2}" + fi + + # Exportar la variable al entorno actual usando declare (seguro contra globbing) + if [ -n "$key" ]; then + declare -x "$key=$value" 2>/dev/null || true + fi + fi + done < "$env_file" + fi +} + +# Cargar configuración desde archivos locales +load_env_file ".env" +load_env_file ".env.production" + +# Acoplar variables precargadas desde el entorno o archivos .env +DOMAIN="${DOMAIN:-${OMNIOIL_DOMAIN:-}}" +SERVER_IP="${SERVER_IP:-${OMNIOIL_IP:-}}" +CF_TOKEN="${CF_TOKEN:-}" +CF_ZONE_ID="${CF_ZONE_ID:-}" +DOKPLOY_API_URL="${DOKPLOY_API_URL:-}" +DOKPLOY_API_KEY="${DOKPLOY_API_KEY:-}" + +# ----------------------------------------------------------------------------- +# 3. Procesamiento de Argumentos de la Línea de Comandos +# ----------------------------------------------------------------------------- +# Las opciones pasadas por la terminal tienen prioridad sobre los archivos .env. +while [[ $# -gt 0 ]]; do + case "$1" in + --domain) + DOMAIN="$2" + shift 2 + ;; + --ip) + SERVER_IP="$2" + shift 2 + ;; + --project) + PROJECT_NAME="$2" + shift 2 + ;; + --compose) + COMPOSE_NAME="$2" + shift 2 + ;; + --cf-token) + CF_TOKEN="$2" + shift 2 + ;; + --cf-zone-id) + CF_ZONE_ID="$2" + shift 2 + ;; + --dokploy-url) + DOKPLOY_API_URL="$2" + shift 2 + ;; + --dokploy-key) + DOKPLOY_API_KEY="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --skip-redeploy) + SKIP_REDEPLOY=true + shift + ;; + -h|--help) + echo "Uso: $0 [opciones]" + echo "" + echo "Opciones:" + echo " --domain Dominio principal (ej. omnioil.app)" + echo " --ip IP pública del servidor VPS" + echo " --project Nombre del proyecto en Dokploy (default: omnioil)" + echo " --compose Nombre de la app Compose en Dokploy (default: anh)" + echo " --cf-token Token de Cloudflare API (permiso DNS Edit)" + echo " --cf-zone-id Zone ID de Cloudflare" + echo " --dokploy-url URL API Dokploy (default: http://:3000/api)" + echo " --dokploy-key API Key de Dokploy" + echo " --dry-run Modo simulación (imprime acciones sin hacer cambios reales)" + echo " --skip-redeploy Evita el re-despliegue automático en Dokploy" + exit 0 + ;; + *) + echo "❌ Opción desconocida: $1" + echo "Ejecuta '$0 --help' para ver las opciones disponibles." + exit 1 + ;; + esac +done + +# ----------------------------------------------------------------------------- +# 4. Validaciones de Entorno e Interactividad +# ----------------------------------------------------------------------------- + +# El comando 'curl' es indispensable para las peticiones de red +if ! command -v curl &>/dev/null; then + echo "❌ Error: Se requiere 'curl' instalado en el sistema." + exit 1 +fi + +# Buscador tolerante de 'jq' +# Si no está instalado globalmente en el sistema, busca en carpetas de usuario +# locales de WSL o rutas del proyecto para mitigar problemas de dependencias. +JQ_CMD="jq" +if ! command -v jq &>/dev/null; then + if [ -f "/home/yesid_farfan/jq" ]; then + JQ_CMD="/home/yesid_farfan/jq" + elif [ -f "$HOME/jq" ]; then + JQ_CMD="$HOME/jq" + elif [ -f "./jq" ]; then + JQ_CMD="./jq" + elif [ -f "./scripts/jq" ]; then + JQ_CMD="./scripts/jq" + else + echo "❌ Error: Se requiere 'jq' instalado en el sistema para procesar respuestas JSON." + exit 1 + fi +fi + +# Solicitar dominio e IP al usuario interactivamente si no están configurados +if [ -z "$DOMAIN" ]; then + read -rp "Introduce el dominio principal (ej. omnioil.app): " DOMAIN +fi + +if [ -z "$SERVER_IP" ]; then + read -rp "Introduce la IP pública del servidor VPS: " SERVER_IP +fi + +# Cancelar si faltan los datos mínimos obligatorios +if [ -z "$DOMAIN" ] || [ -z "$SERVER_IP" ]; then + echo "❌ Error: El dominio y la IP del servidor son campos obligatorios." + exit 1 +fi + +# Asignar la URL de la API de Dokploy por defecto a partir de la IP del VPS si no se provee +if [ -z "$DOKPLOY_API_URL" ]; then + DOKPLOY_API_URL="http://$SERVER_IP:3000/api" +fi + +# Evaluar credenciales para determinar qué plataformas se van a configurar +RUN_CLOUDFLARE=true +if [ -z "$CF_TOKEN" ] || [ -z "$CF_ZONE_ID" ]; then + echo "⚠️ Advertencia: Falta CF_TOKEN o CF_ZONE_ID. Se omitirá Cloudflare." + RUN_CLOUDFLARE=false +fi + +RUN_DOKPLOY=true +if [ -z "$DOKPLOY_API_KEY" ]; then + echo "⚠️ Advertencia: Falta DOKPLOY_API_KEY. Se omitirá Dokploy." + RUN_DOKPLOY=false +fi + +# Si no hay credenciales para ninguna plataforma, salimos +if [ "$RUN_CLOUDFLARE" = false ] && [ "$RUN_DOKPLOY" = false ]; then + echo "❌ Error: No hay credenciales configuradas para Cloudflare ni para Dokploy." + exit 1 +fi + +# ----------------------------------------------------------------------------- +# 5. Mapeo de Subdominios a Contenedores Docker Compose +# ----------------------------------------------------------------------------- +# Formato: "subdominio:nombre_servicio_docker:puerto_interno" +# Este mapeo se deriva del documento docs/DESPLIEGUE.md. +MAPPINGS=( + "@:landing:80" # omnioil.app -> contenedor landing puerto 80 + "www:landing:80" # www.omnioil.app -> contenedor landing puerto 80 + "app:frontend-admin:80" # app.omnioil.app -> contenedor frontend-admin puerto 80 + "console:frontend-console:80" # console.omnioil.app -> contenedor frontend-console puerto 80 + "mobile:frontend-pwa:80" # mobile.omnioil.app -> contenedor frontend-pwa puerto 80 + "api:backend-api:8000" # api.omnioil.app -> contenedor backend-api puerto 8000 + "storage:minio:9001" # storage.omnioil.app -> consola de administración de minio puerto 9001 + "mqtt:mosquitto:9001" # mqtt.omnioil.app -> broker websocket mosquitto puerto 9001 +) + +# ----------------------------------------------------------------------------- +# 6. Funciones de Integración con la API de Cloudflare (DNS) +# ----------------------------------------------------------------------------- + +# Crea o actualiza un registro DNS tipo A en Cloudflare +setup_cloudflare_dns() { + local name="$1" content="$2" type="${3:-A}" proxied="${4:-true}" + local record_name + + # Manejar el dominio raíz (@) versus subdominios + if [ "$name" = "@" ]; then + record_name="$DOMAIN" + else + record_name="$name.$DOMAIN" + fi + + echo " → Configurando DNS para $record_name -> $content" + + # Buscar si el registro ya existe en el Zone ID + local search_res + search_res=$(curl -s -X GET "$CLOUDFLARE_API/zones/$CF_ZONE_ID/dns_records?name=$record_name&type=$type" \ + -H "Authorization: Bearer $CF_TOKEN" \ + -H "Content-Type: application/json") + + # Validar que la respuesta sea un JSON de éxito + if ! echo "$search_res" | "$JQ_CMD" -e '.success' | grep -q "true"; then + local err + err=$(echo "$search_res" | "$JQ_CMD" -r '.errors[0].message // "Error en autenticación/red"') + echo " ❌ Falló la consulta a Cloudflare: $err" + return 1 + fi + + # Extraer el record_id si existe + local record_id + record_id=$(echo "$search_res" | "$JQ_CMD" -r '.result[0].id // empty') + + # Generar el payload JSON de forma segura + local payload + payload=$("$JQ_CMD" -n \ + --arg type "$type" \ + --arg name "$record_name" \ + --arg content "$content" \ + --argjson proxied "$proxied" \ + '{type: $type, name: $name, content: $content, proxied: $proxied, ttl: 1}') + + local response + if [ -n "$record_id" ]; then + # Registro existente: Se actualiza con PUT + response=$(curl -s -X PUT "$CLOUDFLARE_API/zones/$CF_ZONE_ID/dns_records/$record_id" \ + -H "Authorization: Bearer $CF_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$payload") + else + # Registro nuevo: Se crea con POST + response=$(curl -s -X POST "$CLOUDFLARE_API/zones/$CF_ZONE_ID/dns_records" \ + -H "Authorization: Bearer $CF_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$payload") + fi + + # Verificar resultado de la operación + if echo "$response" | "$JQ_CMD" -e '.success' | grep -q "true"; then + echo " ✅ DNS $record_name configurado con éxito." + else + local err + err=$(echo "$response" | "$JQ_CMD" -r '.errors[0].message // "Error desconocido"') + echo " ❌ Falló la creación/actualización de DNS $record_name: $err" + fi +} + +# ----------------------------------------------------------------------------- +# 7. Funciones de Integración con la API de Dokploy (Enrutamiento) +# ----------------------------------------------------------------------------- + +# Crea el enrutamiento de un dominio en Dokploy vinculándolo a un servicio del Docker Compose +create_dokploy_domain() { + local host="$1" service="$2" port="$3" compose_id="$4" + echo " → Registrando en Dokploy: $host -> servicio '$service' (puerto $port)" + + # payload JSON para enrutar el tráfico al contenedor mediante Traefik. + # Se habilita SSL Let's Encrypt de forma automática (https: true, certificateType: letsencrypt) + local payload + payload=$("$JQ_CMD" -n \ + --arg host "$host" \ + --arg composeId "$compose_id" \ + --arg serviceName "$service" \ + --argjson port "$port" \ + '{host: $host, composeId: $composeId, serviceName: $serviceName, port: $port, https: true, certificateType: "letsencrypt", path: "/"}') + + local response + response=$(curl -s -X POST "$DOKPLOY_API_URL/domain.create" \ + -H "x-api-key: $DOKPLOY_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$payload") + + # Evaluar respuestas de error + if echo "$response" | "$JQ_CMD" -e '.error // .message // empty' > /dev/null; then + local error_msg + error_msg=$(echo "$response" | "$JQ_CMD" -r '.message // .error') + # Si el dominio ya existe, el script es tolerante y no falla, simplemente salta al siguiente + if [[ "$error_msg" == *"already exists"* || "$error_msg" == *"Duplicate"* ]]; then + echo " ⚠️ El dominio $host ya está registrado en Dokploy. Saltando..." + else + echo " ❌ Error al registrar dominio $host en Dokploy: $error_msg" + fi + else + echo " ✅ Dominio $host enrutado con éxito." + fi +} + +# Envía una señal de re-despliegue (redeploy) al Docker Compose en Dokploy. +# Esto es mandatorio porque Dokploy usa Traefik con enrutamiento por etiquetas (labels). +# Al añadir dominios a Compose, las etiquetas cambian y los contenedores deben recrearse. +redeploy_dokploy_compose() { + local compose_id="$1" + echo "=== Solicitando Re-despliegue de la pila en Dokploy ===" + local response + response=$(curl -s -X POST "$DOKPLOY_API_URL/compose.redeploy" \ + -H "x-api-key: $DOKPLOY_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"composeId\":\"$compose_id\"}") + + if echo "$response" | "$JQ_CMD" -e '.error // .message // empty' > /dev/null; then + local error_msg + error_msg=$(echo "$response" | "$JQ_CMD" -r '.message // .error') + echo " ❌ Error al solicitar re-despliegue: $error_msg" + else + echo " ✅ Re-despliegue iniciado con éxito en Dokploy." + echo " Traefik configurará SSL Let's Encrypt automáticamente una vez estén listos los servicios." + fi +} + +# ============================================================================= +# 8. Ejecución del Script (Flujo de Control) +# ============================================================================= + +echo "=========================================================" +echo " AUTOMATIZACIÓN DE DOMINIOS Y ENRUTAMIENTO" +echo " Dominio: $DOMAIN" +echo " IP Servidor: $SERVER_IP" +if [ "$DRY_RUN" = true ]; then + echo " ⚠️ MODO SIMULACIÓN ACTIVO (No se harán cambios reales)" +fi +echo "=========================================================" + +# ----------------------------------------------------------------------------- +# Paso 1: Configurar DNS en Cloudflare +# ----------------------------------------------------------------------------- +if [ "$RUN_CLOUDFLARE" = true ]; then + echo "" + echo "=== Paso 1: Configurando registros DNS en Cloudflare ===" + for mapping in "${MAPPINGS[@]}"; do + # Separar el mapeo en variables + IFS=":" read -r sub service port <<< "$mapping" + + record_name="" + if [ "$sub" = "@" ]; then record_name="$DOMAIN"; else record_name="$sub.$DOMAIN"; fi + + if [ "$DRY_RUN" = true ]; then + echo " [SIMULACIÓN] Crear/Actualizar A: $record_name -> $SERVER_IP (proxied=true)" + else + setup_cloudflare_dns "$sub" "$SERVER_IP" "A" true + fi + done +fi + +# ----------------------------------------------------------------------------- +# Paso 2: Resolución de recursos e IDs en Dokploy +# ----------------------------------------------------------------------------- +if [ "$RUN_DOKPLOY" = true ]; then + echo "" + echo "=== Paso 2: Conectando con Dokploy y buscando recursos ===" + + compose_id="dummy-compose-id" + + if [ "$DRY_RUN" = false ]; then + # 2a. Obtener la lista de todos los proyectos de la instancia + projects_json=$(curl -s -f -X GET "$DOKPLOY_API_URL/project.all" -H "x-api-key: $DOKPLOY_API_KEY") + if [ $? -ne 0 ] || [ -z "$projects_json" ]; then + echo "❌ Error: No se pudo conectar a Dokploy en $DOKPLOY_API_URL o la API key es inválida." + exit 1 + fi + + # 2b. Filtrar para hallar el projectId que coincida con PROJECT_NAME + project_id=$(echo "$projects_json" | "$JQ_CMD" -r --arg name "$PROJECT_NAME" '.[] | select(.name == $name) | .projectId // empty') + + if [ -z "$project_id" ]; then + echo "❌ Error: El proyecto '$PROJECT_NAME' no fue encontrado en Dokploy." + echo "Proyectos detectados en Dokploy:" + echo "$projects_json" | "$JQ_CMD" -r '.[].name' + exit 1 + fi + echo " ✅ Proyecto '$PROJECT_NAME' encontrado. (ID: $project_id)" + + # 2c. Obtener los detalles del proyecto para buscar el recurso Compose + project_details=$(curl -s -f -X GET "$DOKPLOY_API_URL/project.one?projectId=$project_id" -H "x-api-key: $DOKPLOY_API_KEY") + + # 2d. Filtrar para hallar el composeId o id del recurso Compose + compose_id=$(echo "$project_details" | "$JQ_CMD" -r --arg name "$COMPOSE_NAME" '.compose[] | select(.name == $name) | .composeId // .id // empty') + + if [ -z "$compose_id" ]; then + echo "❌ Error: El recurso Docker Compose '$COMPOSE_NAME' no fue encontrado en el proyecto '$PROJECT_NAME'." + echo "Compose apps detectadas:" + echo "$project_details" | "$JQ_CMD" -r '.compose[].name // "Ninguno"' + exit 1 + fi + echo " ✅ Docker Compose '$COMPOSE_NAME' encontrado. (ID: $compose_id)" + else + echo " [SIMULACIÓN] Buscado proyecto '$PROJECT_NAME' y Compose '$COMPOSE_NAME' (IDs simulados)." + fi + + # ----------------------------------------------------------------------------- + # Paso 3: Registrar dominios en la API de Dokploy + # ----------------------------------------------------------------------------- + echo "" + echo "=== Paso 3: Registrando dominios en Dokploy ===" + for mapping in "${MAPPINGS[@]}"; do + # Separar mapeo + IFS=":" read -r sub service port <<< "$mapping" + + host="" + if [ "$sub" = "@" ]; then + host="$DOMAIN" + else + host="$sub.$DOMAIN" + fi + + if [ "$DRY_RUN" = true ]; then + echo " [SIMULACIÓN] Registrar dominio: $host -> servicio '$service' (puerto $port)" + else + create_dokploy_domain "$host" "$service" "$port" "$compose_id" + fi + done + + # ----------------------------------------------------------------------------- + # Paso 4: Re-despliegue de la pila para aplicar cambios en Traefik + # ----------------------------------------------------------------------------- + if [ "$SKIP_REDEPLOY" = false ]; then + echo "" + if [ "$DRY_RUN" = true ]; then + echo " [SIMULACIÓN] Re-desplegar compose con ID $compose_id" + else + redeploy_dokploy_compose "$compose_id" + fi + fi +fi + +echo "" +echo "=========================================================" +echo " 🎉 PROCESO FINALIZADO CON ÉXITO" +echo "========================================================="