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 Nummer 38. 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
1059 lines
38 KiB
Bash
Executable File
1059 lines
38 KiB
Bash
Executable File
#!/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 "$@"
|