clusev/install.sh

178 lines
8.3 KiB
Bash
Executable File

#!/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"
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"
# ── [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:-<bestehend>}"
if [ -n "$ADMIN_PW" ]; then
info "Passwort: ${ADMIN_PW} (nur jetzt sichtbar, nicht gespeichert)"
else
info "Passwort: <bereits installiert — kein neuer Admin angelegt>"
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 "============================================================"