#!/usr/bin/env bash # ============================================================ # Sub-Fase 3.16 — install.sh — Bootstrap fresco de un Panel # EncryptionTV en un VPS nuevo desde cero. # # 🔴 v4.35.0 — INSTALA POR IMÁGENES. Ya NO se clona ningún repositorio # ni se compila nada en el VPS: el panel se descarga del registry de # EncryptionTV y los ficheros de despliegue (compose, plantilla de # Caddy, auto-update.sh) se extraen de la propia imagen. El admin no # recibe código fuente. # # 🔴 v4.36.0 — BASTA CON UN UBUNTU LIMPIO. El instalador ya no exige # que Docker esté puesto: lo INSTALA desde el repositorio oficial, igual # que curl, jq y openssl si faltaran. Y comprueba el DNS ANTES de tocar # nada, que es lo que más instalaciones rompía. # # 🔴 v4.38.0 — ESCRITO PARA QUIEN NUNCA HA USADO UN TERMINAL. # El que instala esto compra un panel de IPTV; no es administrador de # sistemas. Sabe usar un navegador y no tiene por qué saber qué es un # registro DNS. Por eso, aquí, un mensaje que solo entienda quien ya # sabe lo que pasa es un mensaje MAL ESCRITO. # # Tres cambios de fondo en esta versión: # · Se PREGUNTA la cuenta (correo + clave), no se exige exportarla # antes. `export … |# -en: https://central.encryptiontv.com/install # # Uso: # curl -fsSL https://central.encryptiontv.com/install | sudo bash` NO funcionaba: sudo limpia el # entorno, así que las variables no llegaban nunca y el instalador # acababa pidiendo una clave de licencia que el cliente no tiene. # · Se dejan de preguntar los datos que se pueden DEDUCIR (el correo # de contacto y la dirección del central). Cada pregunta menos es # un sitio menos donde equivocarse. # · Al terminar se dice DÓNDE entrar y CON QUÉ, que es lo único que # el admin necesita saber para seguir desde el navegador. # # Los 7 pasos: # 1. Revisar el servidor: permisos, Ubuntu soportado (22.04/24.04), # arquitectura x86_64 y memoria. Instala curl/jq/openssl y DOCKER # si faltan, y comprueba que Docker ejecuta un contenedor de # verdad. Prepara /opt/encryptiontv (ruta FIJA); si ya había una # instalación, la reutiliza sin tocar sus secretos. # 2. 🔴 EL DOMINIO, ANTES DE TOCAR NADA: que los dos nombres del # panel resuelvan y apunten A ESTA máquina, y que los puertos # 80/443 estén libres. Si algo falla, aborta con el servidor # INTACTO y una guía de qué crear, dónde y con qué valor. # Es el bloque que más instalaciones salva: sin él, un DNS mal # puesto llegaba hasta el final, dejaba el VPS montado, el panel # inaccesible, y encima quemaba la cuota de Let's Encrypt. # 3. La cuenta de EncryptionTV (correo + clave del documento de # bienvenida). El central emite la licencia firmada para este # dominio y devuelve, con ella, la credencial del registry y el # hash de la contraseña con la que el admin entrará al panel. # 4. Descargar el panel: login + `pull` ANTES de escribir nada, para # que una credencial mala aborte con el sistema intacto. De la # propia imagen salen el compose, la plantilla de Caddy y # auto-update.sh (el admin no recibe código fuente). # 5. Preparar la configuración: secretos aleatorios de 32 caracteres # (contraseña de la base de datos, COOKIE_SECRET, CSRF_SECRET, # passphrase de WireGuard, token panel↔central) y el Caddyfile, # validado con el propio Caddy ANTES de instalarlo. # 6. Arrancar: `docker compose up -d`, esperar a que responda, # guardar la licencia firmada, sembrar el usuario administrador y # activar la comprobación de licencia. # 7. Comprobar que el panel se ve DESDE INTERNET (certificado # incluido) y decir cómo entrar. # # Idempotente: si el .env ya existe, no se regeneran los secretos (se # perdería la contraseña de la base de datos). Volver a lanzarlo tras # un fallo continúa donde estaba, sin duplicar nada. # # Uso: # curl -fsSL https://central.encryptiontv.com/install | sudo bash # sudo bash install.sh --dry-run (ensayo completo, no toca nada) # sudo bash install.sh --staging (certificado de PRUEBAS: no quema # la cuota de Let's Encrypt) # sudo bash install.sh --force (empezar de cero: BORRA datos) # # Permisos: requiere sudo (instala programas + escribe en /etc). # # 🔴 ESTE FICHERO SE SIRVE FUERA DE LA IMAGEN. No viaja con el panel: lo # sirve el central desde una COPIA MANUAL, así que mergear no lo # actualiza. La marca de abajo hace que `scripts/coherencia.sh` lo # encuentre solo y avise si lo servido se ha quedado atrás — que es lo # que pasó durante MESES sin que nadie lo notara. # # @servido-en: https://central.encryptiontv.com/install # ============================================================ set -euo pipefail # ── Config / defaults ────────────────────────────────────── # v4.35.0 — INSTALACIÓN POR IMÁGENES. Ya no se clona ni se extrae el # repositorio: el panel se descarga del registry de EncryptionTV y los # ficheros de despliegue salen de la propia imagen. # # El directorio es FIJO y no depende del usuario. Antes se instalaba en # el home del admin mientras el compose montaba `/home/jon/...` escrito # a fuego, así que el auto-update solo funcionaba si el usuario se # llamaba jon. Una ruta fija no tiene ese problema. INSTALL_USER="${SUDO_USER:-${USER:-$(id -un)}}" INSTALL_DIR="${ETV_INSTALL_DIR:-/opt/encryptiontv}" # Compatibilidad hacia dentro: el resto del script y auto-update.sh # siguen hablando de REPO_DIR. Es el mismo sitio. REPO_DIR="$INSTALL_DIR" REGISTRY_HOST="${ETV_REGISTRY_HOST:-registry.encryptiontv.com}" IMAGEN_PANEL_API="${REGISTRY_HOST}/encryptiontv/panel-api" IMAGEN_PANEL_WEB="${REGISTRY_HOST}/encryptiontv/panel-web" IMAGEN_TAG="${ETV_IMAGE_TAG:-latest}" DRY_RUN="false" # v4.36.0 — entorno de PRUEBAS de Let's Encrypt. Sus certificados no # valen para un navegador, pero su límite es enorme: sirve para ensayar # una instalación entera sin quemar la cuota del dominio real. STAGING="false" # v4.36.0 — empezar de cero: borra datos. Pide confirmación explícita. FORCE="false" # v4.42.0 — quitar el panel de este servidor y dejarlo limpio. Hasta # ahora no había ninguna vía: quien quería empezar de cero o mudarse de # máquina borraba a mano y dejaba medio servidor sucio. UNINSTALL="false" # v4.40.0 — los datos del alta, por bandera. Prioridad: bandera > variable # de entorno > preguntar. Sirven para instalar sin que nadie escriba nada # (pruebas, reinstalaciones repetidas). # # ⚠️ Una credencial en la línea de comandos queda en el historial del # shell y la ve cualquiera que haga `ps` mientras corre. Para una # instalación de verdad es mejor dejar que pregunte, o exportarla. La # bandera existe para automatizar, no porque sea la vía recomendada. ARG_PANEL_URL="" ARG_EMAIL="" ARG_CREDENTIAL="" ARG_LICENSE_KEY="" # Distribuciones donde el repositorio oficial de Docker tiene paquetes y # donde esto se ha pensado para funcionar. UBUNTU_SOPORTADAS="22.04 24.04" # Defaults para prompts (override por env). DEFAULT_CENTRAL_URL="${ETV_CENTRAL_URL:-https://central.encryptiontv.com}" # Parse args. while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY_RUN="true"; shift ;; --staging) STAGING="true"; shift ;; --force) FORCE="true"; shift ;; --uninstall) UNINSTALL="true"; shift ;; --install-dir) INSTALL_DIR="$2"; REPO_DIR="$2"; shift 2 ;; --image-tag) IMAGEN_TAG="$2"; shift 2 ;; --domain) ARG_PANEL_URL="$2"; shift 2 ;; --email) ARG_EMAIL="$2"; shift 2 ;; --credential) ARG_CREDENTIAL="$2"; shift 2 ;; --license-key) ARG_LICENSE_KEY="$2"; shift 2 ;; -h|--help) # Ayuda ESCRITA, no un volcado de la cabecera del fichero: con # `curl … | bash` el fichero no existe y `head "$0"` no imprimía # nada. Además la cabecera está llena de notas para quien # mantiene el instalador, no para quien lo usa. echo "Instalador del panel EncryptionTV" echo "" echo " Se instala con un solo comando, como administrador:" echo "" echo " curl -fsSL https://central.encryptiontv.com/install | sudo bash" echo "" echo " Te preguntará tres cosas, todas en tu documento de bienvenida:" echo " · el dominio de tu panel" echo " · tu correo de EncryptionTV" echo " · tu clave de EncryptionTV" echo "" echo " Antes de empezar necesitas:" echo " · un servidor con Ubuntu 22.04 o 24.04 y al menos 2 GB de memoria" echo " · tu dominio apuntando a este servidor (te decimos cómo si falta)" echo "" echo " Puedes dárselas hechas y no te preguntará nada:" echo " --domain https://panel.tudominio.com" echo " --email tu@correo.com" echo " --credential ETVA-...." echo " o exportando ETV_PANEL_URL, ETV_ACCOUNT_EMAIL y" echo " ETV_ACCOUNT_CREDENTIAL (con sudo hace falta 'sudo -E'," echo " o sudo se las descarta por el camino)." echo "" echo " Opciones (no las necesitas en una instalación normal):" echo " --dry-run ensayo: enseña lo que haría, sin tocar nada" echo " --staging certificado de PRUEBAS (no vale para el navegador," echo " pero no gasta tu cupo de certificados)" echo " --force empezar de cero: BORRA la base de datos" echo " --uninstall quitar el panel de este servidor y dejarlo limpio" echo " --install-dir RUTA --image-tag VERSION" exit 0 ;; *) shift ;; esac done # ── helpers ─────────────────────────────────────────────── log() { echo "[$(date -u +%H:%M:%S)Z] $*"; } warn() { echo "[$(date -u +%H:%M:%S)Z] WARN: $*" >&2; } # 🔴 v4.36.0 — la pausa NO es decorativa. La salida va por # `exec 2> >(tee …)`, y al salir del script bash mata ese subproceso # ANTES de que vacíe su buffer: el mensaje de error se perdía entero, # ni en pantalla ni en el log. Comprobado en un Ubuntu limpio: el # instalador se paraba sin decir absolutamente nada, que es la peor # forma posible de fallar. Darle un instante a `tee` lo arregla. fail() { echo "" >&2 echo " ✗ $*" >&2 echo "" >&2 # 🔴 Que TODO fallo diga que se puede reintentar. Alguien que no ha # usado nunca un terminal, ante un error, da por hecho que ha roto # algo y que hay que empezar de cero (o llamar a alguien). Ni una cosa # ni la otra: el instalador es idempotente y continúa donde estaba. # Decirlo en cada fallo cuesta tres líneas y evita esa llamada. echo " ────────────────────────────────────────────────────────────" >&2 echo " Cuando lo hayas resuelto, vuelve a ejecutar el mismo comando:" >&2 echo "" >&2 echo " ${COMO_LANZARME:-curl -fsSL https://central.encryptiontv.com/install | sudo bash}" >&2 echo "" >&2 echo " No perderás lo que ya se haya hecho: el instalador continúa" >&2 echo " donde se quedó y no duplica nada." >&2 echo "" >&2 sleep 0.3 exit 1 } # ── El hilo conductor de la instalación ─────────────────────────────── # Antes cada paso era una línea de log con marca de tiempo y nombres # internos ("9/10 Persistir licencia en portal_config"). Para quien mira # esto por primera vez, eso es ruido indistinguible de un error. Un # encabezado por paso, en castellano y con el número de paso, convierte # una pared de texto en algo que se puede seguir. PASOS_TOTALES=7 paso() { echo "" echo "════════════════════════════════════════════════════════════" echo " Paso $1 de $PASOS_TOTALES · $2" echo "════════════════════════════════════════════════════════════" } # Un aviso ANTES de los pasos que tardan. Sin esto, dos minutos sin ver # nada moverse se leen como "se ha colgado" — y quien lo cree, corta. tardara() { echo " ⏳ $* Puede tardar un rato; no cierres esta ventana."; } run() { if [ "$DRY_RUN" = "true" ]; then echo " [DRY] $*" else "$@" fi } # 🔴 v4.36.1 — PREGUNTAR CUANDO SE EJECUTA CON `curl … | bash`. # # Con ese patrón, la entrada estándar del script ES EL PROPIO SCRIPT. # Un `read` normal no lee del teclado: se come la SIGUIENTE LÍNEA DEL # FICHERO como si fuera la respuesta del usuario — y encima esa línea # desaparece del flujo, así que bash tampoco la ejecuta. # # No es teórico: al probarlo, `panel_url` acabó valiendo literalmente # [[ "$PANEL_URL" =~ ^https://[a-zA-Z0-9.-]+(:[0-9]+)?$ ]] || \ # que es la línea siguiente al prompt. # # La solución es leer del TERMINAL (/dev/tty) en vez de la entrada # estándar. Así el one-liner funciona pelado y el script sigue pudiendo # preguntar, que es lo que queremos: mejor preguntar que exigir flags. TTY_DISPONIBLE="false" [ -r /dev/tty ] && [ -t 1 ] && TTY_DISPONIBLE="true" # Cómo decirle al admin que vuelva a lanzarnos. Con `curl … | bash` el # script no existe como fichero y `$0` vale literalmente "bash", así que # un mensaje que dijera "ejecuta sudo bash $0" saldría como "sudo bash # bash". Se detecta y se ofrece el comando que de verdad funciona. if [ -f "$0" ] && [ "$0" != "bash" ] && [ "$0" != "-bash" ]; then COMO_LANZARME="sudo bash $0" else COMO_LANZARME="curl -fsSL https://central.encryptiontv.com/install | sudo bash" fi preguntar() { # preguntar [-s] # # 🔴 Las locales van con prefijo `__pv_` por algo concreto: bash tiene # ámbito DINÁMICO, así que si quien llama pide guardar el resultado en # una variable que aquí dentro es `local`, el `printf -v` escribe en LA # LOCAL y el llamante se queda con la suya sin asignar. Con `set -u` # eso revienta con «unbound variable» en un sitio que no tiene nada que # ver. Pasó al escribir preguntar_hasta, que usaba `valor`. local __pv_msg="$1" __pv_dst="$2" __pv_sil="${3:-}" __pv_val="" if [ "$TTY_DISPONIBLE" = "true" ]; then if [ "$__pv_sil" = "-s" ]; then read -r -s -p "$__pv_msg" __pv_val < /dev/tty; echo else read -r -p "$__pv_msg" __pv_val < /dev/tty fi elif [ -t 0 ]; then # Sin /dev/tty pero con entrada interactiva (ejecución normal). read -r -p "$__pv_msg" __pv_val else fail "El instalador necesita preguntarte algunos datos y no encuentra un terminal donde hacerlo. Suele pasar al ejecutarlo dentro de un script, por cron o con la entrada redirigida. Lánzalo desde una sesión SSH normal: curl -fsSL https://central.encryptiontv.com/install | sudo bash Si necesitas ejecutarlo sin interacción, pásale los datos por variables de entorno: ETV_PANEL_URL, ETV_ACCOUNT_EMAIL, ETV_ACCOUNT_CREDENTIAL" fi printf -v "$__pv_dst" '%s' "$__pv_val" } # ── 🔴 v4.41.0 — DECIR QUE SÍ NO PUEDE SER UN EXAMEN ────────────────── # # Antes, la confirmación exigía una respuesta exacta y CANCELABA LA # INSTALACIÓN ENTERA con cualquier otra cosa. Alguien que escribe «s», # «si», «yes» o pulsa Enter está diciendo que sí, y perder por eso diez # minutos de trabajo es castigar un tecleo. # # Se aceptan todas las variantes razonables. El truco para las tildes: # se dejan SOLO las letras ASCII antes de comparar, así «sí» y «SÍ» caen # los dos en «s» sin pelearse con UTF-8 (que es donde `tr` se rompe). normalizar_respuesta() { # shellcheck disable=SC2018,SC2019 # A propósito 'a-zA-Z' y no '[:lower:]/[:upper:]': lo que se busca aquí # es justo lo contrario de soportar acentos — se DESCARTAN los bytes no # ASCII para que «sí» y «SÍ» acaben los dos en «s» sin depender de la # configuración regional del servidor, que es donde esto se rompe. printf '%s' "$1" | tr -cd 'a-zA-Z' | tr 'A-Z' 'a-z' } # confirmar [defecto: si|no] # Devuelve 0 = sí, 1 = no. Si no entiende la respuesta REPREGUNTA: nunca # aborta. El tope de intentos existe solo para no girar en el vacío si la # entrada se ha agotado (ejecución automatizada), no para castigar a # nadie. confirmar() { local mensaje="$1" defecto="${2:-si}" valor r for _ in 1 2 3 4 5; do preguntar "$mensaje" valor r="$(normalizar_respuesta "$valor")" if [ -z "$r" ]; then [ "$defecto" = "si" ] && return 0 || return 1 fi case "$r" in s|si|sii|sip|y|ye|yes|yep|yeah|ok|okay|oki|vale|dale|claro|correcto|adelante|continuar|seguir) return 0 ;; n|no|nop|nope|not|cancelar|cancela|salir|parar|stop|abortar) return 1 ;; esac echo " No he entendido «$valor». Escribe SÍ o NO (o pulsa Enter para" >&2 if [ "$defecto" = "si" ]; then echo " seguir, que es lo normal)." >&2 else echo " no hacerlo)." >&2 fi done # Cinco veces sin entenderse: se toma el camino seguro y se dice. echo " No consigo entender la respuesta. Tomo la opción más prudente." >&2 [ "$defecto" = "si" ] && return 0 || return 1 } # preguntar_hasta # Pregunta hasta que el validador diga que sí. Un dominio mal tecleado o # un correo con un espacio de más NO pueden tirar abajo la instalación: # se vuelve a preguntar, que es lo que haría cualquier persona. preguntar_hasta() { local mensaje="$1" destino="$2" validador="$3" ayuda="$4" valor for _ in 1 2 3 4 5; do preguntar "$mensaje" valor if "$validador" "$valor"; then printf -v "$destino" '%s' "$valor" return 0 fi echo "" >&2 echo "$ayuda" >&2 echo "" >&2 done fail "Después de varios intentos el dato sigue sin ser válido. Míralo con calma en tu documento de bienvenida y vuelve a lanzarme cuando lo tengas. No se ha instalado nada." } # Sub-Fase 3.16 (deuda detectada smoke 1) — tee stdout/stderr a # /var/log/encryptiontv-install.log para post-mortem si la # sesión SSH cae mid-run (build docker pesado + 4GB RAM tight # en VPS S+ puede OOM-mata sshd antes de finalizar). Si no se # puede escribir el log (no root), seguimos sin tee. INSTALL_LOG="/var/log/encryptiontv-install.log" # 🔴 fd 3 = LA PANTALLA DE VERDAD, sin pasar por el registro. # # Hace falta para una cosa concreta: al terminar hay que enseñar la # credencial del admin, y todo lo que sale por la salida estándar se # copia al fichero de registro. Escribir ahí su credencial —que además # es la llave de recuperación de acceso— la dejaría en claro y para # siempre en un fichero del servidor. Se asigna SIEMPRE, aunque el # registro no llegue a montarse, para que `>&3` nunca quede colgando. exec 3>&1 if touch "$INSTALL_LOG" 2>/dev/null; then # 600, no 644: aquí dentro quedan el dominio, el correo y la clave de # licencia. No es catastrófico, pero no tiene por qué leerlo cualquier # usuario del servidor. chmod 600 "$INSTALL_LOG" 2>/dev/null || true exec > >(tee -a "$INSTALL_LOG") exec 2> >(tee -a "$INSTALL_LOG" >&2) log "==== install.sh start — log también en $INSTALL_LOG ====" else warn "No se pudo escribir $INSTALL_LOG — sigo sin tee; output se perdería si SSH cae." fi random_token() { # 32 chars base64url sin padding. openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' } # ── 0. DESINSTALAR ─────────────────────────────────────────────────── # Va ANTES que todo lo demás: desinstalar no necesita comprobar la # versión de Ubuntu, ni la memoria, ni instalar nada. Solo permisos. if [ "$UNINSTALL" = "true" ]; then if [ "$(id -u)" -ne 0 ]; then fail "Para desinstalar hacen falta permisos de administrador. Añade \"sudo\" al comando." fi echo "" echo "════════════════════════════════════════════════════════════" echo " DESINSTALAR EL PANEL ENCRYPTIONTV" echo "════════════════════════════════════════════════════════════" echo "" # 🔴 EL AVISO DE LA LICENCIA VA PRIMERO, ANTES DE BORRAR NADA. # # Borrar el panel de este servidor NO libera la licencia: sigue atada a # este dominio en nuestro sistema. Quien lea esto DESPUÉS de borrar ya # no puede decidir nada — por eso va aquí, donde todavía se está a # tiempo de parar. echo " ⚠️ ANTES DE SEGUIR, SOBRE TU LICENCIA" echo "" echo " Borrar el panel de este servidor NO libera tu licencia: en" echo " nuestro sistema seguirá atada a tu dominio." echo "" echo " Eso no es un problema, pero conviene que lo sepas: cuando" echo " instales en otra máquina, el panel te dirá que tu licencia ya" echo " está en uso y tendrás que confirmar el traslado desde su propia" echo " web (Configuración → Facturación y licencia). Es un clic, pero" echo " si no lo esperas parece que algo ha ido mal." echo "" echo " Si lo que quieres es MUDAR el panel a otro servidor, monta" echo " primero el nuevo y confirma allí el traslado: así no te quedas" echo " sin nada por el camino." echo "" echo " ────────────────────────────────────────────────────────────" echo "" echo " SE VA A BORRAR DE ESTE SERVIDOR, SIN VUELTA ATRÁS:" echo "" echo " · LA BASE DE DATOS DEL PANEL — tus clientes, sus códigos, sus" echo " dispositivos y todo su historial. Esto es lo único que no se" echo " puede recuperar de ninguna manera." echo " · La configuración de $INSTALL_DIR, con sus contraseñas." echo " · Los certificados de seguridad ya emitidos." echo " · Los contenedores del panel y sus imágenes descargadas." echo "" echo " NO se toca: Docker (lo puedes estar usando para otra cosa) ni" echo " ningún otro programa del servidor." echo "" if [ "$DRY_RUN" = "true" ]; then echo " [DRY] aquí pediría escribir BORRAR y luego borraría todo eso." exit 0 fi CONFIRMA_DESINSTALAR="" for _ in 1 2 3; do preguntar " Escribe BORRAR para confirmar (o Enter para dejarlo): " CONFIRMA_DESINSTALAR case "$(normalizar_respuesta "$CONFIRMA_DESINSTALAR")" in borrar) CONFIRMA_DESINSTALAR="BORRAR"; break ;; "") CONFIRMA_DESINSTALAR=""; break ;; *) echo " Para borrarlo todo tienes que escribir la palabra BORRAR." >&2 CONFIRMA_DESINSTALAR="" ;; esac done if [ "$CONFIRMA_DESINSTALAR" != "BORRAR" ]; then echo "" echo " Cancelado. No se ha tocado nada." exit 0 fi echo "" log "Parando y borrando el panel…" if command -v docker >/dev/null 2>&1; then if [ -f "$INSTALL_DIR/infra/docker-compose.prod.yml" ]; then docker compose -f "$INSTALL_DIR/infra/docker-compose.prod.yml" down -v --remove-orphans >/dev/null 2>&1 || true fi # Por si el compose ya no estuviera: por nombre. docker rm -f encryptiontv-panel-api encryptiontv-panel-web \ encryptiontv-postgres encryptiontv-caddy >/dev/null 2>&1 || true docker volume rm encryptiontv-postgres-data encryptiontv-panel-web-dist \ encryptiontv-caddy-data encryptiontv-caddy-config >/dev/null 2>&1 || true docker rmi encryptiontv/panel-api:latest encryptiontv/panel-web:latest >/dev/null 2>&1 || true log " OK contenedores, volúmenes e imágenes del panel eliminados" else log " (Docker no está instalado: no había contenedores que borrar)" fi rm -rf "$INSTALL_DIR" log " OK $INSTALL_DIR eliminado" # 🔴 EL SWAP SE PREGUNTA APARTE. Lo creó este instalador, pero para # cuando alguien desinstale puede que lo esté usando el servidor entero # para otras cosas. Quitarlo sin preguntar sería llevarse por delante # algo que ya no es solo nuestro. if [ -f /swapfile ]; then echo "" echo " Este servidor tiene 4 GB de memoria de respaldo (/swapfile) que" echo " creó el instalador. Puede que ahora la esté usando el servidor" echo " para otras cosas, así que no la toco sin preguntar." echo "" if confirmar " ¿La quito también? [NO]: " no; then swapoff /swapfile 2>/dev/null || true sed -i '\|^/swapfile |d' /etc/fstab 2>/dev/null || true rm -f /swapfile log " OK memoria de respaldo eliminada" else log " OK la memoria de respaldo se queda donde está" fi fi echo "" echo "════════════════════════════════════════════════════════════" echo " ✅ PANEL DESINSTALADO" echo "════════════════════════════════════════════════════════════" echo "" echo " Este servidor ya no tiene nada del panel." echo "" echo " Recuerda: tu licencia sigue viva y atada a tu dominio. Para" echo " usarla en otra máquina, instala allí y confirma el traslado" echo " desde la web del panel nuevo." echo "" echo " Para volver a instalar aquí mismo:" echo " $COMO_LANZARME" echo "" exit 0 fi # ── 1. Prerequisitos ───────────────────────────────────── echo "" echo " ENCRYPTIONTV — instalación de tu panel" echo "" # Solo se anuncian preguntas si de verdad va a haberlas: quien lo lanza # con sus datos ya puestos no tiene por qué leer que le vamos a preguntar. if [ -n "${ARG_CREDENTIAL:-}${ETV_ACCOUNT_CREDENTIAL:-}${ARG_LICENSE_KEY:-}${ETV_LICENSE_KEY:-}" ]; then echo " Esto tarda entre 5 y 15 minutos y va solo. Ya me has dado tus" echo " datos, así que no te preguntaré nada." else echo " Esto tarda entre 5 y 15 minutos y va solo. Te preguntaré tres" echo " cosas por el camino, todas están en tu documento de bienvenida." fi # 🔴 EL AVISO DEL `sudo`, LO ANTES POSIBLE. # # Quien exporta ETV_PANEL_URL / ETV_ACCOUNT_EMAIL / ETV_ACCOUNT_CREDENTIAL # y luego pasa a root —poniendo `sudo` delante, o con `sudo -i` / `su -`— # PIERDE las tres: sudo limpia el entorno por seguridad. Desde fuera # parece que «el instalador no las hereda», y lo que ha pasado es que # nunca le llegaron. # # Pasó de verdad, y el daño no fue solo tener que teclear: al preguntar # lo primero (el dominio) y no venir nada, la instalación se fue por un # camino que no era. El instalador no puede recuperar esas variables —no # ve el entorno de antes—, pero sí puede reconocer la situación y decirla # aquí, antes de la primera pregunta, en vez de comportarse como si no # hubiera pasado nada. if [ -n "${SUDO_USER:-}" ] && [ -z "${ETV_PANEL_URL:-}${ETV_ACCOUNT_EMAIL:-}${ETV_ACCOUNT_CREDENTIAL:-}${ETV_LICENSE_KEY:-}" ] \ && [ -z "${ARG_PANEL_URL}${ARG_EMAIL}${ARG_CREDENTIAL}${ARG_LICENSE_KEY}" ]; then echo "" echo " ℹ️ Si antes de esto habías escrito líneas 'export ETV_…': sudo las" echo " descarta siempre, así que no me han llegado. No es culpa tuya" echo " ni pasa nada — respóndeme aquí y seguimos." echo " (Si prefieres que se hereden, se relanza con 'sudo -E'.)" fi paso 1 "Revisando tu servidor" # ── 1.a PERMISOS ───────────────────────────────────────── # Antes esto se daba por supuesto: la cabecera decía "requiere sudo" y # nada lo comprobaba, así que sin permisos el instalador moría a mitad, # escribiendo, con el sistema ya tocado. Fallar aquí es gratis. if [ "$(id -u)" -ne 0 ]; then fail "El instalador necesita permisos de administrador y no los tiene. Es lo normal: la instalación toca partes del servidor que están protegidas. Se resuelve añadiendo \"sudo\" al comando." fi # ── 1.b LA DISTRIBUCIÓN ────────────────────────────────── # Hoy no se miraba. En un Ubuntu 20.04 la instalación llegaba lejos y # moría en algo raro, sin decir nunca cuál era el problema real. if [ -r /etc/os-release ]; then # shellcheck disable=SC1091 . /etc/os-release DISTRO_ID="${ID:-desconocida}" DISTRO_VER="${VERSION_ID:-desconocida}" DISTRO_NOMBRE="${PRETTY_NAME:-$DISTRO_ID $DISTRO_VER}" else fail "No se puede identificar el sistema operativo (falta /etc/os-release). EncryptionTV se instala sobre Ubuntu Server ${UBUNTU_SOPORTADAS// / o }." fi if [ "$DISTRO_ID" != "ubuntu" ]; then fail "Sistema no soportado: $DISTRO_NOMBRE EncryptionTV se instala sobre Ubuntu Server ${UBUNTU_SOPORTADAS// / o }. Puede que funcione en otras distribuciones parecidas, pero no está probado y no podemos darte soporte si algo falla." fi VERSION_OK="false" for v in $UBUNTU_SOPORTADAS; do [ "$DISTRO_VER" = "$v" ] && VERSION_OK="true" done if [ "$VERSION_OK" != "true" ]; then fail "Versión de Ubuntu no soportada: $DISTRO_NOMBRE Hace falta Ubuntu Server ${UBUNTU_SOPORTADAS// / o } (LTS). Si tu VPS trae una versión más antigua, lo más rápido es reinstalarlo desde el panel de tu proveedor eligiendo Ubuntu 24.04." fi log " OK $DISTRO_NOMBRE" # ── 1.c ARQUITECTURA ───────────────────────────────────── # 🔴 Solo se publican imágenes para x86_64. En un VPS ARM el `pull` # funciona pero el contenedor muere al arrancar con un error de # formato de ejecutable que no dice nada. Mejor pararlo aquí. ARQ="$(uname -m)" case "$ARQ" in x86_64|amd64) log " OK arquitectura $ARQ" ;; *) fail "Arquitectura NO COMPATIBLE: $ARQ EncryptionTV publica sus imágenes solo para x86_64 (amd64), que es lo que usan los VPS habituales. Este servidor es $ARQ (normalmente ARM). Qué hacer: contrata un VPS x86_64, o escríbenos para saber si ya hay imágenes para tu arquitectura. NO sigas: la instalación terminaría con contenedores que no arrancan." ;; esac # ── 1.d PROGRAMAS DE APOYO — se INSTALAN si faltan ─────── # Antes se comprobaban y se abortaba con un `apt-get` de ejemplo para # que lo copiara el admin. El requisito es que baste con un Ubuntu # limpio, así que ahora los pone el instalador. APOYO="curl jq openssl ca-certificates" FALTAN="" for bin in curl jq openssl; do command -v "$bin" >/dev/null 2>&1 || FALTAN+=" $bin" done if [ -n "$FALTAN" ]; then log " Instalando algunos programas que faltan:$FALTAN" if [ "$DRY_RUN" = "true" ]; then echo " [DRY] apt-get update && apt-get install -y $APOYO" else export DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || warn "apt-get update dio error; se intenta instalar igualmente" # shellcheck disable=SC2086 apt-get install -y -qq $APOYO >/dev/null 2>&1 || fail "No se han podido instalar:$FALTAN Prueba a mano y vuelve a lanzar el instalador: sudo apt-get update && sudo apt-get install -y$FALTAN" log " OK instalados" fi else log " OK curl, jq y openssl ya estaban" fi # ── 1.e DOCKER — se instala si falta ───────────────────── instalar_docker() { log " Falta Docker, el motor con el que corre el panel. Lo instalo yo." tardara "Instalando Docker." if [ "$DRY_RUN" = "true" ]; then echo " [DRY] instalación de docker-ce desde download.docker.com" return 0 fi export DEBIAN_FRONTEND=noninteractive # Repositorio OFICIAL de Docker, no el paquete de Ubuntu: el de la # distribución va meses por detrás y le falta el plugin de compose. install -m 0755 -d /etc/apt/keyrings || return 1 curl -fsSL "https://download.docker.com/linux/ubuntu/gpg" \ -o /etc/apt/keyrings/docker.asc || return 1 chmod a+r /etc/apt/keyrings/docker.asc echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" \ > /etc/apt/sources.list.d/docker.list || return 1 apt-get update -qq >/dev/null 2>&1 || return 1 apt-get install -y -qq docker-ce docker-ce-cli containerd.io \ docker-buildx-plugin docker-compose-plugin >/dev/null 2>&1 || return 1 systemctl enable --now docker >/dev/null 2>&1 || return 1 return 0 } if ! command -v docker >/dev/null 2>&1; then instalar_docker || fail "No se ha podido instalar Docker automáticamente. Puede pasar si el servidor no llega a download.docker.com o si apt está bloqueado por otra instalación en curso. Si sabes hacerlo, instálalo a mano siguiendo la guía oficial: https://docs.docker.com/engine/install/ubuntu/ Y si no, escríbenos: es un problema del servidor, no tuyo. No se ha tocado nada más del servidor." else log " OK Docker ya estaba instalado" fi if [ "$DRY_RUN" = "false" ]; then docker compose version >/dev/null 2>&1 || fail "Docker está, pero le falta el plugin 'compose'. Instálalo con: sudo apt-get install -y docker-compose-plugin" systemctl is-active docker >/dev/null 2>&1 || systemctl start docker >/dev/null 2>&1 || true docker info >/dev/null 2>&1 || fail "Docker está instalado pero no responde. Comprueba el servicio: sudo systemctl status docker" # 🔴 Que el binario exista no prueba que Docker FUNCIONE. Esto sí. if ! docker run --rm hello-world >/dev/null 2>&1; then fail "Docker está instalado pero no consigue ejecutar un contenedor. Suele ser falta de conexión con Docker Hub o un problema del propio servicio. Compruébalo con: sudo docker run --rm hello-world La instalación se para aquí: sin contenedores no hay panel." fi log " OK Docker funciona (versión $(docker --version | awk '{print $3}' | tr -d ','))" else echo " [DRY] comprobaría: docker compose, docker info y 'docker run hello-world'" fi # ── 1.c MEMORIA ─────────────────────────────────────────── RAM_MB="$(free -m 2>/dev/null | awk '/^Mem:/{print $2}')" if [ -n "$RAM_MB" ] && [ "$RAM_MB" -lt 1800 ]; then fail "Este servidor tiene poca memoria: ${RAM_MB} MB. El panel necesita 2 GB como mínimo, y con 4 GB va cómodo. Con menos, el servidor se queda sin memoria y empieza a cerrar cosas por su cuenta: el panel se caería solo cada poco. Qué hacer: amplía el plan de tu servidor (en la web donde lo contrataste) y vuelve a intentarlo." fi # ── 1.f MEMORIA DE RESPALDO (swap) — SE CREA SOLA ──────────────────── # # Antes esto era un aviso con tres órdenes para que las copiara el admin. # Un aviso que nadie de nuestro público sabe atender no es un aviso: es # dejar el problema donde estaba con la conciencia tranquila. El panel # con 2 GB va justo, y el día que Postgres y una importación coinciden, # sin swap el sistema empieza a matar procesos — y lo que se cae es el # panel, sin explicación ninguna. # # Se hace solo, y no se interrumpe la instalación si no se puede: hay # VPS (contenedores OpenVZ/LXC) donde el swap no existe y no es un fallo # del admin ni nuestro. SWAP_MB="$(free -m 2>/dev/null | awk '/^Swap:/{print $2}')" if [ -n "$SWAP_MB" ] && [ "$SWAP_MB" -lt 1000 ]; then if [ "$DRY_RUN" = "true" ]; then echo " [DRY] crearía 4 GB de memoria de respaldo en /swapfile" else LIBRE_MB="$(df -Pm / 2>/dev/null | awk 'NR==2{print $4}')" if [ -z "$LIBRE_MB" ] || [ "$LIBRE_MB" -lt 6000 ]; then warn "Este servidor no tiene swap y no queda disco suficiente para crearla (hacen falta unos 5 GB libres). El panel funcionará, pero si se queda sin memoria puede cerrarse solo. Ampliar el disco lo arregla." else log " Creando 4 GB de memoria de respaldo (swap): este servidor no tenía" crear_swap() { # fallocate es instantáneo pero algunos sistemas de ficheros no # lo soportan para swap; dd es lento pero funciona siempre. fallocate -l 4G /swapfile 2>/dev/null \ || dd if=/dev/zero of=/swapfile bs=1M count=4096 status=none 2>/dev/null \ || return 1 chmod 600 /swapfile || return 1 mkswap /swapfile >/dev/null 2>&1 || return 1 swapon /swapfile 2>/dev/null || return 1 # Que sobreviva a un reinicio. Idempotente: si ya está, no se # duplica la línea (un fstab con la entrada dos veces impide # arrancar en algunas versiones). grep -q '^/swapfile ' /etc/fstab 2>/dev/null \ || echo "/swapfile none swap sw 0 0" >> /etc/fstab return 0 } if [ -e /swapfile ] && ! swapon --show 2>/dev/null | grep -q '/swapfile'; then # Un intento anterior a medias: se limpia antes de repetir. rm -f /swapfile fi if crear_swap; then log " OK 4 GB de memoria de respaldo activos (y al reiniciar también)" else rm -f /swapfile 2>/dev/null || true warn "No se ha podido crear la memoria de respaldo. Suele pasar en servidores virtualizados que no lo permiten. La instalación sigue: el panel funciona igual, solo tiene menos margen si se queda sin memoria." fi fi fi fi # ── 2. Directorio de instalación ───────────────────────── # Ya NO se clona ni se extrae ningún repositorio: el panel viaja como # imagen. Aquí solo se crea el árbol mínimo que el compose y el # auto-update necesitan. Los ficheros de despliegue se sacan de la # imagen más abajo, cuando ya sabemos que la credencial es válida. # # Va dentro del paso 1 a propósito: para quien instala, "revisar el # servidor" y "crear una carpeta" son el mismo momento, no dos pasos. log " Preparando la carpeta del panel en $INSTALL_DIR" # ── 2.b ¿YA HAY ALGO INSTALADO AQUÍ? ───────────────────────────────── # Un admin al que le falle la instalación va a reintentar: es lo primero # que hace cualquiera. Y algún día alguien va a relanzar esto sobre una # instalación que YA FUNCIONA, por error. Ninguna de las dos cosas puede # acabar en pérdida de datos. YA_INSTALADO="false" PANEL_ENV_PREVIO="$INSTALL_DIR/apps/panel-api/.env" [ -f "$PANEL_ENV_PREVIO" ] && YA_INSTALADO="true" HAY_DATOS="false" if [ "$DRY_RUN" = "false" ] && command -v docker >/dev/null 2>&1; then docker volume inspect encryptiontv-postgres-data >/dev/null 2>&1 && HAY_DATOS="true" fi if [ "$FORCE" = "true" ]; then # 🔴 El camino destructivo. Se dice EXACTAMENTE qué se va a borrar y se # exige escribir una palabra: un [y/N] se pulsa sin leer. echo "" echo " ⚠️ --force: EMPEZAR DE CERO" echo "" echo " Se va a BORRAR, sin posibilidad de recuperarlo:" echo " · la base de datos del panel (clientes, códigos, dispositivos…)" echo " · la configuración de $INSTALL_DIR (incluidos sus secretos)" echo " · los certificados HTTPS ya emitidos" echo "" if [ "$HAY_DATOS" = "true" ]; then echo " 🔴 HAY UNA BASE DE DATOS EN ESTE SERVIDOR. Si es un panel en uso," echo " esto se lleva por delante los datos de sus clientes." echo " Haz una copia antes si tienes la menor duda." echo "" fi if [ "$DRY_RUN" = "true" ]; then echo " [DRY] pediría escribir BORRAR y luego eliminaría contenedores," echo " [DRY] volúmenes y $INSTALL_DIR" else # 🔴 AQUÍ SÍ SE EXIGE LA PALABRA, y a propósito: es la única pregunta # cuyo «sí» destruye datos que no vuelven. Un [s/n] se pulsa sin leer; # escribir BORRAR obliga a enterarse. Lo que sí se relaja es la # mayúscula (la fricción está en la palabra, no en la tecla), y se # repregunta en vez de cancelar de golpe — cancelar aquí no cuesta # nada, pero tampoco hay motivo para castigar un tecleo. CONFIRMA_FORCE="" for _ in 1 2 3; do preguntar " Escribe BORRAR para continuar (o Enter para dejarlo): " CONFIRMA_FORCE case "$(normalizar_respuesta "$CONFIRMA_FORCE")" in borrar) CONFIRMA_FORCE="BORRAR"; break ;; "") CONFIRMA_FORCE=""; break ;; *) echo " Para borrarlo todo tienes que escribir la palabra BORRAR." >&2 CONFIRMA_FORCE="" ;; esac done [ "$CONFIRMA_FORCE" = "BORRAR" ] || fail "Cancelado. No se ha tocado nada." log " Limpiando instalación anterior…" if [ -f "$INSTALL_DIR/infra/docker-compose.prod.yml" ]; then docker compose -f "$INSTALL_DIR/infra/docker-compose.prod.yml" down -v >/dev/null 2>&1 || true fi docker volume rm encryptiontv-postgres-data encryptiontv-panel-web-dist \ encryptiontv-caddy-data encryptiontv-caddy-config >/dev/null 2>&1 || true rm -rf "$INSTALL_DIR" YA_INSTALADO="false"; HAY_DATOS="false" log " OK borrado" fi elif [ "$YA_INSTALADO" = "true" ]; then # Reintento sobre una instalación previa (completa o a medias). log " Ya hay una instalación en $INSTALL_DIR — se REUTILIZA" log " · el .env NO se toca: contiene secretos que no se pueden regenerar" log " · los contenedores se actualizan en su sitio, no se duplican" if [ "$HAY_DATOS" = "true" ]; then log " · la base de datos existente se conserva" fi log " (si lo que quieres es empezar de cero: vuelve a lanzarme con --force)" fi run mkdir -p "$INSTALL_DIR/infra/caddy" "$INSTALL_DIR/apps/panel-api" "$INSTALL_DIR/scripts" log " OK carpeta preparada" # ── 2. El dominio ──────────────────────────────────────── paso 2 "Comprobando tu dominio" # Cada dato se puede dar por variable de entorno (útil para automatizar); # si no viene, se pregunta. Preguntar es el camino normal: así el # one-liner funciona pelado, sin que el admin tenga que recordar flags. # # 🔴 SE PREGUNTA LO PRIMERO Y SE COMPRUEBA EN EL ACTO. El dominio es el # único requisito que el instalador NO puede resolver por su cuenta, y # el único que obliga a salir de aquí e ir a otra web. Descubrirlo al # final, con medio servidor montado, es la peor versión de esta # instalación. # Se aceptan despistes evidentes en vez de rechazarlos: sobra la barra # final, sobran los espacios, y "panel.midominio.com" a secas es lo que # escribe la mayoría. Corregir lo obvio es mejor que un error. limpiar_dominio() { local d; d="$(printf '%s' "$1" | tr -d '[:space:]' | sed -E 's|/+$||')" case "$d" in https://*) ;; http://*) d="https://${d#http://}" ;; "") ;; *) d="https://$d" ;; esac printf '%s' "$d" } dominio_valido() { local d; d="$(limpiar_dominio "$1")" [[ "$d" =~ ^https://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(:[0-9]+)?$ ]] } PANEL_URL="${ARG_PANEL_URL:-${ETV_PANEL_URL:-}}" if [ -z "$PANEL_URL" ]; then echo "" echo " ¿Cuál es el dominio de tu panel?" echo "" echo " Es la dirección por la que entrarás a administrarlo. Viene en" echo " tu documento de bienvenida; si lo eliges tú, usa un subdominio" echo " de un dominio que ya tengas." echo "" echo " Ejemplo: panel.midominio.com" echo "" preguntar_hasta " Tu dominio: " PANEL_URL dominio_valido \ " Eso no parece un dominio. Tiene que ser un nombre completo con su terminación, por ejemplo: panel.midominio.com (el https:// te lo pongo yo si no lo escribes)." else # Viene por bandera o variable: aquí no hay a quién repreguntar. dominio_valido "$PANEL_URL" || fail "El dominio que me has pasado no es válido: $PANEL_URL Tiene que ser un nombre de dominio completo, por ejemplo: --domain https://panel.midominio.com" fi PANEL_URL="$(limpiar_dominio "$PANEL_URL")" # ── 3.b 🔴 EL DNS — ANTES DE TOCAR NADA ────────────────────────────── # # Este es el bloque que más instalaciones va a salvar. El panel necesita # que su dominio apunte a este servidor para que Let's Encrypt pueda # emitir el certificado. Eso el instalador NO lo puede hacer por el # admin: lo tiene que crear él en su proveedor de DNS. # # Antes no se comprobaba nada. La instalación terminaba diciendo "✅ # DONE" y el admin se quedaba con un VPS montado y un panel al que no # podía entrar, sin ninguna pista de por qué. # # 🔴 Y hay algo peor que perder el tiempo: Let's Encrypt limita los # fallos de validación a 5 por hora y los certificados a 50 por dominio # y semana. Un admin que reintente la instalación con el DNS mal puede # quedarse SIN PODER EMITIR CERTIFICADO DURANTE UNA SEMANA, y eso no se # arregla reinstalando: hay que esperar. Por eso se comprueba aquí, y # por eso existe --staging. # # 🔴 Y el mensaje importa tanto como la comprobación. Quien instala esto # no sabe qué es un registro A ni por qué "no resuelve" su dominio; si # el error no le dice exactamente qué escribir y dónde, da igual que # hayamos detectado el problema: se queda igual de atascado. Por eso # esto no es un error, es una GUÍA. PANEL_HOST="$(echo "$PANEL_URL" | sed -E 's|https?://||; s|/.*||; s|:.*||')" APP_HOST="app.$PANEL_HOST" log " Tu panel usará estos dos nombres:" log " $PANEL_HOST" log " $APP_HOST (por aquí entrarás tú)" # La IP pública de este servidor, preguntando fuera (detrás de NAT la # dirección de la interfaz no sirve). Varios servicios por si uno cae. IP_PUBLICA="" for servicio in "https://api.ipify.org" "https://ifconfig.me/ip" "https://icanhazip.com"; do IP_PUBLICA="$(curl -fsS --max-time 8 "$servicio" 2>/dev/null | tr -d '[:space:]')" [[ "$IP_PUBLICA" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] && break IP_PUBLICA="" done if [ -z "$IP_PUBLICA" ]; then warn "No he podido averiguar la dirección de este servidor: parece que no llega a internet. Sigo adelante, pero no puedo comprobar tu dominio; si estuviera mal apuntado, el panel se instalaría y no se vería desde fuera." else log " La dirección de este servidor es: $IP_PUBLICA" resolver() { # getent usa el resolver del sistema; sirve en cualquier Ubuntu sin # depender de dig, que no viene instalado por defecto. # # 🔴 El `|| true` es imprescindible: el script corre con # `set -euo pipefail`, y cuando el dominio NO resuelve getent sale # con código != 0 → pipefail hace fallar la tubería → set -e mata el # script AHÍ MISMO, en silencio, sin llegar nunca al mensaje que # explica el problema. Es decir: el caso más importante que este # bloque tiene que detectar era justo el que lo mataba sin decir # nada. Lo vi ejecutándolo en un Ubuntu limpio, no leyéndolo. getent ahostsv4 "$1" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ' || true } # 🔴 SE MIRAN LOS DOS NOMBRES ANTES DE DECIR NADA. Antes se fallaba en # el primero que estuviera mal, así que alguien a quien le faltaran las # dos entradas creaba una, volvía a lanzar, y se encontraba el mismo # error otra vez con el otro nombre. Dos viajes al proveedor de # dominios (y dos esperas de propagación) para un problema que se # podía contar entero de una vez. estado_de() { # Imprime "ok", "ausente" o "otra ". local ips; ips="$(resolver "$1")" if [ -z "$ips" ]; then echo "ausente" elif echo " $ips " | grep -q " $IP_PUBLICA "; then echo "ok" else echo "otra ${ips% }"; fi } EST_PANEL="$(estado_de "$PANEL_HOST")" EST_APP="$(estado_de "$APP_HOST")" if [ "$EST_PANEL" = "ok" ] && [ "$EST_APP" = "ok" ]; then log " OK tus dos nombres apuntan a este servidor" else # La guía se compone según lo que falte de verdad: no se le pide a # nadie que cree algo que ya tiene bien. GUIA="" add() { GUIA="${GUIA}$1 "; } add "" add " Tu servidor tiene esta dirección: $IP_PUBLICA" add "" add " Para que tu panel funcione con conexión segura, tu dominio tiene" add " que apuntar a esa dirección. Esto se hace UNA sola vez, en la web" add " donde compraste el dominio." add "" # Caso (b): apunta a otro sitio. Merece explicación propia — no es lo # mismo "no has creado nada" que "has creado algo y está mal". if [ "${EST_PANEL#otra }" != "$EST_PANEL" ] || [ "${EST_APP#otra }" != "$EST_APP" ]; then add " ⚠️ Ojo: algo ya está creado, pero apunta a otro servidor." add "" # Con `if` y no con `[ … ] && add …`: aquí daría igual (`set -e` # perdona al lado izquierdo de un `&&`), pero un condicional # explícito se lee mejor y no invita a copiar el patrón donde sí # importa. if [ "${EST_PANEL#otra }" != "$EST_PANEL" ]; then add " $PANEL_HOST va ahora a ${EST_PANEL#otra }" fi if [ "${EST_APP#otra }" != "$EST_APP" ]; then add " $APP_HOST va ahora a ${EST_APP#otra }" fi add "" add " …pero este servidor es el $IP_PUBLICA" add "" add " Suele ser una de dos: la dirección está mal copiada, o quedó" add " apuntando a un servidor anterior. Hay que corregirla." add "" fi add " Entra en tu proveedor de dominios (donde compraste el dominio)," add " busca la sección de DNS y deja estas entradas de tipo A así:" add "" if [ "$EST_PANEL" != "ok" ]; then add " $PANEL_HOST -> $IP_PUBLICA" fi if [ "$EST_APP" != "ok" ]; then add " $APP_HOST -> $IP_PUBLICA" fi add "" # Caso (c): solo falta uno. Decirlo evita que lo borre o lo cambie. if [ "$EST_PANEL" = "ok" ] || [ "$EST_APP" = "ok" ]; then if [ "$EST_PANEL" = "ok" ]; then OK_HOST="$PANEL_HOST"; else OK_HOST="$APP_HOST"; fi add " (El otro nombre, $OK_HOST, ya está bien: no lo toques.)" add "" else # Solo cuando faltan los dos: si uno ya está puesto, esta frase # sobra y encima confunde. add " Los dos hacen falta: uno es el panel y el otro la pantalla desde" add " la que lo administras." add "" fi add " Guarda los cambios, espera unos 5 minutos y vuelve a ejecutar el" add " mismo comando de instalación. No se ha instalado nada todavía," add " así que no hay nada que deshacer." add "" add " Si usas Cloudflare: la nube que aparece al lado de cada entrada" add " tiene que estar GRIS, no naranja. En naranja, tu dominio apunta" add " a Cloudflare y no a tu servidor, y esto no se puede comprobar." fail "Falta un paso antes de instalar. $GUIA" fi fi # Puertos: lo que SÍ se puede comprobar desde dentro es que no haya otro # programa ocupándolos. Es un fallo habitual en imágenes de VPS que traen # Apache o Nginx preinstalados, y da un error de Caddy que no se entiende. # 🔴 «OCUPADO» NO ES LO MISMO QUE «OCUPADO POR OTRO». Esta comprobación # abortaba también cuando quien tenía los puertos era el propio panel del # admin, funcionando perfectamente. O sea: NO SE PODÍA REINSTALAR SIN # FORMATEAR EL SERVIDOR. # # Y eso rompe justo la vía de escape: reinstalar es lo que se hace cuando # el actualizador falla. Un admin con el actualizador roto y el # instalador negándose se queda sin salida que no sea perder la máquina. # Le pasó a Jon con su panel en marcha y sirviendo. # # Distinguir es sencillo: se le pregunta a Docker si el puerto lo publica # un contenedor NUESTRO. Si es nuestro, esto es una reinstalación y se # sigue. Si es de otro (Apache o Nginx de la imagen del VPS, el caso # original), se aborta igual que antes. nuestro_contenedor_en_puerto() { docker ps --format '{{.Names}}|{{.Ports}}' 2>/dev/null \ | grep '^encryptiontv-' | grep -q ":$1->" } REINSTALACION="false" for puerto in 80 443; do if ss -lntH "sport = :$puerto" 2>/dev/null | grep -q . ; then if nuestro_contenedor_en_puerto "$puerto"; then REINSTALACION="true" log " La puerta $puerto la usa tu propio panel (reinstalación)" continue fi QUIEN="$(ss -lntpH "sport = :$puerto" 2>/dev/null | grep -oE 'users:\(\("[^"]+' | head -1 | sed 's/.*"//')" fail "Ya hay otro programa usando la puerta $puerto de este servidor${QUIEN:+: $QUIEN}. Tu panel necesita las puertas 80 y 443 para sí mismo — son por las que entra el tráfico web. Ahora mismo las tiene ocupadas otro programa. Suele pasar cuando el servidor viene con una página web de ejemplo ya instalada. Si es tu caso y no la usas, se quita así: sudo systemctl disable --now ${QUIEN:-apache2} Para ver quién la ocupa: sudo ss -lntp | grep ':$puerto' No se ha instalado nada todavía." fi done if [ "$REINSTALACION" = "true" ]; then echo "" echo " ────────────────────────────────────────────────────────────" echo " YA TIENES UN PANEL INSTALADO EN ESTE SERVIDOR" echo "" echo " Voy a reinstalarlo encima. En cristiano:" echo "" echo " · Tus datos NO se tocan (clientes, servidores VPN, ajustes)." echo " · Tu configuración se conserva; solo se completa lo que falte." echo " · Se vuelven a colocar los ficheros del panel y se reinicia." echo "" echo " El panel dejará de responder un par de minutos mientras tanto." echo " ────────────────────────────────────────────────────────────" echo "" else log " OK las puertas 80 y 443 están libres" fi # ⚠️ Lo que NO se puede comprobar desde aquí: que los puertos sean # alcanzables DESDE FUERA. Eso depende del cortafuegos del proveedor # (el que se configura en su panel web, no en el servidor) y no hay # forma honesta de verificarlo sin un servicio externo que llame de # vuelta. Si Let's Encrypt fallara pese a que el DNS está bien, es lo # primero que hay que mirar. # ── 3. LA CUENTA ───────────────────────────────────────────────────── paso 3 "Tu cuenta de EncryptionTV" # 🔴 LA DIRECCIÓN DEL CENTRAL YA NO SE PREGUNTA. Se preguntaba con un # valor por defecto entre corchetes, que es la peor forma de preguntar: # quien no sabe qué es se queda mirando, y quien lo sabe pulsa Enter. Es # siempre la misma salvo para nosotros, así que ahora es un valor por # defecto y punto (ETV_CENTRAL_URL lo cambia si hace falta). CENTRAL_URL="$DEFAULT_CENTRAL_URL" # CENTRAL F1 (v4.6.0) — la vía NORMAL: el admin se identifica con su # CUENTA y el central EMITE la licencia firmada para esta panelUrl, con # la credencial del registry y el hash de su contraseña del panel. # # 🔴 v4.38.0 — AHORA SE PREGUNTA, ANTES SE EXIGÍA EXPORTARLA. Y no era # un detalle de comodidad: el documento de bienvenida decía # export ETV_ACCOUNT_EMAIL=… ; curl … | bash # y `sudo` LIMPIA EL ENTORNO, así que en cuanto alguien añadía el sudo # que el instalador le pedía, las dos variables desaparecían, se caía a # la vía clásica y se le acababa pidiendo una clave de licencia que el # cliente NO TIENE (el PDF no la lleva, a propósito). O sea: el camino # documentado para un cliente real no llegaba al final. Preguntando no # hay nada que exportar ni que se pueda perder por el camino. ACCOUNT_EMAIL="${ARG_EMAIL:-${ETV_ACCOUNT_EMAIL:-}}" ACCOUNT_CREDENTIAL="${ARG_CREDENTIAL:-${ETV_ACCOUNT_CREDENTIAL:-}}" LICENSE_KEY="${ARG_LICENSE_KEY:-${ETV_LICENSE_KEY:-}}" correo_valido() { local e; e="$(printf '%s' "$1" | tr -d '[:space:]')" # Una clave pegada donde va el correo: pasa, y con el aviso de abajo # se resuelve en un segundo intento en vez de en una reinstalación. [[ "$e" =~ ^ETVA?- ]] && return 1 [[ "$e" =~ ^[^@]+@[^@]+\.[^@]+$ ]] } credencial_valida() { local c; c="$(printf '%s' "$1" | tr -d '[:space:]')" [[ "$c" =~ ^ETVA- ]] } if [ -z "$LICENSE_KEY" ]; then if [ -z "$ACCOUNT_EMAIL" ]; then echo "" echo " Ahora tus datos de EncryptionTV. Son los dos que aparecen en tu" echo " documento de bienvenida, y también serán tu usuario y tu" echo " contraseña para entrar al panel." echo "" preguntar_hasta " Tu correo: " ACCOUNT_EMAIL correo_valido \ " Ahí va tu CORREO, no una clave. Si lo que has pegado empieza por «ETV», eso es tu credencial: te la pido justo después. Aquí toca la dirección de correo con la que te dimos de alta." else correo_valido "$ACCOUNT_EMAIL" || fail "El correo que me has pasado no vale: $ACCOUNT_EMAIL Tiene que ser el correo con el que te dimos de alta." fi ACCOUNT_EMAIL="$(echo "$ACCOUNT_EMAIL" | tr -d '[:space:]')" if [ -z "$ACCOUNT_CREDENTIAL" ]; then echo "" echo " Tu credencial de EncryptionTV. Está en el documento de bienvenida" echo " que te enviamos, en el apartado TU CREDENCIAL, y empieza por ETVA-." echo "" # 🔴 SE VE MIENTRAS SE ESCRIBE, a diferencia de una contraseña. # # Es un código largo que se pega, y ocultarlo solo servía para que # nadie pudiera comprobar si se había pegado entero o si el copiado # se había comido la última línea. El error no se descubría al # escribirlo, sino cuando el central lo rechazaba. # # Y no se pierde nada por enseñarlo: esta misma credencial aparece en # claro en el mensaje final de la instalación. Ocultarla aquí y # mostrarla cinco minutos después no protegía de nada. echo " Se verá mientras la escribes, para que puedas comprobar que ha" echo " entrado entera. (Si no la encuentras, escríbenos: es la única" echo " pieza que no podemos volver a generar sin darte una nueva.)" preguntar_hasta " Tu credencial: " ACCOUNT_CREDENTIAL credencial_valida \ " Eso no parece tu credencial. Tiene que empezar por ETVA- y venir de tu documento de bienvenida. Ojo, que se parecen: ETVA-.... ← TU CREDENCIAL, la que va aquí (lleva la A) ETV-.... ← una clave de licencia, que es interna nuestra Cópiala entera, con los guiones: lo más habitual es quedarse con media al seleccionarla." else credencial_valida "$ACCOUNT_CREDENTIAL" || fail "La credencial que me has pasado no vale. Tiene que empezar por ETVA-. Si empieza por ETV- sin la A, eso es una clave de licencia (interna nuestra), no tu credencial." fi ACCOUNT_CREDENTIAL="$(echo "$ACCOUNT_CREDENTIAL" | tr -d '[:space:]')" ADMIN_EMAIL="$ACCOUNT_EMAIL" log " Comprobando tus datos y preparando tu licencia…" if [ "$DRY_RUN" = "true" ]; then # 🔴 En ensayo NO se llama al central de verdad. Es el mismo fallo # que ya tuvo la vía clásica en v4.36.0: un --dry-run que activaba # licencias no es un ensayo, es una instalación a medias. echo " [DRY] POST $CENTRAL_URL/licenses/activate (cuenta $ACCOUNT_EMAIL)" BODY=$(jq -n --arg pu "$PANEL_URL" --arg em "$ACCOUNT_EMAIL" \ '{licenseKey:"ETV-ENSA-YOSI-MULA-DOXX", payload:{licenseKey:"ETV-ENSA-YOSI-MULA-DOXX",panelUrl:$pu,adminEmail:$em, tier:"full",features:["feature.app_vpn"],issuedAt:0,expiresAt:null}, signature:"ENSAYO-firma-simulada", registry:{user:"ensayo",password:"ensayo"}, auth:{email:$em,passwordHash:"ENSAYO-hash-simulado"}}') HTTP_CODE="200" else ACTIVATE_PAYLOAD=$(jq -n \ --arg em "$ACCOUNT_EMAIL" \ --arg cr "$ACCOUNT_CREDENTIAL" \ --arg pu "$PANEL_URL" \ '{email: $em, credential: $cr, panelUrl: $pu}') RESP=$(curl -sS -X POST "$CENTRAL_URL/licenses/activate" \ -H "Content-Type: application/json" \ --data "$ACTIVATE_PAYLOAD" \ -w "\n--HTTP--%{http_code}") HTTP_CODE=$(echo "$RESP" | tail -1 | sed 's/^--HTTP--//') BODY=$(echo "$RESP" | sed '$d') fi # F3 (v4.7.0) — 409 = la licencia está ACTIVA en otra instalación. Es el # caso MUDANZA y no aborta: el panel se instala sin licencia y arranca en # MODO ACTIVACIÓN; el admin re-vincula desde la UI (Facturación y licencia) # con la verdad de la otra instalación delante. install.sh NO mueve # licencias a ciegas. if [ "$HTTP_CODE" = "409" ]; then echo "" echo " ⚠️ Tu licencia ya está en uso en otra instalación:" echo " $(echo "$BODY" | jq -r '.conflict.panelUrl // "desconocida"')" echo "" echo " Sigo instalando, pero este panel arrancará SIN licencia, en modo" echo " activación: podrás entrar y él mismo te pedirá que confirmes el" echo " traslado desde Configuración → Facturación y licencia." echo " Al confirmarlo, la instalación anterior dejará de funcionar." echo "" LICENSE_PENDING=true elif [ "$HTTP_CODE" = "401" ]; then fail "El correo o la clave no son correctos. Cópialos tal cual aparecen en tu documento de bienvenida: la clave distingue mayúsculas de minúsculas, y a veces se cuela un espacio al pegarla. Si estás seguro de que están bien, escríbenos: puede que la cuenta todavía no esté activa." elif [ "$HTTP_CODE" = "410" ]; then fail "$(echo "$BODY" | jq -r '.detail // "El plazo para activar esta licencia ha vencido."') Escríbenos y te la renovamos." elif [ "$HTTP_CODE" != "200" ]; then warn "respuesta del central: $BODY" fail "No hemos podido preparar tu licencia (código $HTTP_CODE). Si el problema se repite, escríbenos con este código y lo miramos. No se ha instalado nada todavía." else # F4 (v4.8.0) — el central emite la credencial del REGISTRY de este # panel junto con la licencia: ya no se pide a mano por env. REG_USER=$(echo "$BODY" | jq -r '.registry.user // empty') REG_PASS=$(echo "$BODY" | jq -r '.registry.password // empty') if [ -n "$REG_USER" ] && [ -n "$REG_PASS" ]; then log " OK cuenta verificada y licencia lista" else warn "el central no adjuntó credencial de registry (¿central pre-F4?) — se usará ETV_REGISTRY_PASSWORD del entorno si existe" fi fi else # ── VÍA CLÁSICA — clave de licencia suelta ────────────────────────── # 🔴 Ya NO se ofrece por pantalla, solo por ETV_LICENSE_KEY. Motivo: # `/install/register-panel` NO devuelve credencial de registry ni # identidad de acceso, así que por aquí la instalación se queda sin # poder descargar las imágenes y sin usuario con el que entrar. Es una # vía interna nuestra, no un camino para un cliente — y ofrecérsela a # quien no tiene clave de licencia (el documento de bienvenida no la # lleva) era mandarle a un callejón sin salida. # El espejo del aviso de arriba: quien mete su credencial donde va una # clave de licencia merece el mismo trato. if [[ "$LICENSE_KEY" =~ ^ETVA- ]]; then fail "Eso es tu CREDENCIAL de cuenta, no una clave de licencia. Se parecen (ETVA- lleva la A; ETV- no), pero van en sitios distintos. Y la buena noticia: con tu credencial no hace falta clave de licencia ninguna. Lanza el instalador sin --license-key y te la pedirá a ella, que es el camino normal." fi [[ "$LICENSE_KEY" =~ ^ETV-[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}$ ]] || \ fail "La clave de licencia no tiene el formato ETV-XXXX-XXXX-XXXX-XXXX." log " Registrando el panel con la clave de licencia (vía interna)" if [ "$DRY_RUN" = "true" ]; then # v4.36.0 — ESTO ERA LO QUE ROMPÍA EL ENSAYO EN SECO. La llamada se # hacía siempre, así que un --dry-run acababa registrando de verdad # el panel contra el central (o abortando con un 400 y dejándote sin # poder probar el resto). Ahora se simula una respuesta con la forma # correcta para que el resto del script se pueda recorrer entero. echo " [DRY] POST $CENTRAL_URL/install/register-panel" echo " [DRY] licenseKey=$LICENSE_KEY panelUrl=$PANEL_URL" BODY=$(jq -n --arg lk "$LICENSE_KEY" --arg pu "$PANEL_URL" \ --arg em "${ETV_ADMIN_EMAIL:-admin@ejemplo.com}" \ '{payload:{licenseKey:$lk,panelUrl:$pu,adminEmail:$em,tier:"full", features:["feature.app_vpn"],issuedAt:0,expiresAt:null}, signature:"ENSAYO-firma-simulada", syncToken:"etvp_ENSAYO-token-simulado", registry:{user:"ensayo",password:"ensayo"}, auth:null}') HTTP_CODE="200" else REGISTER_PAYLOAD=$(jq -n \ --arg lk "$LICENSE_KEY" \ --arg pu "$PANEL_URL" \ '{licenseKey: $lk, panelUrl: $pu}') RESP=$(curl -sS -X POST "$CENTRAL_URL/install/register-panel" \ -H "Content-Type: application/json" \ --data "$REGISTER_PAYLOAD" \ -w "\n--HTTP--%{http_code}") HTTP_CODE=$(echo "$RESP" | tail -1 | sed 's/^--HTTP--//') BODY=$(echo "$RESP" | sed '$d') fi if [ "$HTTP_CODE" != "200" ]; then warn "respuesta central: $BODY" fail "El central no ha reconocido esta licencia (HTTP $HTTP_CODE). Comprueba que: · la clave de licencia está bien escrita · el dominio que has puesto ($PANEL_URL) es EXACTAMENTE el que consta en la licencia — un http/https o un www de más cuentan No se ha instalado nada todavía." fi fi # El correo de contacto ya NO se pregunta: es el de la cuenta, o el que # viene FIRMADO en la licencia. Preguntarlo aparte solo servía para que # alguien tecleara uno distinto del de su licencia y acabara con dos # correos diferentes en su propio panel. ADMIN_EMAIL="${ADMIN_EMAIL:-$(echo "$BODY" | jq -r '.payload.adminEmail // empty')}" [ -n "$ADMIN_EMAIL" ] || fail "El central no ha devuelto ningún correo de contacto. Es un fallo nuestro, no de tu configuración: escríbenos." # ── v4.67.0 — EL TOKEN DE SINCRONIZACIÓN LO EMITE EL CENTRAL ───────── # # 🔴 AQUÍ ESTABA EL FALLO MÁS CARO DEL PROYECTO. Este script se INVENTABA # la credencial (`CENTRAL_API_TOKEN=$(random_token)`) con la que el panel # iba a hablar con el central. El central la compara con la suya, así que # no coincidía NUNCA: todo panel instalado con este instalador nacía # incapaz de registrar sus códigos. Y como el envío es best-effort, el # panel no se quejaba: el admin entregaba códigos que su cliente no podía # activar, y el fallo aparecía delante de una televisión, no aquí. # # Una credencial que una parte se inventa y la otra valida no es una # credencial. Ahora la emite quien la va a comprobar. CENTRAL_SYNC_TOKEN=$(echo "$BODY" | jq -r '.syncToken // empty') if [ "${LICENSE_PENDING:-false}" = "true" ]; then # Licencia en uso en otra instalación: el panel arranca en modo # activación y todavía no le corresponde credencial. Se queda VACÍA a # propósito — mejor sin credencial (el panel lo dice en rojo en Salud # del sistema) que con una inventada que produce un 401 desconcertante. CENTRAL_SYNC_TOKEN="" warn "este panel aún no puede registrar códigos: confirma el traslado de" warn "la licencia desde Configuración → Facturación, y vuelve a instalar" elif [ -z "$CENTRAL_SYNC_TOKEN" ]; then fail "El servidor central no ha entregado la credencial de sincronización. Sin ella, este panel no podría registrar los códigos de tus clientes y la app les diría «código no encontrado» al intentar activar. No se ha instalado nada. Suele significar que el servidor central es más antiguo que este instalador: escríbenos y lo actualizamos." fi if [ "${LICENSE_PENDING:-false}" != "true" ]; then LIC_KEY=$(echo "$BODY" | jq -r '.payload.licenseKey') LIC_PANEL_URL=$(echo "$BODY" | jq -r '.payload.panelUrl') LIC_EMAIL=$(echo "$BODY" | jq -r '.payload.adminEmail') LIC_TIER=$(echo "$BODY" | jq -r '.payload.tier') LIC_FEATURES=$(echo "$BODY" | jq -c '.payload.features') LIC_ISSUED_AT=$(echo "$BODY" | jq -r '.payload.issuedAt') LIC_EXPIRES_AT=$(echo "$BODY" | jq -r '.payload.expiresAt') LIC_SIGNATURE=$(echo "$BODY" | jq -r '.signature') # F.A.1 — IDENTIDAD ÚNICA: el central adjunta el hash Argon2id de la # cuenta (nunca el claro). Con él se siembra el admin del panel: el # cliente entra con SU email + credencial de cuenta, verificación 100% # local (central caído → entra igual). Ausente = cuenta pre-F.A.1 o vía # clásica; en ese caso el panel se queda SIN usuario y el mensaje final # lo dice tal cual. Antes proponía `pnpm … seed-owner`, un comando que # necesita el repositorio que el admin ya no recibe: era pedirle algo # imposible justo cuando más perdido está. AUTH_EMAIL=$(echo "$BODY" | jq -r '.auth.email // empty') AUTH_HASH=$(echo "$BODY" | jq -r '.auth.passwordHash // empty') if [ -n "$AUTH_HASH" ]; then log " OK entrarás al panel con tu propio correo y tu clave" fi # F3 — en la vía por CUENTA no hay LICENSE_KEY previa que comparar (la # emite el central); el check solo aplica a la vía clásica. [ -z "${LICENSE_KEY:-}" ] || [ "$LIC_KEY" = "$LICENSE_KEY" ] || fail "central devolvió licenseKey distinta: $LIC_KEY" [ "$LIC_PANEL_URL" = "$PANEL_URL" ] || fail "central devolvió panelUrl distinta: $LIC_PANEL_URL" # ── 4.b — Confirmación interactiva (defensa contra plan equivocado) ── # Se mantiene la pausa —es la última ocasión de parar si le hemos # emitido un plan que no es el suyo—, pero contada en castellano. "Tier" # y "Features" no le dicen nada a quien acaba de comprar un panel. echo "" echo " ────────────────────────────────────────────────────────────" echo " Esta es tu licencia. Compruébala antes de seguir:" echo "" echo " Tu panel $LIC_PANEL_URL" echo " Tu correo $LIC_EMAIL" N_FUNC="$(echo "$LIC_FEATURES" | jq 'length')" if [ "$N_FUNC" = "1" ]; then echo " Tu plan $LIC_TIER, con 1 función incluida" else echo " Tu plan $LIC_TIER, con $N_FUNC funciones incluidas" fi if [ "$LIC_EXPIRES_AT" = "null" ]; then echo " Válida sin fecha de caducidad" else echo " Válida hasta $(date -u -d "@$((LIC_EXPIRES_AT / 1000))" +'%d/%m/%Y' 2>/dev/null || echo "$LIC_EXPIRES_AT")" fi echo "" echo " Si algo no cuadra, para aquí y escríbenos: todavía no se ha" echo " instalado nada." echo " ────────────────────────────────────────────────────────────" echo "" if [ "$DRY_RUN" = "true" ]; then log " [DRY] saltando la confirmación" else # Por defecto SÍ: quien ha llegado hasta aquí ya ha escrito sus tres # datos y acaba de ver que su licencia es la correcta. Pulsar Enter # tiene que ser continuar, que es lo que quiere el 99% de las veces. if ! confirmar " ¿Sigo con la instalación? [SÍ]: " si; then fail "De acuerdo, lo dejamos aquí. No se ha instalado nada. Cuando quieras retomarlo, el mismo comando sirve." fi fi fi # LICENSE_PENDING # ── 4.c 🔴 PRUEBA DE ACCESO AL REGISTRY — ANTES DE ESCRIBIR NADA ───── # Todo lo que viene después escribe en el disco y levanta contenedores. # Si la credencial no vale o el registry no responde, es MUCHO mejor # enterarse aquí, con el sistema aún intacto, que a mitad de # instalación con media configuración escrita y servicios a medio subir. paso 4 "Descargando tu panel" REG_USER_EFECTIVO="${REG_USER:-${ETV_REGISTRY_USER:-}}" REG_PASS_EFECTIVO="${REG_PASS:-${ETV_REGISTRY_PASSWORD:-}}" if [ "$DRY_RUN" = "true" ]; then log " [DRY] saltando login y descarga de prueba" elif [ -z "$REG_USER_EFECTIVO" ] || [ -z "$REG_PASS_EFECTIVO" ]; then fail "No hay credencial para el registry. El central debería haberla entregado al registrar el panel. Si tu central es anterior a la v4.8.0, no la emite: actualízalo, o define ETV_REGISTRY_USER y ETV_REGISTRY_PASSWORD antes de ejecutar esto. Sin credencial no se puede descargar el panel." else if ! printf '%s' "$REG_PASS_EFECTIVO" \ | docker login "$REGISTRY_HOST" -u "$REG_USER_EFECTIVO" --password-stdin >/dev/null 2>&1; then fail "No hemos podido darte acceso a la descarga del panel. Lo más habitual es que la licencia no esté activa todavía, o que esté suspendida por un pago pendiente: en ese caso la descarga se corta automáticamente. Escríbenos indicando tu correo de EncryptionTV y lo miramos. No se ha instalado nada todavía." fi log " OK acceso concedido" tardara "Descargando el panel. Es la parte más lenta de todo esto." if ! docker pull "${IMAGEN_PANEL_API}:${IMAGEN_TAG}" >/dev/null 2>&1; then fail "La descarga del panel se ha cortado. El acceso es correcto (te has identificado bien), así que suele ser un problema de conexión de este servidor. Compruébalo con: curl -I https://$REGISTRY_HOST/v2/ No se ha instalado nada todavía." fi docker pull "${IMAGEN_PANEL_WEB}:${IMAGEN_TAG}" >/dev/null 2>&1 \ || fail "Falta una pieza del panel por descargar. Es un problema nuestro, no de tu servidor: la versión ${IMAGEN_TAG} está publicada a medias. Escríbenos y lo arreglamos enseguida. No se ha instalado nada todavía." # Etiqueta local: es la que referencia el compose, y la misma que # auto-update.sh vuelve a poner en cada actualización. run docker tag "${IMAGEN_PANEL_API}:${IMAGEN_TAG}" encryptiontv/panel-api:latest run docker tag "${IMAGEN_PANEL_WEB}:${IMAGEN_TAG}" encryptiontv/panel-web:latest log " OK panel descargado" fi # ── 4.d Ficheros de despliegue: salen de la IMAGEN, no de git ─────── if [ "$DRY_RUN" = "true" ]; then log " [DRY] saltando extracción" else extraer_de_imagen() { local origen="$1" destino="$2" docker run --rm --entrypoint sh encryptiontv/panel-api:latest \ -c "cat $origen" > "$destino" 2>/dev/null \ || fail "La imagen no contiene $origen. Esa imagen es anterior a la v4.35.0 y no trae sus ficheros de despliegue. Instala una versión igual o posterior: $COMO_LANZARME --image-tag 4.36.0" [ -s "$destino" ] || fail "$origen salió vacío de la imagen." } extraer_de_imagen /app/deploy/docker-compose.prod.yml "$INSTALL_DIR/infra/docker-compose.prod.yml" extraer_de_imagen /app/deploy/auto-update.sh "$INSTALL_DIR/scripts/auto-update.sh" extraer_de_imagen /app/deploy/restore.sh "$INSTALL_DIR/scripts/restore.sh" extraer_de_imagen /app/deploy/Caddyfile.template "/infra/caddy/Caddyfile.template" run chmod 0755 "$INSTALL_DIR/scripts/auto-update.sh" log " OK panel listo para arrancar" fi # ── 5. Generate secrets ────────────────────────────────── PANEL_ENV="$REPO_DIR/apps/panel-api/.env" # (v4.35.0: aquí se declaraba CENTRAL_ENV. El admin no monta un central; # era una variable muerta desde que el central se mudó a su propio VPS.) paso 5 "Preparando la configuración" if [ -f "$PANEL_ENV" ]; then log " Ya había configuración de una instalación anterior: se conserva" log " (dentro están las contraseñas de tu base de datos; regenerarlas" log " te dejaría sin acceso a tus propios datos)" # ── 🔴 CONSERVAR NO PUEDE SIGNIFICAR QUEDARSE ATRÁS ───────────────── # # El .env se conserva ENTERO, y con él se hereda también lo que le # FALTA. Una versión futura que necesite una variable nueva la tendría # en las instalaciones nuevas y no en las que vienen de antes — y el # panel arrancaría con esa variable vacía, que casi nunca falla de # forma evidente: falla raro, más tarde y lejos de aquí. # # Así que se COMPLETA lo ausente y no se toca NADA de lo que ya está. # Las contraseñas y los secretos generados son intocables por # definición: solo se añaden claves que no existen en el fichero. # # La lista es genérica a propósito: añadir una variable nueva al panel # en el futuro es añadir una línea aquí, y las instalaciones viejas la # reciben solas en la siguiente reinstalación. completar_variable() { local clave="$1" valor="$2" motivo="$3" if grep -qE "^${clave}=" "$PANEL_ENV" 2>/dev/null; then return 0 fi if [ "$DRY_RUN" = "true" ]; then echo " [DRY] añadiría $clave=$valor ($motivo)" # También cuenta en el ensayo: si no, el resumen final diría «no # faltaba nada» justo debajo de la lista de lo que falta. VARIABLES_COMPLETADAS=$((VARIABLES_COMPLETADAS + 1)) return 0 fi printf '%s=%s\n' "$clave" "$valor" >> "$PANEL_ENV" # 🔴 SE DICE CUÁL Y CON QUÉ VALOR. Completar en silencio deja el # próximo diagnóstico tan a ciegas como el que nos trajo aquí. log " + añadida $clave=$valor — $motivo" VARIABLES_COMPLETADAS=$((VARIABLES_COMPLETADAS + 1)) } VARIABLES_COMPLETADAS=0 PANEL_HOST_ENV="$(echo "$PANEL_URL" | sed -E 's|https?://||; s|/.*||')" # 🔴 La primera de la lista por lo que costó: sin ella, añadir un # servidor VPN falla SIEMPRE (el VPS intenta registrarse contra # localhost). Los paneles instalados antes de la v4.50.0 no la tienen. completar_variable "PANEL_PUBLIC_URL" "$PANEL_URL" \ "la dirección pública de tu panel (sin ella, añadir un servidor VPN falla)" completar_variable "ETV_REGISTRY_URL" "https://registry.encryptiontv.com" \ "de dónde se descargan las actualizaciones" completar_variable "PANEL_WEB_ORIGIN" "https://app.${PANEL_HOST_ENV}" \ "la dirección desde la que entras al panel" completar_variable "COOKIE_DOMAIN" ".${PANEL_HOST_ENV}" \ "el dominio de tu sesión" completar_variable "PANEL_API_PORT" "4100" "el puerto interno del panel" completar_variable "LOG_LEVEL" "info" "cuánto detalle guarda el registro" # ── 🔴 v4.67.0 — LA CREDENCIAL DEL CENTRAL SÍ SE PISA ─────────────── # # `completar_variable` solo AÑADE lo que falta, y para todo lo demás es # lo correcto (pisar la contraseña de postgres dejaría al admin sin sus # datos). Pero esta es distinta: el registro de arriba acaba de emitir # una credencial NUEVA y ha invalidado la anterior. Conservar la vieja # dejaría el panel exactamente igual de roto que antes —sin poder # registrar códigos— y encima parecería que la reinstalación lo arregló. # # Conservar no puede significar quedarse con algo que ya no vale. if [ -n "${CENTRAL_SYNC_TOKEN:-}" ]; then if [ "$DRY_RUN" = "true" ]; then echo " [DRY] actualizaría CENTRAL_API_TOKEN con la credencial recién emitida" elif grep -qE '^CENTRAL_API_TOKEN=' "$PANEL_ENV" 2>/dev/null; then TMP_ENV=$(mktemp) grep -v '^CENTRAL_API_TOKEN=' "$PANEL_ENV" > "$TMP_ENV" printf 'CENTRAL_API_TOKEN=%s\n' "$CENTRAL_SYNC_TOKEN" >> "$TMP_ENV" # `cat >` trunca y reescribe: MISMO inodo. Un `mv` cambiaría el # inodo y el contenedor seguiría leyendo el fichero viejo, que es # la trampa que ya nos costó una instalación bloqueada con Caddy. cat "$TMP_ENV" > "$PANEL_ENV" rm -f "$TMP_ENV" # 🔴 SE DICE QUÉ SE HA HECHO Y POR QUÉ. Este es el ÚNICO valor del # .env que esta reinstalación sobreescribe, y quien lea este log # dentro de seis meses tiene que entender por qué hay una excepción # justo aquí, sin ir a buscarla al código. log " ↻ CENTRAL_API_TOKEN: se ha SUSTITUIDO la credencial anterior" log " Por qué: el registro de hace un momento ha emitido una nueva y" log " ha invalidado la vieja. Es el único valor que se pisa al" log " reinstalar; el resto del .env (contraseñas de tu base de datos," log " secretos de sesión) se conserva intacto a propósito." log " Si no se sustituyera, este panel no podría registrar los" log " códigos de tus clientes y ellos verían «código no encontrado»." else printf 'CENTRAL_API_TOKEN=%s\n' "$CENTRAL_SYNC_TOKEN" >> "$PANEL_ENV" log " + añadida CENTRAL_API_TOKEN — la emite el servidor central" fi fi if [ "$VARIABLES_COMPLETADAS" -gt 0 ]; then log " OK configuración completada con $VARIABLES_COMPLETADAS ajuste(s) nuevos" else log " OK no faltaba nada en tu configuración" fi else log " Generando las contraseñas internas de tu panel" PG_PASSWORD=$(random_token) COOKIE_SECRET=$(random_token) CSRF_SECRET=$(random_token) PANEL_WG_PRIVATE_KEY_PASSPHRASE=$(random_token) # 🔴 NO se genera: la emite el central en /install/register-panel (ver # arriba). Inventársela aquí es lo que dejó a paneles enteros sin poder # sincronizar códigos. Vacía solo en el caso de licencia pendiente. CENTRAL_API_TOKEN="${CENTRAL_SYNC_TOKEN:-}" # Sub-Fase 3.22 — derive PANEL_HOST (sin scheme) y APP_HOST para # CORS, COOKIE_DOMAIN y Caddyfile. PANEL_HOST=$(echo "$PANEL_URL" | sed -E 's|https?://||; s|/.*||') APP_HOST="app.$PANEL_HOST" if [ "$DRY_RUN" = "true" ]; then log " [DRY] escribiría $PANEL_ENV con contraseñas nuevas" else cat > "$PANEL_ENV" < "$RENDER" # Ningún marcador puede quedar sin rellenar: uno suelto haría que # Caddy pidiera un certificado para un dominio inexistente. if grep -q "__[A-Z_]*__" "$RENDER"; then rm -f "$RENDER" fail "La plantilla del Caddyfile ha quedado con marcadores sin rellenar. Es un fallo del instalador, no de tu configuración. Repórtalo." fi # 🔴 Validación con el propio Caddy antes de instalar el fichero. if ! docker run --rm -v "$RENDER:/etc/caddy/Caddyfile:ro" \ caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1; then log " --- Caddyfile generado (para diagnóstico) ---" sed -n '1,40p' "$RENDER" rm -f "$RENDER" fail "El Caddyfile generado NO es válido. No se ha instalado nada. Revisa que el dominio introducido ($PANEL_HOST) sea correcto." fi # 🔴 ¿HA CAMBIADO LA CONFIGURACIÓN RESPECTO A LA QUE ESTÁ CORRIENDO? # # Hace falta saberlo aquí, antes de sobrescribir. El caso que lo obliga: # una primera instalación con --staging (certificado de pruebas) y # luego otra sin la bandera para el certificado bueno. El Caddyfile # cambia, pero el `docker compose up -d` de más abajo NO recrea Caddy # —el compose no ha cambiado, y el Caddyfile entra por bind-mount—, así # que seguiría sirviendo con la configuración vieja y el certificado de # pruebas indefinidamente. Antes se arreglaba diciéndole al admin que # reiniciara Caddy a mano: eso es justo lo que no puede ser. CADDY_CAMBIO="false" if [ ! -f "$CADDYFILE" ]; then CADDY_CAMBIO="true" elif ! cmp -s "$RENDER" "$CADDYFILE"; then CADDY_CAMBIO="true" fi # Y si Caddy no estaba corriendo, no hay nada que reiniciar: el `up -d` # lo creará con la configuración nueva ya puesta. Reiniciarlo recién # creado sería, además, cortarle la petición del certificado a medias. CADDY_CORRIENDO="false" if command -v docker >/dev/null 2>&1 && \ [ -n "$(docker ps -q -f name=encryptiontv-caddy 2>/dev/null)" ]; then CADDY_CORRIENDO="true" fi [ -f "$CADDYFILE" ] && cp "$CADDYFILE" "$CADDYFILE.bak-pre-install" mv "$RENDER" "$CADDYFILE" chmod 0644 "$CADDYFILE" if [ "$CADDY_CAMBIO" = "true" ] && [ "$CADDY_CORRIENDO" = "true" ]; then log " OK configuración lista — ha cambiado, así que recargaré el servidor web" else log " OK configuración lista y comprobada" fi fi # ── 5.c UFW ────────────────────────────────────────────────────────── # Sub-Fase 3.22 — regla para que Caddy (docker bridge) alcance panel-api # en network_mode:host. Sin esto, Caddy hace timeout contra # host.docker.internal:4100 (UFW deniega el entrante por defecto). if [ "$DRY_RUN" = "false" ] && command -v ufw >/dev/null 2>&1; then ufw allow from 172.18.0.0/16 to any port 4100 comment 'install.sh: Caddy bridge → panel-api host' >/dev/null 2>&1 || true ufw reload >/dev/null 2>&1 || true log " OK UFW: docker bridge 172.18.0.0/16 → 4100 permitido" fi # ✅ v4.35.1 — AQUÍ SE ESCRIBÍA apps/panel-web/.env.production con el # VITE_API_URL del admin, porque la SPA se compilaba en este VPS. Ya no # hace falta NADA: la SPA pide a `/api` de su propio origen y el # Caddyfile que este instalador genera lo enruta hacia panel-api. # La imagen de panel-web es idéntica para todos los admins — que es lo # que debe ser una imagen. Ver apps/panel-web/src/lib/api-base.ts. # ── 6. Levantar el panel (imágenes ya descargadas) ─────────────────── paso 6 "Arrancando el panel" tardara "Poniendo en marcha la base de datos y el panel." run docker compose -f "$INSTALL_DIR/infra/docker-compose.prod.yml" pull postgres caddy run docker compose -f "$INSTALL_DIR/infra/docker-compose.prod.yml" up -d # El reinicio que antes se le pedía al admin. `up -d` no recrea Caddy # cuando solo ha cambiado un fichero montado, así que se hace aquí y se # dice por qué — sin dejar ningún comando pendiente para después. if [ "${CADDY_CAMBIO:-false}" = "true" ] && [ "${CADDY_CORRIENDO:-false}" = "true" ]; then log " Recargando el servidor web con la configuración nueva" run docker compose -f "$INSTALL_DIR/infra/docker-compose.prod.yml" restart caddy log " OK recargado (si has cambiado de certificado de pruebas a" log " definitivo, tardará un momento en pedirlo)" fi # ── 7. Wait healthy ────────────────────────────────────── log " Esperando a que el panel responda (hasta dos minutos)" if [ "$DRY_RUN" = "false" ]; then for i in $(seq 1 60); do if curl -sf "http://127.0.0.1:4100/healthz" >/dev/null 2>&1; then log " OK el panel responde" break fi sleep 2 [ "$i" -eq 60 ] && fail "El panel se ha puesto en marcha pero no responde. Para ver qué dice y podérnoslo contar: sudo docker logs encryptiontv-panel-api --tail 50 No hace falta que lo entiendas: cópianoslo tal cual y lo miramos." done fi # ── 8. Migraciones: el propio panel-api las corre al boot (Fase 3.0). # ── 9. Insertar licencia firmada en portal_config singleton ── if [ "${LICENSE_PENDING:-false}" = "true" ]; then # F3 — mudanza: sin licencia que persistir. Se rellena custom_domain y # contact_email (el boot y la activación desde la UI los necesitan); el # panel arrancará en MODO ACTIVACIÓN y la re-vinculación pondrá el resto. # F5-E — nota sobre el email: aquí va el TECLEADO ($ADMIN_EMAIL) porque # aún no hay licencia de la que leerlo. NO es la divergencia que parece: # la activación (POST /admin/license/activate) SIEMPRE lo pisa con el # adminEmail FIRMADO del payload — y P3 (recuperación vía central) # verifica contra la cuenta del central, nunca contra esta columna. log " Guardando tu dominio (la licencia queda pendiente de confirmar)" SQL_UPDATE=$(cat </dev/null 2>&1; then log " OK el panel funciona con tu licencia" break fi sleep 2 [ "$i" -eq 60 ] && fail "El panel ha dejado de responder al activar la licencia. Para ver qué dice: sudo docker logs encryptiontv-panel-api --tail 50 Cópianoslo tal cual y lo miramos." done # Verifica el boot: licencia OK, o MODO ACTIVACIÓN si es una mudanza (F3). if [ "${LICENSE_PENDING:-false}" = "true" ]; then if docker logs encryptiontv-panel-api 2>&1 | grep -q "MODO ACTIVACIÓN"; then log " OK el panel espera a que confirmes el traslado de la licencia" else warn "log de panel-api no muestra 'MODO ACTIVACIÓN' — revisa docker logs" fi elif docker logs encryptiontv-panel-api 2>&1 | grep -q "licencia OK"; then log " OK licencia verificada" else warn "log de panel-api no muestra 'licencia OK' — revisa docker logs" fi # ── 🔴 v4.67.0 — ¿PUEDE ESTE PANEL REGISTRAR CÓDIGOS? ────────────── # # LA COMPROBACIÓN QUE NO EXISTÍA. Sin ella, una instalación terminaba # con un "✅ DONE" impecable y un panel que no podía registrar ni un # código: el admin entregaba códigos y sus clientes se encontraban # «código no encontrado» delante de la televisión. Dos días costó # encontrarlo, y no se encontró desde aquí — se encontró en los logs # del central, contando 401. # # Se usa la MISMA credencial y el MISMO guardián que usará el panel # después. Comprobar con otra cosa no comprueba nada. if [ "${LICENSE_PENDING:-false}" != "true" ]; then log " Comprobando que el panel puede registrar códigos" SYNC_CODE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 \ -H "Authorization: Bearer $CENTRAL_SYNC_TOKEN" \ "$CENTRAL_URL/admin/sync-check" 2>/dev/null || echo "000") if [ "$SYNC_CODE" = "200" ]; then log " OK el panel puede registrar códigos en el servidor central" elif [ "$SYNC_CODE" = "000" ]; then # Sin respuesta NO es lo mismo que rechazo. Un corte de red no debe # tumbar una instalación por lo demás correcta: se avisa y sigue. warn "no se ha podido contactar con el servidor central para comprobarlo" warn "(la instalación sigue; míralo en Salud del sistema al entrar)" else fail "Este panel NO puede registrar los códigos de tus clientes. El servidor central ha respondido $SYNC_CODE al comprobarlo. Qué significa: los códigos que generes existirían en tu panel, pero la app no los encontraría, y tus clientes verían «código no encontrado» al intentar activar. El panel parecería funcionar bien. El panel ha quedado instalado y puedes entrar, pero NO entregues códigos hasta que esto esté resuelto. Escríbenos con este mensaje y lo miramos: es un fallo de nuestra parte, no de tu configuración." fi fi fi # ── 7. ¿SE VE DESDE INTERNET? ──────────────────────────────────────── # 🔴 Lo que faltaba: comprobar el resultado, no solo los pasos. Hasta # aquí se podía terminar con un "✅ DONE" impecable y un panel que desde # fuera no se veía — porque el certificado aún no estaba, o porque el # cortafuegos del PROVEEDOR (el de su panel web, no el del servidor) # bloquea el 80 y el 443. Eso no se puede saber desde dentro mirando # configuración: hay que llamar a la puerta y ver si abren. paso 7 "Comprobando que tu panel se ve desde internet" VISIBLE="no" if [ "$DRY_RUN" = "true" ]; then log " [DRY] saltando la comprobación" VISIBLE="si" else # El certificado tarda unos segundos en emitirse la primera vez. # `-k` con --staging: ese certificado es de pruebas y el navegador lo # rechaza a propósito, así que validarlo aquí solo daría un falso # negativo. CURL_TLS="" [ "$STAGING" = "true" ] && CURL_TLS="-k" tardara "Pidiendo tu certificado de seguridad." for i in $(seq 1 30); do # shellcheck disable=SC2086 if curl -fsS $CURL_TLS --max-time 8 "https://$APP_HOST/" >/dev/null 2>&1; then VISIBLE="si"; break fi sleep 4 [ "$i" -eq 15 ] && log " Sigue emitiéndose el certificado; esto es normal…" done fi # ── 8. El mensaje que se lleva ─────────────────────────────────────── echo "" # Sin marco cerrado a la derecha: los emoji ocupan dos columnas en unos # terminales y una en otros, así que un marco "bonito" se descuadra justo # en el mensaje que más se mira. Una línea arriba y otra abajo no falla. echo "════════════════════════════════════════════════════════════" if [ "$VISIBLE" = "si" ]; then echo " ✅ TU PANEL YA ESTÁ FUNCIONANDO" else echo " ⚠️ TU PANEL ESTÁ INSTALADO, PERO NO SE VE DESDE FUERA" fi echo "════════════════════════════════════════════════════════════" echo "" echo " Entra aquí:" echo "" echo " https://$APP_HOST" echo "" # ── 🔴 CON QUÉ ENTRA — por fd 3, que NO se copia al registro ────────── # # Aquí se enseña la credencial en claro. Va por la pantalla de verdad y # no por la salida estándar a propósito: todo lo que sale por ahí acaba # en /var/log/encryptiontv-install.log, y esta credencial no es solo la # contraseña del panel — es también la llave con la que se RECUPERA el # acceso desde la pantalla de entrada. Dejarla escrita en un fichero del # servidor sería regalar las dos cosas a la vez. # # Se enseña, y no se remite al documento, porque decir "usa la que # elegiste" o "mírala en tu correo" es dejar tirado en el último paso a # quien compró hace tres semanas: lo tiene TODO hecho y no puede entrar. # El instalador la tiene delante —la acaba de teclear—, así que la # muestra. Lo que NO puede hacer es recuperarla si no la ha tecleado: el # central solo guarda su hash scrypt, a propósito, y ni nosotros podemos # leerla. sleep 0.3 if [ -n "${AUTH_HASH:-}" ]; then { echo " Con estos datos:" echo "" echo " Usuario $AUTH_EMAIL" if [ -n "${ACCOUNT_CREDENTIAL:-}" ]; then echo " Contraseña $ACCOUNT_CREDENTIAL" echo "" echo " Es tu credencial de EncryptionTV: la que acabas de escribir y la" echo " que viene en tu documento de bienvenida. Apúntala ahora si no la" echo " tienes a mano — no vuelve a aparecer." else # Sin credencial en memoria (instalación automatizada por # variables de entorno): se dice DÓNDE está, con nombre y apellidos. echo " Contraseña tu credencial de EncryptionTV, la que empieza" echo " por ETVA-, en el apartado TU CREDENCIAL de tu" echo " documento de bienvenida" fi echo "" echo " 🔒 Y si algún día pierdes la contraseña: en la propia pantalla de" echo " entrada tienes «¿Perdiste el acceso? Recupéralo con tu cuenta" echo " EncryptionTV». Con tu correo y esa misma credencial ETVA- el" echo " panel te da una contraseña nueva. Guarda la credencial: es la" echo " llave de repuesto de tu propio panel." } >&3 echo " (Tus datos de acceso se han mostrado en pantalla; no se guardan" echo " en este registro a propósito.)" else # Vía interna (clave de licencia suelta): el central no entrega # identidad, así que aquí NO hay usuario todavía. Se dice tal cual, en # vez de mandar a nadie a ejecutar un comando que necesitaría el # código fuente que ya no se entrega. echo " ⚠️ Este panel se ha instalado con una clave de licencia suelta," echo " sin cuenta, así que todavía no tiene ningún usuario con el" echo " que entrar. Escríbenos y lo dejamos listo." fi echo "" if [ "${LICENSE_PENDING:-false}" = "true" ]; then echo " Tu licencia estaba en uso en otra instalación: al entrar, el panel" echo " te pedirá que confirmes el traslado en Configuración → Facturación" echo " y licencia. Hasta entonces funcionará en modo limitado." echo "" fi if [ "$VISIBLE" = "si" ]; then echo " Ya puedes cerrar esta ventana: el panel se queda funcionando solo" echo " y vuelve a arrancar por su cuenta si reinicias el servidor." echo "" echo " Lo siguiente, ya desde el navegador:" echo " 1. Entra y cambia tu contraseña en Configuración → Cuenta." echo " 2. Conecta tu servidor de contenidos en Conexiones." echo " 3. Da de alta a tu primer cliente." else echo " El panel está instalado y funcionando dentro del servidor, pero" echo " desde fuera no responde todavía. Casi siempre es una de estas dos:" echo "" echo " · El cortafuegos de tu proveedor de servidores bloquea las" echo " puertas 80 y 443. Se abre en su web, no aquí." echo " · El cambio de dominio aún se está propagando. Espera 15" echo " minutos y prueba a entrar." echo "" echo " No hay que reinstalar nada: en cuanto se abra el paso, tu panel" echo " aparecerá solo en esa dirección." fi echo "" echo " Si algo se tuerce, escríbenos con esta línea:" echo " $PANEL_HOST · $(date -u +%Y-%m-%d) · registro en $INSTALL_LOG" echo ""