From 4a3c572a890d8fb002300c2f7794fb24afc28e58 Mon Sep 17 00:00:00 2001 From: boban Date: Mon, 15 Jun 2026 19:11:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(installer):=20root=20one-command=20bootstr?= =?UTF-8?q?ap=20=E2=80=94=20apt=20Docker=20install,=20clusev=20user,=20DNS?= =?UTF-8?q?=20check,=20themed=20MOTD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- docker/motd/00-clusev | 15 +++ install.sh | 206 ++++++++++++++++++++++++++++++++---------- 2 files changed, 171 insertions(+), 50 deletions(-) create mode 100644 docker/motd/00-clusev diff --git a/docker/motd/00-clusev b/docker/motd/00-clusev new file mode 100644 index 0000000..433107d --- /dev/null +++ b/docker/motd/00-clusev @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Clusev themed MOTD — printed at host login (Debian/Ubuntu run-parts MOTD). +# Installed by install.sh; __CLUSEV_URL__ is substituted with the live access URL. +# Signal-orange brand, hairline rule, no emoji. Idempotent: install.sh only ever +# overwrites this single 00-clusev file, never other MOTD parts. +O=$'\033[38;5;208m' # signal-orange (brand) +D=$'\033[2m' # dim +B=$'\033[1m' # bold +R=$'\033[0m' # reset +printf '%s\n' "" +printf ' %s%sClus%sev%s %s· Fleet Control%s\n' "$B" "$O" "$R$B" "$R" "$D" "$R" +printf ' %s--------------------------------------%s\n' "$D" "$R" +printf ' Dashboard: %s%s%s\n' "$O" "__CLUSEV_URL__" "$R" +printf ' %sverwaltet vom Benutzer clusev · docker compose -f docker-compose.prod.yml ps%s\n' "$D" "$R" +printf '%s\n' "" diff --git a/install.sh b/install.sh index 1895c6a..ef06a66 100755 --- a/install.sh +++ b/install.sh @@ -1,10 +1,13 @@ #!/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. +# 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. # -# ./install.sh # interactive (prompts for domain + admin email) -# CLUSEV_DOMAIN=app.host.tld CLUSEV_ADMIN_EMAIL=admin@host.tld ./install.sh +# 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")" @@ -43,12 +46,64 @@ set_kv() { } 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." +# ── 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." -info "docker + compose + openssl vorhanden" + +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) ─────── DOMAIN="${CLUSEV_DOMAIN:-}" @@ -67,6 +122,33 @@ if [ -t 0 ]; then 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 @@ -87,8 +169,8 @@ else 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" +# ── [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 @@ -117,12 +199,13 @@ force_kv REVERB_SCHEME "$REVERB_SCHEME" 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)" +# 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)" info "Secrets ok; Proxy/URL aus APP_DOMAIN abgeleitet" -# ── [3/7] image ────────────────────────────────────────────────────── -phase 3/7 "Image bauen" +# ── [4/9] image ────────────────────────────────────────────────────── +phase 4/9 "Image bauen" if [ "${CLUSEV_PULL:-0}" = "1" ]; then $COMPOSE pull; else $COMPOSE build; fi info "Image bereit" @@ -135,37 +218,37 @@ case "$cur_key" in *) info "APP_KEY vorhanden" ;; esac -# ── [4/7] stack ────────────────────────────────────────────────────── -phase 4/7 "Stack starten" +# ── [5/9] stack ────────────────────────────────────────────────────── +phase 5/9 "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. +# container never gets the Docker socket. Skipped (with a hint) when systemd is +# unavailable — see docker/restart-sentinel/README.md for the manual path. The +# unit runs as the dedicated `clusev` user (a member of the docker group). install_restart_sentinel() { - local src="docker/restart-sentinel" dst="/etc/systemd/system" proj sysd + 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 — 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 + # 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 clusev 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)" + sed -e "s#/home/nexxo/clusev#${proj}#g" -e "s/^User=.*/User=clusev/" "${src}/clusev-restart.service" > "$tmp_svc" + if install -m 0644 "$tmp_path" "${dst}/clusev-restart.path" \ + && install -m 0644 "$tmp_svc" "${dst}/clusev-restart.service" \ + && systemctl daemon-reload \ + && systemctl enable --now clusev-restart.path; then + info "Neustart-Watcher aktiv (clusev-restart.path, User=clusev)" else warn "Neustart-Watcher konnte nicht installiert werden — siehe ${src}/README.md." fi @@ -173,8 +256,8 @@ install_restart_sentinel() { } install_restart_sentinel -# ── [5/7] wait for the database ────────────────────────────────────── -phase 5/7 "Datenbank bereit" +# ── [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 @@ -184,15 +267,15 @@ for _ in $(seq 1 60); do done [ "$db_ready" = 1 ] || die "MariaDB nicht rechtzeitig bereit." -# ── [6/7] migrate + caches ─────────────────────────────────────────── -phase 6/7 "Migrationen" +# ── [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" -# ── [7/7] first admin ──────────────────────────────────────────────── -phase 7/7 "Admin anlegen" +# ── [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)" @@ -200,17 +283,40 @@ ADMIN_PW="$(printf '%s\n' "$INSTALL_OUT" | sed -n 's/^CLUSEV_ADMIN_PASSWORD=// 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)" +# ── [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 - info "Passwort: " + if [ "$HTTP_PORT" = "80" ]; then ACCESS_URL="http://${SERVER_IP}"; else ACCESS_URL="http://${SERVER_IP}:${HTTP_PORT}"; fi 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 "============================================================" +if [ -d /etc/update-motd.d ]; then + sed "s|__CLUSEV_URL__|${ACCESS_URL}|" docker/motd/00-clusev > /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 + sed "s|__CLUSEV_URL__|${ACCESS_URL}|" docker/motd/00-clusev | 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:-}" +if [ -n "$ADMIN_PW" ]; then + info " Admin-Passwort: ${ADMIN_PW}" +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."