Files
Laufzettel/deploy/install.sh
T
Florian Wilde bf1e52cf73
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
feat(betrieb): native Installation, Proxmox-Einzeiler und Abnahme
Zwei Wege in den Betrieb, beide so weit gefahren, wie es hier moeglich war.

deploy/install.sh richtet Debian 13 in neun Schritten ein: PostgreSQL 17 hoert
nur am Unix-Socket, der Dienstnutzer hat kein Login, die Geheimnisse entstehen
im Zielsystem und liegen 0600 bei root. Die Umgebungsdatei wird nie
ueberschrieben -- auch nicht beim Aktualisieren, sonst enden alle Sitzungen und
die verschluesselten Bind-Passwoerter waeren unlesbar.

deploy/proxmox/laufzettel.sh legt den Container an und ruft install.sh darin
auf. Der pct-Teil ist ungetestet, weil kein PVE-Host zur Verfuegung stand; die
Gegenprobe steht als Pruefliste in docs/betrieb/proxmox.md, das Folge-Issue ist
#024.

Die Abnahme gegen alle 61 Kriterien hat drei Zusagen ohne Deckung gefunden, alle
behoben: der Healthcheck des Webdienstes konnte nie bestehen (busybox loest
localhost nach ::1 auf, Next lauscht nur auf IPv4), die CI-Pipeline konnte in
keinem Job gruen werden (kein LDAP-Dienst, kein gestarteter Server, kein
shellcheck), und der README nannte fuer die Entwicklungsdatenbank den falschen
Port.

Closes #19
2026-09-26 15:53:01 +02:00

