#!/usr/bin/env bash # # Noctcom — self-host installer # # curl -fsSL https://raw.githubusercontent.com/RedderLabs/noctcom/main/install.sh | bash # # Clona el repo, genera un .env con secretos seguros y levanta el stack con # Docker Compose (PostgreSQL + DragonflyDB + MinIO + backend + frontend + Caddy # con TLS automático). Idempotente: si ya existe un .env, conserva tus secretos. # # Variables de entorno opcionales (para instalación no interactiva): # NOCTCOM_DIR=noctcom carpeta destino # NOCTCOM_DOMAIN=example.com dominio base (usa app. y api.) # NOCTCOM_EMAIL=you@mail.com email para los certificados Let's Encrypt # NOCTCOM_LAN_IP=auto modo LAN sin dominio: IP a usar ('auto' = detectar) # NOCTCOM_NONINTERACTIVE=1 no preguntar; sin NOCTCOM_DOMAIN usa modo LAN # NOCTCOM_NO_START=1 solo prepara .env, no arranca los contenedores # # Sin dominio → modo LAN: HTTP plano por IP (app en :80, API en :3000), ideal # para homelab/LXC. Con dominio → Caddy emite TLS automático (Let's Encrypt). # set -euo pipefail REPO_URL="https://github.com/RedderLabs/noctcom.git" REF="${NOCTCOM_REF:-main}" # rama/tag a instalar (main por defecto) DIR="${NOCTCOM_DIR:-noctcom}" # ─── Colores ──────────────────────────────────────────────────── if [ -t 1 ]; then B=$'\033[1m'; DIM=$'\033[2m'; G=$'\033[32m'; Y=$'\033[33m'; R=$'\033[31m'; V=$'\033[35m'; N=$'\033[0m' else B=""; DIM=""; G=""; Y=""; R=""; V=""; N="" fi say() { printf "%s\n" "$*"; } ok() { printf "${G}✓${N} %s\n" "$*"; } warn() { printf "${Y}!${N} %s\n" "$*"; } die() { printf "${R}✗ %s${N}\n" "$*" >&2; exit 1; } hr() { printf "${DIM}────────────────────────────────────────────────────────${N}\n"; } # Lee de la terminal aunque el script venga por una tubería (curl | bash). ask() { # ask local prompt="$1" default="$2" __var="$3" reply="" if [ "${NOCTCOM_NONINTERACTIVE:-0}" = "1" ] || [ ! -e /dev/tty ]; then printf -v "$__var" '%s' "$default"; return fi printf "${B}%s${N}${DIM}%s${N} " "$prompt" "${default:+ [$default]}" > /dev/tty read -r reply < /dev/tty || true printf -v "$__var" '%s' "${reply:-$default}" } # Secreto seguro y URL-safe (sin caracteres que rompan DATABASE_URL/CACHE_URL). secret() { openssl rand -hex 24; } printf "\n${V}${B} Noctcom${N} ${DIM}· self-host installer${N}\n" printf "${DIM} Zero-knowledge encrypted storage · AGPL-3.0${N}\n\n" # ─── 1. Requisitos ────────────────────────────────────────────── say "${B}1. Comprobando requisitos…${N}" # Prefijo para mandar como root cuando haga falta (instalar paquetes, o hablar # con el demonio de Docker si el usuario aún no está en el grupo 'docker'). if [ "$(id -u)" = "0" ]; then SUDO=""; elif command -v sudo >/dev/null 2>&1; then SUDO="sudo"; else SUDO=""; fi # Instala paquetes base con el gestor que haya (best-effort, multi-distro). pkg_install() { # pkg_install if command -v apt-get >/dev/null 2>&1; then $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -yqq "$@" >/dev/null 2>&1 elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y "$@" >/dev/null 2>&1 elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y "$@" >/dev/null 2>&1 elif command -v apk >/dev/null 2>&1; then $SUDO apk add --no-cache "$@" >/dev/null 2>&1 elif command -v pacman >/dev/null 2>&1; then $SUDO pacman -Sy --noconfirm "$@" >/dev/null 2>&1 else return 1; fi } # Utilidades base: si faltan, intenta instalarlas; si no se puede, aborta claro. for tool in curl git openssl; do if ! command -v "$tool" >/dev/null 2>&1; then warn "Falta '$tool' — intentando instalarlo…" pkg_install "$tool" || true command -v "$tool" >/dev/null 2>&1 || die "No pude instalar '$tool'. Instálalo a mano y reintenta." fi done # Docker: si falta, lo instalamos con el script oficial (get.docker.com), igual # que el instalador de Proxmox dentro del LXC → VPS/PC en un solo comando. if ! command -v docker >/dev/null 2>&1; then DO_DOCKER="y" if [ "${NOCTCOM_NONINTERACTIVE:-0}" != "1" ] && [ -e /dev/tty ]; then ask " Docker no está instalado. ¿Lo instalo ahora (get.docker.com)? (y/n):" "y" DO_DOCKER fi case "$DO_DOCKER" in [yYsS]*) warn "Instalando Docker con el script oficial (get.docker.com)…" curl -fsSL https://get.docker.com | $SUDO sh \ || die "No se pudo instalar Docker. Hazlo a mano: https://docs.docker.com/engine/install/" $SUDO systemctl enable --now docker >/dev/null 2>&1 || true ok "Docker instalado" ;; *) die "Docker es necesario. Instálalo y reintenta. → https://docs.docker.com/engine/install/" ;; esac fi # El demonio debe responder. Si solo responde con sudo (usuario recién añadido al # grupo 'docker', que aún no aplica en esta sesión), usamos sudo para TODO Docker. DOCKER="docker" if docker info >/dev/null 2>&1; then : elif [ -n "$SUDO" ] && $SUDO docker info >/dev/null 2>&1; then DOCKER="$SUDO docker" else die "El demonio de Docker no responde (¿arrancado? ¿permisos?). Prueba: ${SUDO:-sudo} systemctl start docker" fi # Docker Compose v2 (plugin) o el binario clásico, con el mismo prefijo. if $DOCKER compose version >/dev/null 2>&1; then DC="$DOCKER compose" elif command -v docker-compose >/dev/null 2>&1; then DC="${SUDO:+$SUDO }docker-compose" else die "Falta Docker Compose v2 (plugin 'docker compose'). → https://docs.docker.com/compose/install/" fi ok "git, openssl y Docker ($DC) disponibles" # ─── 2. Código ────────────────────────────────────────────────── say "" say "${B}2. Obteniendo el código…${N}" if [ -d "$DIR/.git" ]; then ok "Repo ya presente en '$DIR' — actualizando" git -C "$DIR" pull --ff-only || warn "No se pudo hacer fast-forward; sigo con lo que hay." else git clone --depth 1 --branch "$REF" "$REPO_URL" "$DIR" ok "Clonado en '$DIR' (ref: $REF)" fi cd "$DIR" # ─── 3. Configuración (.env) ──────────────────────────────────── say "" say "${B}3. Configuración…${N}" if [ -f .env ]; then warn ".env ya existe — conservo tus secretos y no lo toco." if ! grep -q '^COMPOSE_FILE=' .env; then printf '\n# Fijado por install.sh: evita cargar docker-compose.override.yml (solo desarrollo).\nCOMPOSE_FILE=docker-compose.yml\n' >> .env warn "Añadido COMPOSE_FILE a .env (el override de desarrollo publicaba puertos de postgres/dragonfly/minio al host)." fi # Migración Redis → DragonflyDB: cambia el motor de la caché, no el secreto. # Renombramos la clave conservando su valor para que un .env anterior siga # sirviendo (compose exige CACHE_PASSWORD). Lo que había dentro era caché # (contadores con TTL), así que no hay nada que trasladar. if grep -q '^REDIS_PASSWORD=' .env && ! grep -q '^CACHE_PASSWORD=' .env; then sed -i.bak -E 's|^REDIS_PASSWORD=|CACHE_PASSWORD=|' .env && rm -f .env.bak warn "Caché migrada a DragonflyDB (REDIS_PASSWORD → CACHE_PASSWORD en .env)." fi # Modo self-host: si falta, lo añadimos para que el frontend se hornee con el # panel operativo (capacidad por disco + salud del stack + '/' al login). Las # instalaciones previas lo recogen al reejecutar install.sh/update.sh. if ! grep -q '^SELF_HOST=' .env; then printf '\n# Build self-host (panel operativo). Escrito por install.sh.\nSELF_HOST=true\n' >> .env warn "Añadido SELF_HOST=true a .env — se reconstruye el frontend con el panel self-host." fi # Migración a same-origin (solo modo LAN): si la API se horneó en http://:3000, # vaciamos PUBLIC_API_URL para que el frontend use URLs relativas (mismo origen, # vía Caddyfile.lan) y quitamos el :3000 de PUBLIC_URL (las subidas de chunks # viajan ahora por el :80 bajo /api). Deja de depender de la IP y del puerto 3000. if grep -q '^CADDY_DOMAIN=localhost' .env && grep -qE '^PUBLIC_API_URL=.+' .env; then sed -i.bak -E 's|^PUBLIC_API_URL=.*|PUBLIC_API_URL=|' .env sed -i.bak -E 's|^(PUBLIC_URL=https?://[^:/]+):3000$|\1|' .env rm -f .env.bak warn "Modo LAN migrado a same-origin (PUBLIC_API_URL vaciado → URLs relativas). Se reconstruye el frontend." fi else [ -f .env.example ] || die "No encuentro .env.example en el repo." DOMAIN="${NOCTCOM_DOMAIN:-}" [ -z "$DOMAIN" ] && ask " Dominio base (vacío = modo LAN por IP, sin TLS):" "" DOMAIN EMAIL="${NOCTCOM_EMAIL:-}" [ -z "$EMAIL" ] && [ -n "$DOMAIN" ] && ask " Email para los certificados TLS:" "admin@$DOMAIN" EMAIL COMPOSE_FILES="docker-compose.yml" if [ -n "$DOMAIN" ]; then FRONT="https://app.$DOMAIN"; API="https://api.$DOMAIN"; APIBASE="$API"; FROM="noreply@$DOMAIN" else # Modo LAN same-origin con HTTPS INTERNO: sin dominio. Caddy (Caddyfile.lan) # emite un certificado autofirmado para la IP (tls internal) y sirve la web Y # la API en https:// (enruta /api y /health al backend). HTTPS es # OBLIGATORIO: en http plano el navegador desactiva Web Crypto y el cifrado # zero-knowledge no funciona (login/sesión/subidas rotas). El frontend usa # URLs RELATIVAS → PUBLIC_API_URL VACÍO (aguanta cambios de IP sin rehornear). # CADDY_DOMAIN = la IP, que es lo que el Caddyfile.lan presenta como cert. LAN_IP="${NOCTCOM_LAN_IP:-auto}" if [ "$LAN_IP" = "auto" ]; then # '|| true': con pipefail, un 'hostname -I' que falle mataría el script. LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" fi [ -z "$LAN_IP" ] && LAN_IP="127.0.0.1" DOMAIN="$LAN_IP"; EMAIL="${EMAIL:-admin@localhost}" FRONT="https://$LAN_IP"; API=""; APIBASE="https://$LAN_IP"; FROM="noreply@localhost" COMPOSE_FILES="docker-compose.yml:docker-compose.lan.yml" warn "Sin dominio: modo LAN por IP con HTTPS interno ($LAN_IP). El navegador avisará del certificado autofirmado la 1ª vez (acéptalo). Para cero avisos, usa un dominio." fi cp .env.example .env sed_i() { sed -i.bak "s|$1|$2|" .env && rm -f .env.bak; } sed_i '^CADDY_DOMAIN=.*' "CADDY_DOMAIN=$DOMAIN" sed_i '^CADDY_EMAIL=.*' "CADDY_EMAIL=$EMAIL" sed_i '^POSTGRES_PASSWORD=.*' "POSTGRES_PASSWORD=$(secret)" sed_i '^CACHE_PASSWORD=.*' "CACHE_PASSWORD=$(secret)" sed_i '^MINIO_ROOT_PASSWORD=.*' "MINIO_ROOT_PASSWORD=$(secret)" sed_i '^JWT_SECRET=.*' "JWT_SECRET=$(openssl rand -base64 64 | tr -d '\n')" sed_i '^PUBLIC_URL=.*' "PUBLIC_URL=$APIBASE" sed_i '^PUBLIC_API_URL=.*' "PUBLIC_API_URL=$API" sed_i '^FRONTEND_URL=.*' "FRONTEND_URL=$FRONT" sed_i '^SMTP_FROM=.*' "SMTP_FROM=$FROM" # COMPOSE_FILE fija qué ficheros usa 'docker compose' desde esta carpeta: # nunca el override de desarrollo; en modo LAN añade docker-compose.lan.yml. printf '\n# Ficheros compose (escrito por install.sh).\nCOMPOSE_FILE=%s\n' "$COMPOSE_FILES" >> .env # Todo lo instalado por install.sh es self-host → activa el panel operativo # (capacidad por disco, salud del stack, sin cuota de plan) y '/' → login. printf '\n# Build self-host (panel operativo). Escrito por install.sh.\nSELF_HOST=true\n' >> .env # Discos de datos extra (opcional, ver sección 3b). Vacío por defecto; se puede # pasar NOCTCOM_EXTRA_DATA_DIR en la 1ª instalación o editar .env luego. printf '\n# Discos de datos extra para el backend (rutas coma-separadas). Ver .env.example.\nEXTRA_DATA_DIR=%s\n' "${NOCTCOM_EXTRA_DATA_DIR:-}" >> .env chmod 600 .env ok "Generado .env con secretos aleatorios (chmod 600)" say "${DIM} Email (verificación/OTP) desactivado por defecto: añade RESEND_API_KEY o SMTP_* en .env.${N}" fi # ─── 3b. Discos de datos extra (EXTRA_DATA_DIR) ───────────────── # Añade discos del PROPIO servidor (interno/USB, o un recurso de red ya montado # en el host; en Proxmox, el disco pasado al LXC con 'pct set -mpN') sin editar # compose a mano. Cada ruta de EXTRA_DATA_DIR se bind-monta en el backend con el # MISMO path (lo que registras en la web coincide). Idempotente: regenera el # fichero y lo mantiene en COMPOSE_FILE; si está vacío, lo retira. DISKS_FILE="docker-compose.disks.yml" EXTRA_DIRS="$(grep -E '^EXTRA_DATA_DIR=' .env 2>/dev/null | head -1 | cut -d= -f2-)" EXTRA_DIRS="${EXTRA_DIRS%\"}"; EXTRA_DIRS="${EXTRA_DIRS#\"}" if [ -n "$(printf '%s' "$EXTRA_DIRS" | tr -d ', ')" ]; then { printf 'services:\n backend:\n volumes:\n' OLDIFS="$IFS"; IFS=',' for d in $EXTRA_DIRS; do d="$(printf '%s' "$d" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" [ -z "$d" ] && continue mkdir -p "$d" 2>/dev/null || true printf ' - "%s:%s"\n' "$d" "$d" done IFS="$OLDIFS" } > "$DISKS_FILE" if ! grep -q "$DISKS_FILE" .env; then sed -i.bak -E "s|^(COMPOSE_FILE=.*)$|\1:$DISKS_FILE|" .env && rm -f .env.bak fi ok "Discos extra para el backend: $EXTRA_DIRS" else rm -f "$DISKS_FILE" if grep -q "$DISKS_FILE" .env; then sed -i.bak -E "s|:$DISKS_FILE||g" .env && rm -f .env.bak fi fi # ─── 4. Arranque ──────────────────────────────────────────────── if [ "${NOCTCOM_NO_START:-0}" = "1" ]; then say "" warn "NOCTCOM_NO_START=1 — no arranco. Revisa .env y luego: ${B}$DC up -d --build${N}" exit 0 fi say "" say "${B}4. Construyendo y levantando (la primera vez tarda unos minutos)…${N}" # --remove-orphans: retira contenedores del proyecto que ya no existen en el # compose. Es lo que se lleva el antiguo 'noctcom-redis' al actualizar a # DragonflyDB (si no, seguiría corriendo y comiendo memoria sin que nadie lo use). $DC up -d --build --remove-orphans # Caddy NO se recrea cuando solo cambia el Caddyfile montado (no su imagen), así # que se quedaría con la config vieja (p. ej. el enrutado /api del modo LAN). Lo # reiniciamos para que cargue siempre la configuración actual. $DC restart caddy >/dev/null 2>&1 || true ok "Contenedores en marcha" # Permisos de los discos extra: el backend corre como usuario 'app' (no root), # así que las rutas bind-montadas deben pertenecerle para poder escribir (si no, # registrar el volumen daría "path is not a writable directory"). Leemos el uid # real de 'app' del contenedor y ajustamos SOLO el directorio raíz (no recursivo: # respeta cualquier dato previo del disco). if [ -n "${EXTRA_DIRS:-}" ] && [ -n "$(printf '%s' "$EXTRA_DIRS" | tr -d ', ')" ]; then APP_UID=""; APP_GID="" for _ in 1 2 3 4 5; do APP_UID="$($DC exec -T backend id -u 2>/dev/null | tr -dc '0-9')" || true APP_GID="$($DC exec -T backend id -g 2>/dev/null | tr -dc '0-9')" || true [ -n "$APP_UID" ] && [ -n "$APP_GID" ] && break sleep 2 done if [ -n "$APP_UID" ] && [ -n "$APP_GID" ]; then OLDIFS="$IFS"; IFS=',' for d in $EXTRA_DIRS; do d="$(printf '%s' "$d" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" [ -n "$d" ] && chown "$APP_UID:$APP_GID" "$d" 2>/dev/null || true done IFS="$OLDIFS" ok "Permisos de discos extra ajustados (app uid $APP_UID)" else warn "No pude leer el uid de 'app' del backend; si registrar un disco extra falla, ajusta el dueño del directorio a mano." fi fi # ─── 5. Migraciones de paridad ────────────────────────────────── # Los scripts docker/postgres/init/ SOLO corren con el volumen de Postgres vacío # (primer arranque). En una BD ya existente (típico tras 'update.sh') las columnas # nuevas no se aplicarían, rompiendo signup y la subida de archivos. Este paso # aplica los parches de paridad idempotentes contra el Postgres en marcha. say "" say "${B}5. Sincronizando schema (migraciones de paridad)…${N}" if [ -f scripts/selfhost-db-sync.sh ]; then if DC="$DC" bash scripts/selfhost-db-sync.sh; then ok "Schema al día" else warn "No se pudo sincronizar el schema. Ejecútalo luego: ${B}bash scripts/selfhost-db-sync.sh${N}" fi else warn "Falta scripts/selfhost-db-sync.sh — actualiza el repo (git pull) y reintenta." fi # ─── 5b. Aviso de cambio de IP (modo LAN) ─────────────────────── # En modo LAN la IP del servidor queda horneada en PUBLIC_URL/CADDY_DOMAIN (la # usan las subidas de chunks). Si la IP cambió desde la instalación (DHCP sin # reserva), el login aguanta (rutas relativas) pero las subidas apuntarían a la # IP vieja. Avisamos con la solución (reservar la IP o re-hornear con la nueva). if grep -qE '^COMPOSE_FILE=.*docker-compose\.lan\.yml' .env; then BAKED_IP="$(grep -E '^CADDY_DOMAIN=' .env | cut -d= -f2- || true)" CUR_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" if [ -n "$CUR_IP" ] && [ -n "$BAKED_IP" ] && [ "$CUR_IP" != "$BAKED_IP" ]; then say "" warn "La IP de este servidor (${B}$CUR_IP${N}) no coincide con la de la instalación (${B}$BAKED_IP${N})." say "${DIM} El acceso sigue (rutas relativas), pero las subidas grandes usan la IP horneada.${N}" say "${DIM} Recomendado: reserva ${BAKED_IP} en tu DHCP. O re-hornea con la IP nueva editando${N}" say "${DIM} CADDY_DOMAIN y PUBLIC_URL en .env a ${CUR_IP} y re-ejecutando: ${B}bash update.sh${N}${DIM}.${N}" fi fi # ─── Resumen ──────────────────────────────────────────────────── DOM="$(grep -E '^CADDY_DOMAIN=' .env | cut -d= -f2- || true)" FRONT_URL="$(grep -E '^FRONTEND_URL=' .env | cut -d= -f2- || true)" LANMODE=0; grep -qE '^COMPOSE_FILE=.*docker-compose\.lan\.yml' .env && LANMODE=1 say "" hr printf "${G}${B} ¡Noctcom está arrancando!${N}\n" hr if [ "$LANMODE" = "1" ]; then say " App + API: ${B}${FRONT_URL}${N} ${DIM}(API bajo ${FRONT_URL}/api)${N}" say " ${Y}! La 1ª vez el navegador avisará del certificado autofirmado (HTTPS interno): acéptalo.${N}" say " ${DIM}Modo LAN same-origin con HTTPS interno, accesible desde tu red. Para cero avisos usa un dominio.${N}" say " ${DIM}Inicia sesión por esta URL desde cualquier equipo de la red. La web usa rutas${N}" say " ${DIM}relativas, así que sigue funcionando aunque cambie la IP de esta máquina.${N}" else say " App: ${B}https://app.$DOM${N}" say " API: ${B}https://api.$DOM${N}" say "" say " ${Y}Apunta estos DNS (registros A) a la IP de este servidor:${N}" say " app.$DOM → " say " api.$DOM → " say " ${DIM}Caddy emitirá los certificados TLS automáticamente al resolver el DNS.${N}" fi say "" say " ${DIM}(desde la carpeta '$DIR')${N}" say " Ver logs: ${B}$DC logs -f${N}" say " Parar: ${B}$DC down${N} · Actualizar: ${B}bash update.sh${N}" say " Backup: ${B}bash scripts/backup.sh${N} · Restaurar: ${B}bash scripts/restore.sh ${N}" say " Email: añade ${B}RESEND_API_KEY${N} o ${B}SMTP_*${N} en .env para verificación/OTP." say "" say " ${DIM}Self-host gratis para siempre · AGPL-3.0 · https://noctcom.com${N}" say ""