feat(installer): root one-command bootstrap — apt Docker install, clusev user, DNS check, themed MOTD
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
c54932b532
commit
4a3c572a89
|
|
@ -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' ""
|
||||
206
install.sh
206
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:-<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
|
||||
|
|
@ -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:-<bestehend>}"
|
||||
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: <bereits installiert — kein neuer Admin angelegt>"
|
||||
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:-<bestehend>}"
|
||||
if [ -n "$ADMIN_PW" ]; then
|
||||
info " Admin-Passwort: ${ADMIN_PW}"
|
||||
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."
|
||||
|
|
|
|||
Loading…
Reference in New Issue