#!/usr/bin/env bash # Clusev installer — idempotent one-command bootstrap (root required). # Installs Docker on Debian/Ubuntu (official apt repo), creates a dedicated # `clusev` host user, checks the domain's DNS, generates secrets, brings up the # prod stack, migrates the schema, creates the first admin with a one-time # random password, and installs a themed MOTD. Safe to re-run: existing secrets # and the clusev user are preserved, never regenerated. # # sudo ./install.sh # interactive (prompts for domain + admin email) # sudo 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"; } # set_stage: publish the current macro-stage (fetch|build|restart|migrate|done) to a file the # still-running app container serves to the browser's update-progress page, so it shows REAL # progress instead of a time guess (run/ is bind-mounted into the container). Strictly best-effort — # a write failure must NEVER abort the update, hence the guards + `return 0`. STAGE_FILE="run/update-phase.json" set_stage() { mkdir -p run 2>/dev/null || true printf '{"stage":"%s","at":%s}\n' "$1" "$(date +%s 2>/dev/null || echo 0)" > "$STAGE_FILE" 2>/dev/null || true chmod 644 "$STAGE_FILE" 2>/dev/null || true return 0 } # ── .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}"; } # env_get: read a key's current value from .env (empty if the file or key is absent). Lets a # re-run / update default to the EXISTING domain & ACME e-mail instead of wiping them. env_get() { [ -f "$ENV_FILE" ] && grep -E "^${1}=" "$ENV_FILE" | head -n1 | cut -d= -f2- || true; } # ── root + OS detection (before everything) ────────────────────────── [ "$(id -u)" = 0 ] || die "Bitte mit sudo ausfuehren: sudo ./install.sh" OS_ID=""; OS_LIKE=""; IS_APT=0 if [ -r /etc/os-release ]; then # shellcheck disable=SC1091 . /etc/os-release OS_ID="${ID:-}"; OS_LIKE="${ID_LIKE:-}" fi case " ${OS_ID} ${OS_LIKE} " in *debian*|*ubuntu*) IS_APT=1 ;; esac # ── [1/9] preflight + Docker ───────────────────────────────────────── phase 1/9 "Voraussetzungen pruefen" command -v openssl >/dev/null 2>&1 || die "openssl nicht gefunden." if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then info "docker + compose vorhanden" elif [ "$IS_APT" = 1 ]; then info "Docker nicht gefunden — Installation aus dem offiziellen Docker-apt-Repo ..." export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y ca-certificates curl install -m 0755 -d /etc/apt/keyrings curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" -o /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc # shellcheck disable=SC1091 codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")" printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/%s %s stable\n' \ "$(dpkg --print-architecture)" "$OS_ID" "$codename" \ > /etc/apt/sources.list.d/docker.list apt-get update apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin systemctl enable --now docker docker compose version >/dev/null 2>&1 || die "Docker-Installation fehlgeschlagen (docker compose weiterhin nicht verfuegbar)." info "Docker installiert + aktiviert" else die "Docker manuell installieren: https://docs.docker.com/engine/install/ , dann erneut ausfuehren." fi info "openssl vorhanden" # WireGuard gate (clusev wg ...) — present but inert until the operator runs `clusev wg setup`. if [ "$IS_APT" = 1 ]; then if DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard-tools qrencode >/dev/null 2>&1; then info "wireguard-tools + qrencode vorhanden" else warn "wireguard-tools/qrencode nicht installiert — 'clusev wg' erst nach 'apt-get install wireguard-tools qrencode' nutzbar." fi fi # ── [2/9] dedicated clusev user ────────────────────────────────────── phase 2/9 "Benutzer clusev" USER_CREATED=0 CLUSEV_USER_PW="" if ! id -u clusev >/dev/null 2>&1; then useradd -m -s /bin/bash clusev CLUSEV_USER_PW="$(openssl rand -base64 18)" printf 'clusev:%s\n' "$CLUSEV_USER_PW" | chpasswd USER_CREATED=1 info "Benutzer clusev angelegt" else info "Benutzer clusev vorhanden — unveraendert" fi usermod -aG docker clusev chown -R clusev:clusev . info "Installationsverzeichnis gehoert clusev" # ── inputs (interactive at a TTY, else env / bare-IP fallback) ─────── # Default to the EXISTING .env values so a non-interactive re-run (i.e. an update via # update.sh) preserves the configured domain / ACME e-mail instead of clearing them. On a # first install .env does not exist yet, so these resolve empty → bare-IP, unchanged. DOMAIN="${CLUSEV_DOMAIN:-$(env_get APP_DOMAIN)}" ADMIN_EMAIL="${CLUSEV_ADMIN_EMAIL:-$(env_get ACME_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 (leer = admin@clusev.local): "; 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" SERVER_IP="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)" [ -n "$SERVER_IP" ] || SERVER_IP="$HOST_IP" # ── domain DNS check (only when a domain is set) ───────────────────── DOMAIN_OK=0 if [ -n "$DOMAIN" ]; then RESOLVED="$(getent ahosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ')" case " $RESOLVED " in *" $SERVER_IP "*) DOMAIN_OK=1 ;; esac if [ "$DOMAIN_OK" = 1 ]; then info "DNS ok: ${DOMAIN} zeigt auf diesen Server (${SERVER_IP})" else warn "DNS-Pruefung: ${DOMAIN} aufgeloest auf [${RESOLVED:-}], dieser Server ist ${SERVER_IP}." if [ -t 0 ]; then echo " [1] Domain trotzdem uebernehmen (Cert kommt automatisch, sobald DNS hierher zeigt; bis dahin nur HTTP)" echo " [2] Per IP weitermachen (Domain verwerfen -> bare-IP)" printf '%s' " Auswahl [1]: "; read -r dns_choice || true case "${dns_choice:-1}" in 2) DOMAIN=""; warn "Domain verworfen — Installation per IP." ;; *) info "Domain uebernommen — TLS folgt automatisch, sobald DNS stimmt." ;; esac else info "Nicht-interaktiv: Domain wird uebernommen (TLS folgt automatisch, sobald DNS stimmt)." fi fi fi # 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 # ── [3/9] .env + secrets (idempotent) ──────────────────────────────── phase 3/9 "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 # Containers run as the dedicated clusev user (it owns the install dir + ./run). force_kv HOST_UID "$(id -u clusev)" force_kv HOST_GID "$(id -g clusev)" # Bake the deployed commit + branch into .env so the dashboard's Version page can show them in # production. The prod image ships NO .git (dockerignored), so a runtime .git read is impossible # there; we capture it here on the HOST (where .git exists) and config:cache freezes it later. git config --global --add safe.directory "$(pwd)" 2>/dev/null || true force_kv CLUSEV_BUILD_SHA "$(git rev-parse --short=7 HEAD 2>/dev/null || true)" force_kv CLUSEV_BUILD_BRANCH "$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" # Display times in the HOST's timezone (dashboard "geprüft"-Zeit etc.) instead of UTC. DB stays # UTC. Detect via systemd, then /etc/timezone, then the /etc/localtime symlink; default UTC. HOST_TZ="$(timedatectl show -p Timezone --value 2>/dev/null || true)" [ -n "$HOST_TZ" ] || HOST_TZ="$(cat /etc/timezone 2>/dev/null || true)" [ -n "$HOST_TZ" ] || HOST_TZ="$(readlink -f /etc/localtime 2>/dev/null | sed -n 's#.*/zoneinfo/##p')" [ -n "$HOST_TZ" ] || HOST_TZ="UTC" force_kv APP_TIMEZONE "$HOST_TZ" info "Secrets ok; Proxy/URL aus APP_DOMAIN abgeleitet; Zeitzone ${HOST_TZ}" # ── [4/9] image ────────────────────────────────────────────────────── phase 4/9 "Image bauen" set_stage build 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 # ── [5/9] stack ────────────────────────────────────────────────────── phase 5/9 "Stack starten" set_stage restart $COMPOSE up -d # Caddyfile changes ship as edits to a bind-mounted file; `up -d` does NOT recreate caddy for a # content-only change, so force it to re-read the (possibly updated) Caddyfile on every deploy. $COMPOSE up -d --force-recreate caddy >/dev/null 2>&1 || true info "Container gestartet" # ── host watchers (restart + update sentinels) — idempotent, best-effort ──── # Installs the scoped systemd units so the dashboard's "Jetzt neu starten" and # "Jetzt aktualisieren" buttons work (they write ./run/{restart,update}.request; the # host units act on them). The container never gets the Docker socket. Skipped (with a # hint) when systemd is unavailable — see docker/restart-sentinel/README.md. The restart # unit runs as the `clusev` user (docker group); the update unit runs as ROOT, because # update.sh -> install.sh needs root for docker/systemd/apt. install_host_watchers() { local src="docker/restart-sentinel" dst="/etc/systemd/system" proj proj="$(pwd)" if ! command -v systemctl >/dev/null 2>&1; then warn "systemd nicht gefunden — Host-Watcher nicht installiert (siehe ${src}/README.md, Loop-Fallback)."; return 0 fi # The container's ./run bind mount is owned by clusev; let clusev own it host-side too. mkdir -p ./run chown -R clusev:clusev ./run # Render the units with THIS project's path (the repo units default to /home/nexxo/clusev). local tmp_path tmp_svc tmp_upath tmp_usvc tmp_path="$(mktemp)"; tmp_svc="$(mktemp)"; tmp_upath="$(mktemp)"; tmp_usvc="$(mktemp)" local tmp_gate; tmp_gate="$(mktemp)" local tmp_csvc tmp_ctimer; tmp_csvc="$(mktemp)"; tmp_ctimer="$(mktemp)" # Restart units run as the clusev user. 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=clusev/" "${src}/clusev-restart.service" > "$tmp_svc" # Update units keep User=root (only the path is rewritten). sed "s#/home/nexxo/clusev#${proj}#g" "${src}/clusev-update.path" > "$tmp_upath" sed "s#/home/nexxo/clusev#${proj}#g" "${src}/clusev-update.service" > "$tmp_usvc" # Gate unit keeps User=root; only the path is rewritten. sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-gate.service" > "$tmp_gate" sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-collect.service" > "$tmp_csvc" sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-collect.timer" > "$tmp_ctimer" if install -m 0644 "$tmp_path" "${dst}/clusev-restart.path" \ && install -m 0644 "$tmp_svc" "${dst}/clusev-restart.service" \ && install -m 0644 "$tmp_upath" "${dst}/clusev-update.path" \ && install -m 0644 "$tmp_usvc" "${dst}/clusev-update.service" \ && install -m 0644 "$tmp_gate" "${dst}/clusev-wg-gate.service" \ && install -m 0644 "$tmp_csvc" "${dst}/clusev-wg-collect.service" \ && install -m 0644 "$tmp_ctimer" "${dst}/clusev-wg-collect.timer" \ && systemctl daemon-reload \ && systemctl enable --now clusev-restart.path \ && systemctl enable --now clusev-update.path \ && systemctl enable clusev-wg-gate.service \ && systemctl enable --now clusev-wg-collect.timer; then info "Host-Watcher aktiv (clusev-restart.path User=clusev, clusev-update.path User=root)" else warn "Host-Watcher konnten nicht installiert werden — siehe ${src}/README.md." fi rm -f "$tmp_path" "$tmp_svc" "$tmp_upath" "$tmp_usvc" "$tmp_gate" "$tmp_csvc" "$tmp_ctimer" } install_host_watchers # ── [6/9] wait for the database ────────────────────────────────────── phase 6/9 "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." # ── [7/9] migrate + caches ─────────────────────────────────────────── phase 7/9 "Migrationen" set_stage migrate $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" # ── [8/9] first admin ──────────────────────────────────────────────── phase 8/9 "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" # ── [9/9] Host-CLI + MOTD ──────────────────────────────────────────── phase 9/9 "Host-CLI + MOTD installieren" # clusev host command: bake the install dir into the template, drop it in PATH. The path is # printf %q shell-escaped before substitution so a checkout path containing shell syntax cannot # inject code into the generated CLI (which later runs as root via `sudo clusev update`). Literal # (not sed) substitution; best-effort with the whole group's stderr muted so a read-only # /usr/local/bin never fails the installer (no stray "Permission denied"). cli_template="$(cat docker/clusev/clusev)" cli_dir="$(printf '%q' "$(pwd)")" cli_rendered="${cli_template//__CLUSEV_DIR__/$cli_dir}" if { printf '%s\n' "$cli_rendered" > /usr/local/bin/clusev && chmod 0755 /usr/local/bin/clusev; } 2>/dev/null; then info "Host-Befehl installiert: clusev (z. B. 'clusev ps', 'sudo clusev update')" else info "Host-Befehl uebersprungen (/usr/local/bin nicht beschreibbar)" fi if [ -n "$DOMAIN" ] && [ "$DOMAIN_OK" = 1 ]; then ACCESS_URL="https://${DOMAIN}" elif [ -n "$DOMAIN" ]; then # Domain taken but DNS not yet pointing here — until then it's HTTP on the IP. ACCESS_URL="https://${DOMAIN}" else if [ "$HTTP_PORT" = "80" ]; then ACCESS_URL="http://${SERVER_IP}"; else ACCESS_URL="http://${SERVER_IP}:${HTTP_PORT}"; fi fi # Version (for the MOTD header) and the absolute compose path (the MOTD shows a LIVE # stack status via `docker compose ps` and runs from any cwd at login, so it needs the # full path). cwd is the repo root (cd at the top of this script). MOTD_VERSION="$(grep -oE "'version' => '[^']+'" config/clusev.php | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)" MOTD_COMPOSE="$(pwd)/docker-compose.prod.yml" motd_render() { sed -e "s|__CLUSEV_URL__|${ACCESS_URL}|" \ -e "s|__CLUSEV_VERSION__|${MOTD_VERSION}|" \ -e "s|__CLUSEV_COMPOSE__|${MOTD_COMPOSE}|" docker/motd/00-clusev } if [ -d /etc/update-motd.d ]; then motd_render > /etc/update-motd.d/00-clusev chmod +x /etc/update-motd.d/00-clusev info "Dynamisches MOTD installiert (/etc/update-motd.d/00-clusev)" else motd_render | bash > /etc/motd 2>/dev/null || true info "Statisches MOTD geschrieben (/etc/motd)" fi # Update fully applied — tell the in-browser progress page it may finish (real completion signal). set_stage "done" # ── closing banner (passwords shown ONLY here) ─────────────────────── echo bold "Installation erfolgreich." echo info "+----------------------------------------------------------+" info " Dashboard: ${ACCESS_URL}" info " Admin-Login: ${ADMIN_MAIL:-}" if [ -n "$ADMIN_PW" ]; then info " Admin-Passwort: ${ADMIN_PW} (Standard — beim ersten Login sofort aendern!)" else info " Admin-Passwort: " fi if [ "$USER_CREATED" = 1 ]; then info " Host-Benutzer: clusev" info " Host-Passwort: ${CLUSEV_USER_PW}" fi info "+----------------------------------------------------------+" info "Diese Passwoerter werden nur jetzt angezeigt."