#!/usr/bin/env bash # # Laufzettel – native Installation auf Debian 13, ohne Docker (#019). # # Ein zweiter Aufruf aktualisiert statt neu anzulegen: Quellcode holen, bauen, # Migrationen einspielen, Dienst neu starten. Geheimnisse und Daten bleiben # unangetastet — /etc/laufzettel/laufzettel.env wird nie ueberschrieben. # # sudo bash install.sh # installieren oder aktualisieren # bash install.sh --hilfe # alle Schalter # # Die systemd-Unit steht in diesem Skript und nicht als zweite Datei daneben: # der Proxmox-Weg laedt genau diese eine Datei in den frischen Container, und # eine zweite Datei waere eine, die man vergessen kann. set -Eeuo pipefail # ---------- Festwerte ------------------------------------------------------- readonly ZIEL="/opt/laufzettel" readonly STANDALONE="$ZIEL/.next/standalone" # Heimatverzeichnis des Dienstnutzers: kein Arbeitskonto, sondern der # Zwischenspeicher von pnpm und corepack. So bleibt der Checkout unter /opt # sauber und `git pull` findet nichts Fremdes vor. readonly HEIM="/var/cache/laufzettel" readonly ETC_VERZEICHNIS="/etc/laufzettel" readonly UMGEBUNGSDATEI="$ETC_VERZEICHNIS/laufzettel.env" readonly UNIT_DATEI="/etc/systemd/system/laufzettel.service" readonly DIENSTNUTZER="laufzettel" readonly DIENSTNAME="laufzettel.service" readonly DATENBANK="laufzettel" readonly DB_ROLLE="laufzettel" readonly SOCKET_VERZEICHNIS="/run/postgresql" readonly QUELLE_STANDARD="https://git.wilde-server.de/florian.wilde/Laufzettel.git" readonly SCHRITTE_GESAMT=9 # ---------- Schalter -------------------------------------------------------- # Jeder Wert laesst sich als Schalter oder als Umgebungsvariable mitgeben. Der # Proxmox-Weg (deploy/proxmox/laufzettel.sh) ruft dieses Skript ueber `pct exec` # auf und kann nur die Umgebung setzen; die Namen sind dieselben wie in # .env.example, damit es nur eine Schreibweise gibt. quelle="${LAUFZETTEL_REPO:-$QUELLE_STANDARD}" zweig="${LAUFZETTEL_ZWEIG:-main}" appUrl="${APP_URL:-}" adminMail="${SEED_ADMIN_EMAIL:-admin@firma.example}" port="${APP_PORT:-3000}" bindAdresse="${BIND_ADDR:-127.0.0.1}" seedModus="${SEED_MODE:-INTERN}" passwortDatei="${LAUFZETTEL_PASSWORT_DATEI:-}" testmodus=0 # Nur die Werte, die in die Umgebungsdatei wandern - Quelle und Zweig gelten # auch beim Aktualisieren und loesen deshalb keinen Hinweis aus. optionenGesetzt=0 if [ -n "${APP_URL:-}${BIND_ADDR:-}${APP_PORT:-}${SEED_ADMIN_EMAIL:-}${SEED_MODE:-}" ]; then optionenGesetzt=1 fi # ---------- Zustand waehrend des Laufs -------------------------------------- ohneSystemd=0 aptStandGelesen=0 umgebungNeu=0 neustartNoetig=0 schrittNummer=0 adminPasswort="" adminMailWirksam="" pgFassung="" testPid="" declare -a pruefBefunde=() pruefFehler=0 # ---------- Ausgabe --------------------------------------------------------- if [ -t 1 ] && [ "${TERM:-dumb}" != "dumb" ]; then readonly F_AKZENT=$'\033[1;38;5;155m' readonly F_FEHLER=$'\033[1;31m' readonly F_WARN=$'\033[1;33m' readonly F_LEISE=$'\033[2m' readonly F_AUS=$'\033[0m' else readonly F_AKZENT="" F_FEHLER="" F_WARN="" F_LEISE="" F_AUS="" fi schritt() { schrittNummer=$((schrittNummer + 1)) printf '%s[%d/%d]%s %s …\n' "$F_AKZENT" "$schrittNummer" "$SCHRITTE_GESAMT" "$F_AUS" "$1" } melde() { printf ' %s\n' "$1"; } hinweis() { printf ' %s%s%s\n' "$F_LEISE" "$1" "$F_AUS"; } warnung() { printf ' %sAchtung: %s%s\n' "$F_WARN" "$1" "$F_AUS" >&2; } fehler() { printf '%sFehler:%s %s\n' "$F_FEHLER" "$F_AUS" "$1" >&2 exit 1 } abbruch() { printf '%sAbbruch%s in Zeile %s (Status %s). Es wurde nichts zurueckgebaut; ein erneuter Aufruf setzt dort fort.\n' \ "$F_FEHLER" "$F_AUS" "$1" "$2" >&2 } trap 'abbruch "$LINENO" "$?"' ERR hilfe() { cat <<'HILFE' Laufzettel – native Installation auf Debian 13 (ohne Docker). sudo bash install.sh [Schalter] Der erste Aufruf installiert, jeder weitere aktualisiert: Quellcode holen, bauen, Migrationen einspielen, Dienst neu starten. Geheimnisse und Daten bleiben dabei unangetastet. Schalter: --quelle Git-Quelle des Quellcodes. Standard ist der Gitea-Server. Ein lokaler Pfad muss ein Git-Arbeitsverzeichnis sein (fuer Testlaeufe: --quelle /src). --zweig Zweig, der ausgecheckt wird. Standard: main --url Oeffentliche Adresse der Anwendung (APP_URL). Sie steckt in den QR-Codes. Standard: http://: --port Port des Webdienstes. Standard: 3000 --bind Adresse, auf die gebunden wird. Standard: 127.0.0.1 — davor gehoert ein Reverse Proxy. --admin-mail Kennung des ersten Administrators. Standard: admin@firma.example --modus INTERN (Standard) oder DIENSTLEISTUNG. --passwort-datei

