#!/usr/bin/env bash # Clusev installer — idempotent. Generates secrets, brings up the prod stack, # migrates the schema, and creates the first admin with a one-time random # password. Safe to re-run: existing secrets are preserved, never regenerated. # # ./install.sh # interactive (prompts for domain + admin email) # CLUSEV_DOMAIN=app.host.tld CLUSEV_ADMIN_EMAIL=admin@host.tld ./install.sh set -euo pipefail cd "$(dirname "$0")" COMPOSE="docker compose -f docker-compose.prod.yml" ENV_FILE=".env" # ── output helpers (German, no emoji) ──────────────────────────────── bold() { printf '\033[1m%s\033[0m\n' "$*"; } info() { printf ' %s\n' "$*"; } warn() { printf '\033[33m ! %s\033[0m\n' "$*"; } die() { printf '\033[31m x %s\033[0m\n' "$*" >&2; exit 1; } phase() { printf '\n\033[1m[%s] %s\033[0m\n' "$1" "$2"; } # ── .env mutators ──────────────────────────────────────────────────── # force_kv: always write (config/derived). set_kv: write only when empty/placeholder. force_kv() { local key="$1" val="$2" if grep -qE "^${key}=" "$ENV_FILE"; then sed -i "s|^${key}=.*|${key}=${val}|" "$ENV_FILE" else printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" fi } set_kv() { local key="$1" val="$2" cur="" if grep -qE "^${key}=" "$ENV_FILE"; then cur="$(grep -E "^${key}=" "$ENV_FILE" | head -n1 | cut -d= -f2-)" case "$cur" in ""|null|changeme|change-me-strong|change-me-strong-root) force_kv "$key" "$val" ;; *) : ;; # keep an existing real value — secrets are never regenerated esac else printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" fi } rand_hex() { openssl rand -hex "${1:-32}"; } # ── [1/7] preflight ────────────────────────────────────────────────── phase 1/7 "Voraussetzungen pruefen" command -v docker >/dev/null 2>&1 || die "docker nicht gefunden." docker compose version >/dev/null 2>&1 || die "docker compose (v2) nicht gefunden." command -v openssl >/dev/null 2>&1 || die "openssl nicht gefunden." info "docker + compose + openssl vorhanden" # ── inputs (interactive at a TTY, else env / bare-IP fallback) ─────── DOMAIN="${CLUSEV_DOMAIN:-}" ADMIN_EMAIL="${CLUSEV_ADMIN_EMAIL:-}" HTTP_PORT="${CLUSEV_HTTP_PORT:-80}" if [ -t 0 ]; then echo; bold "Clusev Installer"; echo "================" printf '%s' "Domain (leer = Zugriff per IP ueber HTTP): "; read -r in_domain || true [ -n "${in_domain:-}" ] && DOMAIN="$in_domain" if [ -z "$DOMAIN" ]; then printf '%s' "HTTP-Port (Standard 80): "; read -r in_port || true [ -n "${in_port:-}" ] && HTTP_PORT="$in_port" fi printf '%s' "Admin E-Mail (Login + Let's Encrypt): "; read -r in_email || true [ -n "${in_email:-}" ] && ADMIN_EMAIL="$in_email" fi HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"; [ -n "$HOST_IP" ] || HOST_IP="127.0.0.1" # derive every proxy/url var from the single APP_DOMAIN knob if [ -n "$DOMAIN" ]; then [ -n "$ADMIN_EMAIL" ] || die "Bei gesetzter Domain ist eine Admin E-Mail fuer Let's Encrypt Pflicht." APP_SCHEME="https"; APP_PORT="$HTTP_PORT" SITE_ADDRESS="https://${DOMAIN}" APP_URL="https://${DOMAIN}" REVERB_HOST="$DOMAIN"; REVERB_PORT="443"; REVERB_SCHEME="https" case "$HOST_IP" in 10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*|127.*) warn "Host-IP ${HOST_IP} ist privat (RFC1918). Let's Encrypt braucht oeffentlich erreichbare 80/443 — sonst schlaegt das Zertifikat fehl (dann DNS-01-Build noetig)." ;; esac else APP_SCHEME="http"; APP_PORT="$HTTP_PORT" SITE_ADDRESS=":${HTTP_PORT}" if [ "$HTTP_PORT" = "80" ]; then APP_URL="http://${HOST_IP}"; else APP_URL="http://${HOST_IP}:${HTTP_PORT}"; fi REVERB_HOST="$HOST_IP"; REVERB_PORT="$HTTP_PORT"; REVERB_SCHEME="http" warn "Bare-IP-Modus: Panel inkl. 2FA/Audit laeuft ueber KLARTEXT-HTTP. Fuer Produktion Domain + TLS setzen." fi # ── [2/7] .env + secrets (idempotent) ──────────────────────────────── phase 2/7 "Secrets generieren" if [ ! -f "$ENV_FILE" ]; then cp .env.example "$ENV_FILE"; info ".env aus .env.example erstellt"; else info ".env vorhanden — bestehende Werte bleiben erhalten"; fi set_kv DB_PASSWORD "$(rand_hex 24)" set_kv DB_ROOT_PASSWORD "$(rand_hex 24)" set_kv REDIS_PASSWORD "$(rand_hex 24)" set_kv REVERB_APP_ID "$(rand_hex 8)" set_kv REVERB_APP_KEY "$(rand_hex 16)" set_kv REVERB_APP_SECRET "$(rand_hex 32)" set_kv UPDATE_HMAC_KEY "$(rand_hex 32)" force_kv APP_ENV production force_kv APP_DEBUG false force_kv APP_URL "$APP_URL" force_kv APP_DOMAIN "$DOMAIN" force_kv APP_SCHEME "$APP_SCHEME" force_kv APP_PORT "$APP_PORT" force_kv ACME_EMAIL "$ADMIN_EMAIL" force_kv SITE_ADDRESS "$SITE_ADDRESS" force_kv REVERB_HOST "$REVERB_HOST" force_kv REVERB_PORT "$REVERB_PORT" force_kv REVERB_SCHEME "$REVERB_SCHEME" # Session hardening (control-plane self-security): strict same-site + end on # browser close everywhere; secure cookie ONLY behind TLS (else the cookie is # never returned over plain HTTP and login silently breaks). force_kv SESSION_SAME_SITE strict force_kv SESSION_EXPIRE_ON_CLOSE true if [ -n "$DOMAIN" ]; then force_kv SESSION_SECURE_COOKIE true; else force_kv SESSION_SECURE_COOKIE false; fi force_kv HOST_UID "$(id -u)" force_kv HOST_GID "$(id -g)" info "Secrets ok; Proxy/URL aus APP_DOMAIN abgeleitet" # ── [3/7] image ────────────────────────────────────────────────────── phase 3/7 "Image bauen" if [ "${CLUSEV_PULL:-0}" = "1" ]; then $COMPOSE pull; else $COMPOSE build; fi info "Image bereit" cur_key="$(grep -E '^APP_KEY=' "$ENV_FILE" | head -n1 | cut -d= -f2-)" case "$cur_key" in ""|base64:) NEW_KEY="$($COMPOSE run --rm --no-deps app php artisan key:generate --show 2>/dev/null | tr -d '\r' | tail -n1)" [ -n "$NEW_KEY" ] || die "APP_KEY konnte nicht erzeugt werden." force_kv APP_KEY "$NEW_KEY"; info "APP_KEY erzeugt" ;; *) info "APP_KEY vorhanden" ;; esac # ── [4/7] stack ────────────────────────────────────────────────────── phase 4/7 "Stack starten" $COMPOSE up -d info "Container gestartet" # ── restart sentinel (host watcher) — idempotent, best-effort ──────── # Installs the scoped systemd units so the dashboard's "Jetzt neu starten" button # works (it writes ./run/restart.request; the host unit restarts the stack). The # container never gets the Docker socket. Skipped (with a hint) when systemd or # sudo are unavailable — see docker/restart-sentinel/README.md for the manual path. install_restart_sentinel() { local src="docker/restart-sentinel" dst="/etc/systemd/system" proj sysd proj="$(pwd)" if ! command -v systemctl >/dev/null 2>&1; then warn "systemd nicht gefunden — Neustart-Watcher nicht installiert (siehe ${src}/README.md, Loop-Fallback)."; return 0 fi sysd="sudo"; [ "$(id -u)" = 0 ] && sysd="" if [ -n "$sysd" ] && ! sudo -n true 2>/dev/null; then warn "Kein passwortloses sudo — Neustart-Watcher nicht installiert. Manuell: ${src}/README.md."; return 0 fi # Render the units with THIS project's path + the invoking user (the units in the # repo default to /home/nexxo/clusev; rewrite to the real deploy location). local tmp_path tmp_svc tmp_path="$(mktemp)"; tmp_svc="$(mktemp)" sed "s#/home/nexxo/clusev#${proj}#g" "${src}/clusev-restart.path" > "$tmp_path" sed -e "s#/home/nexxo/clusev#${proj}#g" -e "s/^User=.*/User=$(id -un)/" "${src}/clusev-restart.service" > "$tmp_svc" if $sysd install -m 0644 "$tmp_path" "${dst}/clusev-restart.path" \ && $sysd install -m 0644 "$tmp_svc" "${dst}/clusev-restart.service" \ && $sysd systemctl daemon-reload \ && $sysd systemctl enable --now clusev-restart.path; then info "Neustart-Watcher aktiv (clusev-restart.path)" else warn "Neustart-Watcher konnte nicht installiert werden — siehe ${src}/README.md." fi rm -f "$tmp_path" "$tmp_svc" } install_restart_sentinel # ── [5/7] wait for the database ────────────────────────────────────── phase 5/7 "Datenbank bereit" db_ready=0 for _ in $(seq 1 60); do if $COMPOSE exec -T mariadb healthcheck.sh --connect --innodb_initialized >/dev/null 2>&1; then db_ready=1; info "MariaDB healthy"; break fi sleep 2 done [ "$db_ready" = 1 ] || die "MariaDB nicht rechtzeitig bereit." # ── [6/7] migrate + caches ─────────────────────────────────────────── phase 6/7 "Migrationen" $COMPOSE exec -T -u app app php artisan migrate --force $COMPOSE exec -T -u app app php artisan config:cache >/dev/null $COMPOSE exec -T -u app app php artisan route:cache >/dev/null info "Schema migriert; Caches gebaut" # ── [7/7] first admin ──────────────────────────────────────────────── phase 7/7 "Admin anlegen" email_args=() [ -n "$ADMIN_EMAIL" ] && email_args=(--email="$ADMIN_EMAIL") INSTALL_OUT="$($COMPOSE exec -T -u app app php artisan clusev:install "${email_args[@]}" 2>&1 || true)" ADMIN_PW="$(printf '%s\n' "$INSTALL_OUT" | sed -n 's/^CLUSEV_ADMIN_PASSWORD=//p' | head -n1)" ADMIN_MAIL="$(printf '%s\n' "$INSTALL_OUT" | sed -n 's/^CLUSEV_ADMIN_EMAIL=//p' | head -n1)" [ -n "$ADMIN_MAIL" ] || ADMIN_MAIL="$ADMIN_EMAIL" # ── closing banner (password shown ONLY here) ──────────────────────── echo echo "============================================================" bold " Clusev ist bereit." info "URL: ${APP_URL}" info "Admin: ${ADMIN_MAIL:-}" if [ -n "$ADMIN_PW" ]; then info "Passwort: ${ADMIN_PW} (nur jetzt sichtbar, nicht gespeichert)" else info "Passwort: " fi info "Erster Login: Passwort aendern + 2FA einrichten (Pflicht)." [ "$APP_SCHEME" = "http" ] && warn "HTTP ohne TLS — fuer Produktion eine Domain setzen und neu ausfuehren." echo "============================================================"