1059 lines
38 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <url|pfad> Git-Quelle des Quellcodes. Standard ist der Gitea-Server.
Ein lokaler Pfad muss ein Git-Arbeitsverzeichnis sein
(fuer Testlaeufe: --quelle /src).
--zweig <name> Zweig, der ausgecheckt wird. Standard: main
--url <adresse> Oeffentliche Adresse der Anwendung (APP_URL). Sie steckt
in den QR-Codes. Standard: http://<erste IP>:<port>
--port <nummer> Port des Webdienstes. Standard: 3000
--bind <adresse> Adresse, auf die gebunden wird. Standard: 127.0.0.1 —
davor gehoert ein Reverse Proxy.
--admin-mail <adr> Kennung des ersten Administrators. Standard:
admin@firma.example
--modus <wert> INTERN (Standard) oder DIENSTLEISTUNG.
--passwort-datei <p> 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" <<UMGEBUNG
# Laufzettel – Umgebung des Dienstes. Im Zielsystem erzeugt am $(date '+%Y-%m-%d %H:%M:%S %Z').
#
# Hier stehen die Geheimnisse dieser Installation. install.sh ueberschreibt
# diese Datei nie – auch nicht beim Aktualisieren. Wer sie loescht, verliert die
# Sitzungen (APP_SECRET) und den Zugang zur Datenbank.
#
# Nach jeder Aenderung: systemctl restart $DIENSTNAME
APP_URL="$appUrl"
APP_SECRET="$appSecret"
# Nur ueber den Unix-Socket: PostgreSQL lauscht auf keinem TCP-Port.
DATABASE_URL="postgresql://$DB_ROLLE:$dbPasswort@localhost/$DATENBANK?host=$SOCKET_VERZEICHNIS&schema=public"
# Bindung des Webdienstes: Next liest PORT und HOSTNAME. Steht hier 127.0.0.1,
# ist die Anwendung nur ueber einen Reverse Proxy auf demselben Rechner zu
# erreichen - so ist es gedacht. 0.0.0.0 oeffnet sie ohne TLS ins Netz.
HOSTNAME="$bindAdresse"
PORT="$port"
# Lokaler Login mit E-Mail und Passwort – der Notfallzugang, mit dem diese
# Installation bedienbar ist. Sobald OIDC eingerichtet ist: auf false setzen.
AUTH_LOCAL_ENABLED="true"
# Generischer OIDC-Anbieter (Ziel ist authentik). Ausfuellen und den Dienst neu
# starten; leere Werte heissen: OIDC ist abgeschaltet.
#OIDC_ISSUER=""
#OIDC_CLIENT_ID=""
#OIDC_CLIENT_SECRET=""
#OIDC_NAME="authentik"
#OIDC_GROUP_ADMIN="lz-admin"
#OIDC_GROUP_EDITOR="lz-redakteur"
#OIDC_GROUP_TECHNICIAN="lz-techniker"
#OIDC_GROUP_VIEWER="lz-leser"
# Der Verzeichnis-Abgleich laeuft im selben Prozess wie die Anwendung.
ZEITPLAN_AKTIV="true"
# Kennung des ersten Administrators. Das Passwort steht absichtlich nicht hier:
# es wird beim ersten Lauf einmal angezeigt und liegt sonst nur als
# argon2-Hash in der Datenbank.
SEED_ADMIN_EMAIL="$adminMail"
SEED_MODE="$seedModus"
SEED_ORG_NAME="Eigene Organisation"
SEED_ORG_KUERZEL="EIGEN"
NEXT_TELEMETRY_DISABLED="1"
UMGEBUNG
)
chown root:root "$UMGEBUNGSDATEI"
chmod 0600 "$UMGEBUNGSDATEI"
umgebungNeu=1
}
geheimnisseAnlegen() {
schritt "Geheimnisse und Umgebungsdatei"
if [ -f "$UMGEBUNGSDATEI" ]; then
melde "$UMGEBUNGSDATEI ist vorhanden und bleibt unangetastet"
if [ "$optionenGesetzt" -eq 1 ]; then
warnung "Adresse, Port, Modus und Kennung wirken nur beim ersten Lauf. Fuer Aenderungen $UMGEBUNGSDATEI bearbeiten und den Dienst neu starten."
fi
else
umgebungSchreiben
melde "APP_SECRET und Datenbankpasswort erzeugt, $UMGEBUNGSDATEI (0600, root)"
fi
# Die Werte gelten von hier an fuer Migration, Seed und Pruefung.
set -a
# shellcheck source=/dev/null
. "$UMGEBUNGSDATEI"
set +a
# NODE_ENV=production wuerde pnpm die devDependencies ueberspringen lassen -
# und damit prisma und tsx, die fuer Migration und Seed gebraucht werden. Der
# Dienst bekommt es ueber die Unit; hier hat es nichts zu suchen.
unset NODE_ENV
if [ -z "${DATABASE_URL:-}" ] || [ -z "${APP_SECRET:-}" ] || [ -z "${APP_URL:-}" ]; then
fehler "In $UMGEBUNGSDATEI fehlt DATABASE_URL, APP_SECRET oder APP_URL. Ergaenzen oder die Datei loeschen, dann legt das Skript sie neu an (dabei geht das bisherige APP_SECRET verloren, alle Sitzungen enden)."
fi
}
# ---------- Schritt 5: PostgreSQL -------------------------------------------
clusterLaeuft() {
pg_lsclusters -h 2>/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 <<SQL
CREATE ROLE "$DB_ROLLE" WITH LOGIN PASSWORD '$passwort';
SQL
melde "Rolle $DB_ROLLE angelegt"
elif [ "$umgebungNeu" -eq 1 ]; then
# Die Umgebungsdatei war weg, die Rolle nicht: dann ist das alte Passwort
# nicht mehr bekannt und muss auf das neue gesetzt werden.
sqlAlsPostgres <<SQL
ALTER ROLE "$DB_ROLLE" WITH LOGIN PASSWORD '$passwort';
SQL
melde "Rolle $DB_ROLLE vorhanden, Passwort auf das neu erzeugte gesetzt"
else
melde "Rolle $DB_ROLLE vorhanden, Passwort unveraendert"
fi
if [ "$(psqlAlsPostgres -tAc "SELECT 1 FROM pg_database WHERE datname = '$DATENBANK'")" != "1" ]; then
sqlAlsPostgres <<SQL
CREATE DATABASE "$DATENBANK" OWNER "$DB_ROLLE";
SQL
melde "Datenbank $DATENBANK angelegt"
else
melde "Datenbank $DATENBANK vorhanden - Daten bleiben, wie sie sind"
fi
}
# ---------- Schritt 6: Node und pnpm ----------------------------------------
nodesourceQuelleAnlegen() {
local schluessel="/usr/share/keyrings/nodesource.gpg"
local liste="/etc/apt/sources.list.d/nodesource.list"
if [ ! -s "$schluessel" ]; then
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key |
gpg --dearmor --batch --yes -o "$schluessel"
chmod 0644 "$schluessel"
fi
# NodeSource liefert seit 2023 unter "nodistro" fuer alle Debian- und
# Ubuntu-Fassungen dasselbe Paket - deshalb steht hier keine Distribution.
local zeile="deb [signed-by=$schluessel] https://deb.nodesource.com/node_24.x nodistro main"
if [ ! -f "$liste" ] || [ "$(cat "$liste")" != "$zeile" ]; then
printf '%s\n' "$zeile" >"$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
# Laufzettel – von deploy/install.sh geschrieben (#019). Aenderungen hier
# ueberlebt der naechste Lauf des Skripts nicht; eigene Zusaetze gehoeren nach
# /etc/systemd/system/$DIENSTNAME.d/.
[Unit]
Description=Laufzettel – Checklisten fuer wiederkehrende IT-Arbeiten
Documentation=$QUELLE_STANDARD
After=network-online.target postgresql.service
Wants=network-online.target postgresql.service
[Service]
Type=simple
User=$DIENSTNUTZER
Group=$DIENSTNUTZER
WorkingDirectory=$STANDALONE
EnvironmentFile=$UMGEBUNGSDATEI
# Nicht in der Umgebungsdatei: mit NODE_ENV=production liesse pnpm beim
# Installieren die devDependencies weg, und Migration und Seed fielen aus.
Environment=NODE_ENV=production
ExecStart=$nodePfad $STANDALONE/server.js
Restart=on-failure
RestartSec=5
# Next beendet sich auf SIGTERM mit 143. Ohne diese Zeile stuende die Unit nach
# jedem Stopp auf "failed", und jede Ueberwachung schluege Alarm. (Keine
# Rueckwaerts-Anfuehrungszeichen in diesem Textblock: er wird von der Shell
# ausgewertet, und sie wuerde den Inhalt als Befehl ausfuehren.)
SuccessExitStatus=143 SIGTERM
UMask=0077
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
LockPersonality=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
# ProtectSystem=strict macht alles nur lesbar. Next schreibt seinen Zwischen-
# speicher, und der Weg zum Datenbank-Socket bleibt offen, falls das nur lesbar
# gemountete /run das Verbinden je verweigert.
ReadWritePaths=-$STANDALONE/.next/cache
ReadWritePaths=-$SOCKET_VERZEICHNIS
StandardOutput=journal
StandardError=journal
SyslogIdentifier=laufzettel
[Install]
WantedBy=multi-user.target
UNIT
)
if [ ! -f "$UNIT_DATEI" ] || [ "$(cat "$UNIT_DATEI")" != "$inhalt" ]; then
printf '%s\n' "$inhalt" >"$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 <<ABSCHLUSS
Dienst systemctl status $DIENSTNAME
Protokoll journalctl -u $DIENSTNAME -f
Umgebung $UMGEBUNGSDATEI (0600, root – hier stehen die Geheimnisse)
Sicherung sudo -u postgres pg_dump -Fc $DATENBANK > 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 "$@"