Legt das beim ersten Lauf erzeugte Administrator-Passwort zusaetzlich in diese Datei (0600, root) — fuer Aufrufer, die es nicht von der Ausgabe ablesen koennen. Wer sie benutzt, loescht sie nach dem Ablesen: sonst liegt das Passwort dauerhaft im Klartext im System. --test Nach der Installation eine Selbstpruefung fahren: kein TCP-Port 5432, Migration und Seed gelaufen, Antwort auf /api/gesundheit. Ohne systemd (Container) wird der Dienst dafuer von Hand gestartet und danach wieder beendet. --hilfe, --help Diese Hilfe. Die Werte --url, --port, --bind, --admin-mail und --modus wirken nur beim ersten Lauf: danach steht die Wahrheit in /etc/laufzettel/laufzettel.env, und die Datei wird nie ueberschrieben. Dieselben Werte nimmt das Skript auch aus der Umgebung, was Aufrufer ohne Befehlszeile brauchen (pct exec im Proxmox-Weg). Ein Schalter schlaegt die Umgebung: APP_URL, BIND_ADDR, APP_PORT, SEED_ADMIN_EMAIL, SEED_MODE, LAUFZETTEL_REPO (wie --quelle), LAUFZETTEL_ZWEIG (wie --zweig), LAUFZETTEL_PASSWORT_DATEI (wie --passwort-datei) Das Administrator-Passwort steht nicht in /etc/laufzettel/laufzettel.env. Es wird beim ersten Lauf einmal angezeigt und liegt sonst nur als argon2-Hash in der Datenbank; wer es maschinell braucht, nimmt --passwort-datei. HILFE } # ---------- Kleine Helfer --------------------------------------------------- # Liest einen Wert aus /etc/os-release, ohne die Datei in die eigene Shell zu # holen: ein `.` darauf setzt Variablen, die spaeter niemand mehr zuordnet. osWert() { local zeile zeile=$(grep -m1 "^$1=" /etc/os-release 2>/dev/null || true) zeile=${zeile#*=} zeile=${zeile%\"} printf '%s' "${zeile#\"}" } dpkgInstalliert() { local status status=$(dpkg-query -W -f='${Status}' "$1" 2>/dev/null || true) case "$status" in *"install ok installed"*) return 0 ;; esac return 1 } aptAktualisieren() { if [ "$aptStandGelesen" -eq 0 ]; then apt-get update -qq aptStandGelesen=1 fi } # Zufallswort aus Buchstaben und Ziffern. Absichtlich ohne Sonderzeichen: der # Wert landet in einer URL (DATABASE_URL) und in einer Umgebungsdatei, und ein # `/` oder `&` darin kostet nur Kopfschmerzen. zufallswort() { local laenge="$1" roh roh=$(openssl rand -base64 $((laenge * 3)) | tr -dc 'A-Za-z0-9') if [ "${#roh}" -lt "$laenge" ]; then fehler "openssl liefert zu wenig Zufall - ist /dev/urandom erreichbar?" fi printf '%s' "${roh:0:laenge}" } hauptAdresse() { local adresse adresse=$(hostname -I 2>/dev/null | awk '{ print $1 }') if [ -z "$adresse" ]; then adresse="127.0.0.1"; fi printf '%s' "$adresse" } # Befehle des Dienstnutzers. HOME und COREPACK_HOME muessen mitgegeben werden: # `runuser -u` erbt die Umgebung von root, und dann legte pnpm seinen Speicher # unter /root ab. Geheimnisse stehen hier nie - sie kommen ueber die vererbte # Umgebung, nicht ueber die Befehlszeile, und tauchen so in `ps` nicht auf. alsDienst() { runuser -u "$DIENSTNUTZER" -- env \ "HOME=$HEIM" "COREPACK_HOME=$HEIM/corepack" \ COREPACK_ENABLE_DOWNLOAD_PROMPT=0 CI=1 "$@" } # psql als Systemnutzer postgres. Aus / heraus, damit kein unlesbares # Arbeitsverzeichnis im Weg steht, und mit -X, damit keine .psqlrc mitredet. psqlAlsPostgres() { (cd / && runuser -u postgres -- psql -X -q -v ON_ERROR_STOP=1 "$@") } # SQL kommt ueber die Standardeingabe, nie ueber -c: ein # `psql -c "ALTER ROLE … PASSWORD …"` stuende mit dem Passwort in der # Prozessliste, und die liest jeder Nutzer des Systems. sqlAlsPostgres() { (cd / && runuser -u postgres -- psql -X -q -v ON_ERROR_STOP=1 -d postgres -f -) } # Passwort der Datenbankrolle aus DATABASE_URL. Die Umgebungsdatei ist die # einzige Quelle der Wahrheit - auch beim zweiten Lauf, wenn niemand ein neues # Passwort erzeugt hat. dbPasswortAusUrl() { local rest anmeldung rest=${DATABASE_URL#*://} anmeldung=${rest%%@*} printf '%s' "${anmeldung#*:}" } gesundheitFragen() { local adresse="$1" versuche="${2:-40}" antwort i for ((i = 1; i <= versuche; i++)); do if antwort=$(curl -fsS --max-time 3 "$adresse" 2>/dev/null); then case "$antwort" in *'"status":"ok"'*) printf '%s' "$antwort" return 0 ;; esac fi sleep 1 done return 1 } # ---------- Schritt 1: Voraussetzungen -------------------------------------- voraussetzungenPruefen() { schritt "Voraussetzungen pruefen" if [ "$(id -u)" -ne 0 ]; then fehler "Das Skript legt Systempakete, einen Dienstnutzer und einen Dienst an - es braucht root. Ruf es als \`sudo bash $0\` auf." fi if [ ! -r /etc/os-release ]; then fehler "/etc/os-release fehlt - das System ist nicht als Debian erkennbar, und ohne apt und dpkg kann das Skript nichts installieren." fi local id aehnlich fassung name id=$(osWert ID) aehnlich=$(osWert ID_LIKE) fassung=$(osWert VERSION_ID) name=$(osWert PRETTY_NAME) case " $id $aehnlich " in *" debian "*) ;; *) fehler "Erwartet wird Debian 13 oder ein damit verwandtes System; hier laeuft ${name:-ein unbekanntes System}. Es fehlen die Debian-Paketquellen (apt-get, dpkg, postgresql)." ;; esac if [ "$fassung" != "13" ]; then warnung "Erwartet ist Debian 13, gefunden ${name:-unbekannt}. Der Lauf geht weiter; die PostgreSQL-Fassung kann abweichen." fi if ! command -v apt-get >/dev/null 2>&1; then fehler "apt-get fehlt. Das Skript installiert seine Pakete ausschliesslich ueber apt." fi if ! command -v runuser >/dev/null 2>&1; then fehler "runuser fehlt (Paket util-linux). Ohne den Wechsel des Systemnutzers laeuft nichts weiter." fi melde "${name:-Debian}, $(uname -m), root" if [ ! -d /run/systemd/system ]; then ohneSystemd=1 hinweis "Kein systemd erkannt (Container o. Ae.): die Unit wird geschrieben, aber nicht aktiviert." fi } # ---------- Schritt 2: Systempakete ----------------------------------------- paketeInstallieren() { schritt "Systempakete installieren" export DEBIAN_FRONTEND=noninteractive local paket local -a fehlend=() # iproute2 steckt mit drin, weil das Abnahmekriterium `ss -ltn` verlangt; # in einem schlanken Debian-Abbild fehlt es. for paket in ca-certificates curl git gnupg openssl iproute2 procps; do if ! dpkgInstalliert "$paket"; then fehlend+=("$paket"); fi done if [ "${#fehlend[@]}" -gt 0 ]; then aptAktualisieren apt-get install -y --no-install-recommends "${fehlend[@]}" >/dev/null melde "nachinstalliert: ${fehlend[*]}" else melde "alles vorhanden" fi } # ---------- Schritt 3: Dienstnutzer und Verzeichnisse ----------------------- nutzerAnlegen() { schritt "Dienstnutzer und Verzeichnisse anlegen" if ! getent group "$DIENSTNUTZER" >/dev/null; then groupadd --system "$DIENSTNUTZER" fi if ! getent passwd "$DIENSTNUTZER" >/dev/null; then # Kein Login, kein Arbeitskonto: nologin als Shell, und das # Heimatverzeichnis ist nur ein Zwischenspeicher unter /var/cache. useradd --system --gid "$DIENSTNUTZER" --home-dir "$HEIM" --no-create-home \ --shell /usr/sbin/nologin --comment "Laufzettel" "$DIENSTNUTZER" melde "Dienstnutzer $DIENSTNUTZER angelegt (nologin, kein Home unter /home)" else melde "Dienstnutzer $DIENSTNUTZER vorhanden" fi install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0755 "$ZIEL" install -d -o "$DIENSTNUTZER" -g "$DIENSTNUTZER" -m 0750 "$HEIM" install -d -o root -g root -m 0750 "$ETC_VERZEICHNIS" } # ---------- Schritt 4: Geheimnisse und Umgebungsdatei ----------------------- umgebungSchreiben() { local appSecret dbPasswort appSecret=$(openssl rand -base64 48 | tr -d '\n') dbPasswort=$(zufallswort 32) if [ -z "$appUrl" ]; then appUrl="http://$(hauptAdresse):$port"; fi # umask vor dem Anlegen: die Datei darf nicht einmal fuer einen Augenblick # fuer andere lesbar sein. ( umask 077 cat >"$UMGEBUNGSDATEI" </dev/null | awk -v f="$pgFassung" '$1 == f && $2 == "main" { print $4; exit }' } clusterStarten() { if [ "$ohneSystemd" -eq 0 ]; then systemctl enable --now "postgresql@$pgFassung-main" >/dev/null 2>&1 || systemctl start "postgresql@$pgFassung-main" else pg_ctlcluster "$pgFassung" main start fi } clusterNeustarten() { if [ "$ohneSystemd" -eq 0 ]; then systemctl restart "postgresql@$pgFassung-main" else pg_ctlcluster "$pgFassung" main restart fi } postgresEinrichten() { schritt "PostgreSQL einrichten (nur Unix-Socket)" local paket="postgresql-17" if ! dpkgInstalliert "$paket" && ! apt-cache show "$paket" >/dev/null 2>&1; then warnung "postgresql-17 steht in diesen Paketquellen nicht bereit - genommen wird das Metapaket postgresql." paket="postgresql" fi if ! dpkgInstalliert "$paket"; then aptAktualisieren apt-get install -y --no-install-recommends "$paket" >/dev/null fi pgFassung=$(pg_lsclusters -h 2>/dev/null | awk '$2 == "main" { print $1; exit }') if [ -z "$pgFassung" ]; then fehler "Nach der Installation gibt es kein Cluster \"main\" (pg_lsclusters ist leer). Ohne Cluster gibt es keine Datenbank - Ursache steht in /var/log/postgresql/." fi melde "PostgreSQL $pgFassung, Cluster main" local verzeichnis="/etc/postgresql/$pgFassung/main" local hauptKonf="$verzeichnis/postgresql.conf" local konfDatei="$verzeichnis/conf.d/laufzettel.conf" install -d -o postgres -g postgres -m 0755 "$verzeichnis/conf.d" local gewuenscht gewuenscht=$( cat <<'KONF' # Laufzettel (#019): Die Datenbank ist ausschliesslich ueber den Unix-Socket # erreichbar. Ein leeres listen_addresses heisst: kein TCP-Port, also nichts, # woran ein Nachbar im LAN klopfen kann. `ss -ltn` zeigt 5432 deshalb nicht. listen_addresses = '' KONF ) if [ ! -f "$konfDatei" ] || [ "$(cat "$konfDatei")" != "$gewuenscht" ]; then printf '%s\n' "$gewuenscht" >"$konfDatei" chown postgres:postgres "$konfDatei" chmod 0644 "$konfDatei" neustartNoetig=1 melde "listen_addresses = '' gesetzt ($konfDatei)" else melde "listen_addresses = '' steht schon" fi # Debians postgresql.conf zieht conf.d selbst herein. Fehlt die Zeile, weil # jemand die Datei ersetzt hat, waere die Vorgabe oben wirkungslos. if ! grep -Eq "^[[:space:]]*include_dir[[:space:]]*=[[:space:]]*'conf\.d'" "$hauptKonf"; then printf "\n# Laufzettel (#019)\ninclude_dir = 'conf.d'\n" >>"$hauptKonf" neustartNoetig=1 fi if [ "$(clusterLaeuft)" != "online" ]; then clusterStarten elif [ "$neustartNoetig" -eq 1 ]; then clusterNeustarten fi local i for ((i = 1; i <= 30; i++)); do if pg_isready -q -h "$SOCKET_VERZEICHNIS" 2>/dev/null; then break; fi sleep 1 done if ! pg_isready -q -h "$SOCKET_VERZEICHNIS" 2>/dev/null; then fehler "PostgreSQL antwortet nach 30 s nicht auf dem Socket $SOCKET_VERZEICHNIS. Ursache steht in /var/log/postgresql/postgresql-$pgFassung-main.log." fi local passwort passwort=$(dbPasswortAusUrl) if [ -z "$passwort" ]; then fehler "In DATABASE_URL steht kein Passwort fuer die Rolle $DB_ROLLE. $UMGEBUNGSDATEI pruefen." fi if [ "$(psqlAlsPostgres -tAc "SELECT 1 FROM pg_roles WHERE rolname = '$DB_ROLLE'")" != "1" ]; then sqlAlsPostgres <"$liste" aptStandGelesen=0 fi } nodeEinrichten() { schritt "Node 24 und pnpm bereitstellen" local vorhanden="" if command -v node >/dev/null 2>&1; then vorhanden=$(node --version 2>/dev/null | sed 's/^v//; s/\..*//') fi if [ "$vorhanden" != "24" ]; then nodesourceQuelleAnlegen aptAktualisieren apt-get install -y nodejs >/dev/null melde "Node $(node --version) aus dem NodeSource-Repo" else melde "Node $(node --version) vorhanden" fi export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 if ! command -v corepack >/dev/null 2>&1; then # Aeltere und neuere Node-Fassungen liefern corepack nicht mehr mit. npm install -g corepack@latest >/dev/null fi corepack enable pnpm melde "corepack aktiv; pnpm kommt aus dem Feld packageManager des Projekts" } # ---------- Schritt 7: Quellcode -------------------------------------------- # Eine lokale Quelle gehoert in der Regel einem anderen Systemnutzer als dem # Dienstnutzer, und git verweigert dann die Arbeit ("dubious ownership"). Die # Ausnahme muss in die globale Konfiguration: ueber `-c` oder die Umgebung # ignoriert git sie absichtlich, damit ein fremdes Repository sie nicht setzen # kann. Genannt werden beide Schreibweisen, weil git je nach Aufruf das # Arbeitsverzeichnis oder das .git-Verzeichnis prueft. quelleFreigeben() { local vorhanden pfad vorhanden=$(alsDienst git config --global --get-all safe.directory 2>/dev/null || true) for pfad in "$quelle" "$quelle/.git"; do if ! printf '%s\n' "$vorhanden" | grep -qxF "$pfad"; then alsDienst git config --global --add safe.directory "$pfad" fi done } quellcodeHolen() { schritt "Quellcode holen oder aktualisieren" local lokal=0 case "$quelle" in /* | ./* | ../*) lokal=1 if [ ! -d "$quelle/.git" ]; then fehler "$quelle ist kein Git-Arbeitsverzeichnis. Das Skript holt den Quellcode ausschliesslich mit git, damit der zweite Lauf mit \`git pull\` aktualisieren kann." fi quelleFreigeben ;; esac if [ -d "$ZIEL/.git" ]; then local vorher nachher vorher=$(alsDienst git -C "$ZIEL" rev-parse --short HEAD) alsDienst git -C "$ZIEL" remote set-url origin "$quelle" alsDienst git -C "$ZIEL" fetch --prune --quiet origin if [ "$(alsDienst git -C "$ZIEL" rev-parse --abbrev-ref HEAD)" != "$zweig" ]; then alsDienst git -C "$ZIEL" checkout --quiet "$zweig" fi if ! alsDienst git -C "$ZIEL" pull --ff-only --quiet; then fehler "\`git pull --ff-only\` im Verzeichnis $ZIEL ist fehlgeschlagen. Meist liegen dort eigene Aenderungen: \`sudo -u $DIENSTNUTZER git -C $ZIEL status\` zeigt sie." fi nachher=$(alsDienst git -C "$ZIEL" rev-parse --short HEAD) if [ "$vorher" = "$nachher" ]; then melde "Zweig $zweig ist schon auf $nachher" else melde "Zweig $zweig von $vorher auf $nachher gebracht" fi else if [ -n "$(ls -A "$ZIEL" 2>/dev/null)" ]; then fehler "$ZIEL ist nicht leer, enthaelt aber kein Git-Arbeitsverzeichnis. Verschiebe oder loesche das Verzeichnis und starte das Skript neu." fi if [ "$lokal" -eq 1 ]; then # --no-hardlinks: die Quelle liegt im Testlauf auf einem fremden, nur # lesbaren Dateisystem; harte Verweise dorthin waeren eine Zeitbombe. alsDienst git clone --no-hardlinks --branch "$zweig" --quiet "$quelle" "$ZIEL" else alsDienst git clone --branch "$zweig" --quiet "$quelle" "$ZIEL" fi melde "geklont von $quelle, Zweig $zweig, Stand $(alsDienst git -C "$ZIEL" rev-parse --short HEAD)" fi } # ---------- Schritt 8: Bauen, Migration, Seed ------------------------------- anwendungBauen() { schritt "Abhaengigkeiten installieren, bauen, Datenbank wandern" cd "$ZIEL" alsDienst pnpm install --frozen-lockfile melde "Abhaengigkeiten installiert" # Fassung und Git-Stand landen in der Anzeige unten links. NEXT_PUBLIC_APP_VERSION=$(node -p "require('$ZIEL/package.json').version") NEXT_PUBLIC_GIT_SHA=$(alsDienst git -C "$ZIEL" rev-parse --short HEAD) export NEXT_PUBLIC_APP_VERSION NEXT_PUBLIC_GIT_SHA alsDienst pnpm build melde "gebaut (standalone), Fassung $NEXT_PUBLIC_APP_VERSION, Stand $NEXT_PUBLIC_GIT_SHA" # Next legt in .next/standalone nur den Server ab; die statischen Dateien und # public/ muessen daneben liegen. Genauso macht es das Dockerfile. alsDienst rm -rf "$STANDALONE/.next/static" "$STANDALONE/public" alsDienst cp -a "$ZIEL/.next/static" "$STANDALONE/.next/static" alsDienst cp -a "$ZIEL/public" "$STANDALONE/public" # Der einzige Ort, an den der Dienst schreiben darf (siehe ReadWritePaths). alsDienst mkdir -p "$STANDALONE/.next/cache" alsDienst pnpm exec prisma migrate deploy melde "Migrationen eingespielt" saatAusbringen } saatAusbringen() { local vorhanden # Hat der Administrator die Datenbank schon? Dann wird kein Passwort erzeugt # und keines angezeigt - ein bestehendes Konto wird nicht angefasst. vorhanden=$(psqlAlsPostgres -tAc \ "SELECT 1 FROM \"Benutzer\" WHERE email = '$adminMailWirksam' LIMIT 1" -d "$DATENBANK" 2>/dev/null || true) if [ "$vorhanden" = "1" ]; then alsDienst pnpm exec prisma db seed # Der Seed meldet "Administrator: uebersprungen", und das ist richtig: ihm # wird absichtlich kein Passwort mitgegeben, solange das Konto steht. melde "Seed gelaufen; Administrator $adminMailWirksam war schon da und bleibt unveraendert" return 0 fi adminPasswort=$(zufallswort 20) # Ueber die vererbte Umgebung, nicht als Argument: Argumente stehen in `ps`. export SEED_ADMIN_PASSWORD="$adminPasswort" alsDienst pnpm exec prisma db seed unset SEED_ADMIN_PASSWORD melde "Seed gelaufen; Administrator $adminMailWirksam angelegt (Passwort nur als argon2-Hash gespeichert)" if [ -n "$passwortDatei" ]; then # Nur auf ausdruecklichen Wunsch: ein Aufrufer, der die Ausgabe nicht lesen # kann (pct exec hinter einem Spinner), braucht einen Ort zum Nachsehen. Die # Datei ist eine Leihgabe, kein Aufbewahrungsort - der Aufrufer loescht sie. ( umask 077 printf 'SEED_ADMIN_EMAIL=%s\nSEED_ADMIN_PASSWORD=%s\n' "$adminMailWirksam" "$adminPasswort" \ >"$passwortDatei" ) chown root:root "$passwortDatei" chmod 0600 "$passwortDatei" melde "Passwort zusaetzlich in $passwortDatei (0600, root) - nach dem Ablesen loeschen" fi } # ---------- Schritt 9: systemd ---------------------------------------------- unitSchreiben() { local inhalt nodePfad # Vollstaendiger Pfad, wie systemd ihn verlangt - und zwar der, unter dem node # hier wirklich liegt (NodeSource legt es nach /usr/bin, ein Handeinbau gern # nach /usr/local/bin). nodePfad=$(command -v node) inhalt=$( cat <"$UNIT_DATEI" chmod 0644 "$UNIT_DATEI" return 0 fi return 1 } dienstEinrichten() { schritt "systemd-Unit schreiben und Dienst starten" local geaendert=0 if unitSchreiben; then geaendert=1; fi melde "$UNIT_DATEI $([ "$geaendert" -eq 1 ] && echo "geschrieben" || echo "unveraendert")" if [ "$ohneSystemd" -eq 1 ]; then hinweis "Kein systemd: die Unit ist geschrieben, aber nicht aktiviert." # Erst als root einlesen, dann die Rechte abgeben: die Umgebungsdatei ist # 0600 und gehoert root, der Dienstnutzer kann sie also nicht lesen. Genau # so macht es systemd mit EnvironmentFile auch. Eingelesen statt in # Argumente verwandelt, weil Werte mit Leerzeichen (SEED_ORG_NAME) sonst in # mehrere Woerter zerfielen. hinweis "Von Hand starten: (set -a; . $UMGEBUNGSDATEI; exec runuser -u $DIENSTNUTZER -- node $STANDALONE/server.js)" return 0 fi systemctl daemon-reload systemctl enable "$DIENSTNAME" >/dev/null 2>&1 || systemctl enable "$DIENSTNAME" systemctl restart "$DIENSTNAME" local adresse adresse="http://$(pruefAdresse):${PORT:-3000}/api/gesundheit" if gesundheitFragen "$adresse" 60 >/dev/null; then melde "Dienst laeuft, $adresse antwortet mit status ok" else fehler "Der Dienst antwortet nach 60 s nicht auf $adresse. Was er sagt: journalctl -u $DIENSTNAME -n 50 --no-pager" fi } # ---------- Selbstpruefung (--test) ----------------------------------------- # 0.0.0.0 ist eine Bindung, keine Adresse, die man anfragt. pruefAdresse() { local adresse="${HOSTNAME:-127.0.0.1}" if [ "$adresse" = "0.0.0.0" ] || [ -z "$adresse" ]; then adresse="127.0.0.1"; fi printf '%s' "$adresse" } befund() { if [ "$1" = "ok" ]; then pruefBefunde+=(" [ok] $2") else pruefBefunde+=(" [FEHLER] $2") pruefFehler=1 fi } dienstVonHandStarten() { local protokoll="/var/log/laufzettel-selbsttest.log" # Ohne systemd (Container) startet die Pruefung den Dienst selbst - sonst # gaebe es nichts, was auf /api/gesundheit antworten koennte. runuser -u "$DIENSTNUTZER" -- env "HOME=$HEIM" node "$STANDALONE/server.js" \ >"$protokoll" 2>&1 & testPid=$! hinweis "Dienst von Hand gestartet (PID $testPid), Ausgabe in $protokoll" } dienstVonHandBeenden() { if [ -n "$testPid" ]; then kill "$testPid" 2>/dev/null || true wait "$testPid" 2>/dev/null || true testPid="" fi pkill -u "$DIENSTNUTZER" -f "$STANDALONE/server.js" 2>/dev/null || true } selbstpruefung() { printf '\n%sSelbstpruefung%s\n' "$F_AKZENT" "$F_AUS" # 1. Kein TCP-Port 5432 - das Abnahmekriterium des nativen Wegs. if command -v ss >/dev/null 2>&1; then if ss -ltnH 2>/dev/null | grep -q ':5432[[:space:]]'; then befund fehler "ss -ltn zeigt einen Horcher auf 5432 - PostgreSQL spricht TCP" else befund ok "ss -ltn zeigt keinen Port 5432" fi else befund fehler "ss fehlt (Paket iproute2) - der Port konnte nicht geprueft werden" fi # 2. Datenbank ueber den Socket erreichbar, Migration und Seed gelaufen. local vorlagen benutzer vorlagen=$(psqlAlsPostgres -tAc 'SELECT count(*) FROM "Vorlage"' -d "$DATENBANK" 2>/dev/null || echo "-") benutzer=$(psqlAlsPostgres -tAc 'SELECT count(*) FROM "Benutzer"' -d "$DATENBANK" 2>/dev/null || echo "-") if [ "$vorlagen" = "-" ]; then befund fehler "Die Tabelle Vorlage gibt es nicht - Migration nicht gelaufen" elif [ "$vorlagen" -ge 7 ]; then befund ok "Migration und Seed gelaufen: $vorlagen Vorlagen, $benutzer Benutzer" else befund fehler "Nur $vorlagen Vorlagen in der Datenbank - der Seed erwartet sieben" fi # 3. Umgebungsdatei: 0600, root. local rechte rechte=$(stat -c '%a %U:%G' "$UMGEBUNGSDATEI") if [ "$rechte" = "600 root:root" ]; then befund ok "$UMGEBUNGSDATEI hat $rechte" else befund fehler "$UMGEBUNGSDATEI hat $rechte, erwartet 600 root:root" fi # 4. Der Dienst antwortet. if [ "$ohneSystemd" -eq 1 ]; then dienstVonHandStarten; fi local adresse adresse="http://$(pruefAdresse):${PORT:-3000}/api/gesundheit" local antwort if antwort=$(gesundheitFragen "$adresse" 60); then befund ok "$adresse antwortet: $antwort" else befund fehler "$adresse antwortet nicht mit status ok" fi if [ "$ohneSystemd" -eq 1 ]; then dienstVonHandBeenden; fi printf '\n' local zeile for zeile in "${pruefBefunde[@]}"; do printf '%s\n' "$zeile"; done printf '\n' if [ "$pruefFehler" -eq 1 ]; then fehler "Die Selbstpruefung hat Befunde. Siehe oben." fi printf '%sSelbstpruefung ohne Befund.%s\n' "$F_AKZENT" "$F_AUS" } # ---------- Abschluss ------------------------------------------------------- abschluss() { printf '\n%sLaufzettel ist eingerichtet.%s\n\n' "$F_AKZENT" "$F_AUS" local bindung="${HOSTNAME:-127.0.0.1}" printf ' Adresse %s\n' "${APP_URL:-}" printf ' Dienst horcht %s:%s\n' "$bindung" "${PORT:-3000}" printf ' Administrator %s\n' "$adminMailWirksam" if [ -n "$adminPasswort" ]; then printf ' Passwort %s%s%s\n' "$F_AKZENT" "$adminPasswort" "$F_AUS" printf '\n %sDieses Passwort wird genau hier einmal angezeigt.%s In der Datenbank steht\n' "$F_WARN" "$F_AUS" printf ' nur der argon2-Hash, in %s steht es nicht.\n' "$UMGEBUNGSDATEI" if [ -n "$passwortDatei" ]; then printf ' Auf Wunsch liegt es ausserdem in %s - dort loeschen.\n' "$passwortDatei" fi else printf ' Passwort unveraendert (Konto bestand schon)\n' fi printf '\n' case "$bindung" in 127.0.0.1 | localhost | ::1) printf ' Der Dienst horcht nur lokal. Erreichbar wird er durch einen Reverse Proxy\n' printf ' mit TLS, der %s auf http://%s:%s weiterleitet.\n' \ "${APP_URL:-die oeffentliche Adresse}" "$bindung" "${PORT:-3000}" ;; *) printf ' %sDer Dienst horcht auf %s:%s – also im Netz, ohne TLS.%s Davor gehoert ein\n' \ "$F_WARN" "$bindung" "${PORT:-3000}" "$F_AUS" printf ' Reverse Proxy mit TLS; danach BIND_ADDR wieder auf 127.0.0.1 stellen.\n' ;; esac printf ' %s muss zum Proxy passen, sonst fuehren die QR-Codes und die\n' "${APP_URL:-APP_URL}" printf ' OIDC-Rueckrufadresse ins Leere.\n' cat < laufzettel.dump Aktualisieren dieses Skript erneut aufrufen ABSCHLUSS } # ---------- Ablauf ---------------------------------------------------------- schalterLesen() { while [ "$#" -gt 0 ]; do case "$1" in --hilfe | --help | -h) hilfe exit 0 ;; --quelle) quelle="${2:?--quelle braucht einen Pfad oder eine URL}" shift 2 ;; --zweig) zweig="${2:?--zweig braucht einen Namen}" shift 2 ;; --url) appUrl="${2:?--url braucht eine Adresse}" optionenGesetzt=1 shift 2 ;; --port) port="${2:?--port braucht eine Nummer}" optionenGesetzt=1 shift 2 ;; --bind) bindAdresse="${2:?--bind braucht eine Adresse}" optionenGesetzt=1 shift 2 ;; --admin-mail) adminMail="${2:?--admin-mail braucht eine Adresse}" optionenGesetzt=1 shift 2 ;; --modus) seedModus=$(printf '%s' "${2:?--modus braucht INTERN oder DIENSTLEISTUNG}" | tr '[:lower:]' '[:upper:]') optionenGesetzt=1 shift 2 ;; --passwort-datei) passwortDatei="${2:?--passwort-datei braucht einen Pfad}" shift 2 ;; --test) testmodus=1 shift ;; *) fehler "Unbekannter Schalter \"$1\". \`bash $0 --hilfe\` zeigt alle." ;; esac done case "$seedModus" in INTERN | DIENSTLEISTUNG | SERVICE) ;; *) fehler "--modus kennt nur INTERN und DIENSTLEISTUNG, nicht \"$seedModus\"." ;; esac case "$port" in '' | *[!0-9]*) fehler "--port muss eine Zahl sein, nicht \"$port\"." ;; esac if ! printf '%s' "$adminMail" | grep -Eq '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'; then fehler "\"$adminMail\" sieht nicht wie eine E-Mail-Adresse aus." fi # Der Seed sucht das Konto ueber die kleingeschriebene Adresse. adminMail=$(printf '%s' "$adminMail" | tr '[:upper:]' '[:lower:]') } haupt() { schalterLesen "$@" printf '%sLaufzettel%s – native Installation auf Debian 13\n\n' "$F_AKZENT" "$F_AUS" voraussetzungenPruefen paketeInstallieren nutzerAnlegen geheimnisseAnlegen # Ab hier gilt die Umgebungsdatei, nicht der Schalter: beim zweiten Lauf steht # die Kennung des Administrators dort und nicht in der Befehlszeile. adminMailWirksam="${SEED_ADMIN_EMAIL:-$adminMail}" postgresEinrichten nodeEinrichten quellcodeHolen anwendungBauen dienstEinrichten if [ "$testmodus" -eq 1 ]; then selbstpruefung; fi abschluss } haupt "$@"