359 lines
18 KiB
Bash
Executable File
359 lines
18 KiB
Bash
Executable File
#!/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"; }
|
|
|
|
# ── .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"
|
|
|
|
# ── [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:-<keine>}], 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"
|
|
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"
|
|
$COMPOSE up -d
|
|
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)"
|
|
# 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"
|
|
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" \
|
|
&& systemctl daemon-reload \
|
|
&& systemctl enable --now clusev-restart.path \
|
|
&& systemctl enable --now clusev-update.path; 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"
|
|
}
|
|
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"
|
|
$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] MOTD (themed, shown at host login) ─────────────────────────
|
|
phase 9/9 "MOTD installieren"
|
|
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
|
|
|
|
# ── closing banner (passwords shown ONLY here) ───────────────────────
|
|
echo
|
|
bold "Installation erfolgreich."
|
|
echo
|
|
info "+----------------------------------------------------------+"
|
|
info " Dashboard: ${ACCESS_URL}"
|
|
info " Admin-Login: ${ADMIN_MAIL:-<bestehend>}"
|
|
if [ -n "$ADMIN_PW" ]; then
|
|
info " Admin-Passwort: ${ADMIN_PW} (Standard — beim ersten Login sofort aendern!)"
|
|
else
|
|
info " Admin-Passwort: <bereits installiert — kein neuer Admin angelegt>"
|
|
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."
|