diff --git a/.env.example b/.env.example index cda3587..6e8379e 100644 --- a/.env.example +++ b/.env.example @@ -11,12 +11,19 @@ APP_URL=https://laufzettel.example.de # Mindestens 32 Zeichen. Erzeugen mit: openssl rand -base64 48 APP_SECRET= -# Zugang zur Datenbank. Im Compose-Stand wird der Dienstname postgres verwendet -# und die Datenbank ist von aussen nicht erreichbar. +# Zugang zur Datenbank. +# +# Im Compose-Stand baut sich die Adresse aus den drei POSTGRES_*-Werten zusammen: +# die Datenbank heisst dort `postgres` (Dienstname im internen Netz) und ist von +# aussen nicht erreichbar. DATABASE_URL wird dafuer **nicht** gelesen. +# +# DATABASE_URL gilt fuer alles, was ausserhalb der Container laeuft: `pnpm dev`, +# `pnpm prisma …`, `pnpm db:seed` von der Werkbank aus. Standard ist die +# Entwicklungsdatenbank aus docker-compose.dev.yml (Port 5434, umstellbar ueber DEV_DB_PORT). POSTGRES_USER=laufzettel POSTGRES_PASSWORD= POSTGRES_DB=laufzettel -DATABASE_URL=postgresql://laufzettel:@postgres:5432/laufzettel?schema=public +DATABASE_URL=postgresql://laufzettel:laufzettel@localhost:5434/laufzettel?schema=public # ---------- Anmeldung ---------- diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7126b49..8d05216 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -44,6 +44,21 @@ jobs: - name: Prisma-Client erzeugen run: pnpm exec prisma generate + # Der Verzeichnis-Abgleich wird gegen ein echtes OpenLDAP geprueft. Ohne + # diesen Container bricht `pnpm test` in CI ab (tests/fixtures/ldap gibt in + # CI absichtlich keinen stillen Uebersprung) -- gefunden bei der Abnahme zu + # #019. Kein services:-Block, weil der Container die Fixture-LDIFs aus dem + # Arbeitsbaum einhaengt und Dienste vor dem Checkout starten. + # + # Bedingung an den Runner: die Schritte muessen den von compose + # veroeffentlichten Port 127.0.0.1:1389 selbst erreichen. Auf einem + # act_runner, der die Schritte in einem Container ausfuehrt und nur den + # Docker-Socket durchreicht, liegt der Port auf dem Host -- dort braucht es + # `--network host` fuer den Job oder einen Runner im Host-Modus. Ungeprueft: + # fuer dieses Repo ist noch kein Runner registriert. + - name: LDAP-Testverzeichnis starten + run: docker compose -f docker-compose.test.yml up -d --wait ldap + - name: Code-Stil prüfen run: pnpm lint @@ -62,13 +77,17 @@ jobs: - name: Dokumentation prüfen run: pnpm pruefe:doku + # Im Container und nicht per apt: so laeuft in CI genau die Fassung, die + # auch lokal geprueft wird, und der Runner braucht shellcheck nicht selbst + # mitzubringen. - name: Shell-Skripte prüfen run: | - if compgen -G "deploy/*.sh" > /dev/null || compgen -G "deploy/proxmox/*.sh" > /dev/null; then - shellcheck --severity=warning deploy/*.sh deploy/proxmox/*.sh - else - echo "Noch keine Shell-Skripte vorhanden (kommen mit #019)." - fi + docker run --rm -v "$PWD:/mnt:ro" koalaman/shellcheck:stable \ + --severity=warning /mnt/deploy/install.sh /mnt/deploy/proxmox/laufzettel.sh + + - name: LDAP-Testverzeichnis stoppen + if: always() + run: docker compose -f docker-compose.test.yml down -v e2e: runs-on: ubuntu-latest @@ -116,9 +135,32 @@ jobs: - name: Bauen run: pnpm build + # Playwright bringt keinen Server mit (kein webServer in der Config, damit + # lokal immer gegen einen bewusst gebauten Stand geprueft wird). In CI muss + # er deshalb hier starten, sonst bricht global-setup mit „Unter + # http://127.0.0.1:3000 antwortet keine Anwendung“ ab -- gefunden bei der + # Abnahme zu #019. + - name: Gebauten Stand starten + run: | + pnpm start & + echo $! > /tmp/laufzettel.pid + for i in $(seq 1 60); do + if curl -fsS http://127.0.0.1:3000/api/gesundheit > /dev/null; then + echo "Anwendung antwortet nach ${i}s." + exit 0 + fi + sleep 1 + done + echo "Die Anwendung ist nicht hochgekommen." >&2 + exit 1 + - name: Ende-zu-Ende-Tests run: pnpm exec playwright test + - name: Stand beenden + if: always() + run: kill "$(cat /tmp/laufzettel.pid)" || true + - uses: actions/upload-artifact@v4 if: failure() with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b87be8..310f399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Alle nennenswerten Änderungen an Laufzettel werden in dieser Datei dokumentiert Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). -## [Unveröffentlicht] +## [0.1.0] – 2026-09-26 ### Hinzugefügt @@ -188,6 +188,28 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). Datenbank schon Läufe, Entwürfe oder bestimmte Personen enthält, und keiner sucht seine Vorlage über die Position in der Liste (#018) +### Behoben + +- Der Compose-Stand las `DATABASE_URL` aus der `.env` — dort steht die Adresse für die Entwicklung + (`localhost` und ein offener Port), und im Container zeigt sie ins Leere. Wer den dokumentierten + Weg ging, bekam einen Stand, der nicht startet. Die Adresse baut sich jetzt immer aus den + `POSTGRES_*`-Werten zusammen (#019) +- Der dokumentierte Seed-Befehl `docker compose run --rm migrate pnpm db:seed` scheiterte auf einer + frischen Installation: der Einmal-Dienst bekam kein `APP_SECRET`, und ohne das lässt sich das + Bind-Passwort der Beispiel-Verzeichnisquelle nicht verschlüsseln. Abgebrochen wurde erst, nachdem + die Vorlagen schon angelegt waren (#019) +- Der Healthcheck des Webdienstes konnte nie bestehen: busybox löst `localhost` im Container zuerst + nach `::1` auf, Next lauscht aber nur auf IPv4. `docker compose ps` zeigte `web` dauerhaft als + `unhealthy`, obwohl die App einwandfrei antwortete — und jedes `--wait` lief in den Zeitablauf. + Geprüft wird jetzt über `127.0.0.1`, und ein Test hält die Adresse fest (#019) +- Die CI-Pipeline konnte in keinem der beiden Jobs grün werden: dem Prüf-Job fehlte das + LDAP-Testverzeichnis, das `pnpm test` dort verlangt, dem E2E-Job fehlte ein gestarteter Server, und + `shellcheck` wurde aufgerufen, ohne vorhanden zu sein. Die Zusage „Qualitätsschranken“ galt damit + nur lokal (#019) +- Der README nannte für die Entwicklungsdatenbank Port 5433; `docker-compose.dev.yml` nimmt 5434. + Wer dem README folgte, landete auf der Datenbank eines anderen Projekts — mit „0 Treffer“ als + einzigem Hinweis (#019) + ### Sicherheit - Der Compose-Stand bricht ab, wenn `APP_SECRET`, `POSTGRES_PASSWORD` oder `APP_URL` fehlen, diff --git a/README.md b/README.md index e520463..3d5bd4a 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ der Wechsel eine Datenänderung und keine Migration. corepack enable pnpm pnpm install cp .env.example .env # APP_SECRET und Passwörter ausfüllen -pnpm db:up # PostgreSQL auf 127.0.0.1:5433 +pnpm db:up # PostgreSQL auf 127.0.0.1:5434 pnpm db:migrate pnpm db:seed pnpm dev # http://localhost:3000 @@ -104,8 +104,9 @@ nc -z localhost 5432 && echo "FEHLER: offen" || echo "dicht" docker compose exec web nc -z postgres 5432 && echo "von innen erreichbar" ``` -Für die Entwicklung gibt es `docker-compose.dev.yml`, das PostgreSQL auf `5433` öffnet. Diese -Datei ist nicht für den Betrieb gedacht. +Für die Entwicklung gibt es `docker-compose.dev.yml`, das PostgreSQL auf `5434` öffnet — umstellbar +über `DEV_DB_PORT`. 5432 ist oft lokal belegt und 5433 von einem anderen Projekt, daher dieser Port. +Diese Datei ist nicht für den Betrieb gedacht. ### Reverse Proxy @@ -127,8 +128,11 @@ Zurückspielen: gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel laufzettel ``` -Gesichert werden muss nur die Datenbank — die App hält keinen veränderlichen Zustand auf der -Platte. Das Volume heißt `laufzettel_datenbank`. +Gesichert werden müssen **zwei** Dinge: die Datenbank (das Volume heißt `laufzettel_datenbank`; die +App selbst hält keinen veränderlichen Zustand auf der Platte) **und die Geheimnisse aus der `.env`**. +Ohne das alte `APP_SECRET` sind nach dem Zurückspielen alle gespeicherten LDAP-Bind-Passwörter +unlesbar — und das merkt man erst beim nächsten Abgleich. Einzelheiten in +[`docs/betrieb/sicherung.md`](docs/betrieb/sicherung.md). ## Anmeldung @@ -207,7 +211,9 @@ Compose-Stand ab, statt mit Standardwerten zu starten. macht daraus ein PDF; jedem Release liegt es bei - [Architektur und Entscheidungen](docs/architektur.md) - [Austauschformat für Vorlagen](docs/vorlagen-format.md) — wie eine Vorlage als JSON aussieht -- [Anbindung an authentik](docs/betrieb/authentik.md) — Inbetriebnahme, Proxmox und Reverse Proxy folgen mit #019 +- [Betrieb](docs/betrieb/) — [Inbetriebnahme mit Compose](docs/betrieb/inbetriebnahme.md), + [nativ im Proxmox-LXC](docs/betrieb/proxmox.md), [Reverse Proxy](docs/betrieb/reverse-proxy.md), + [Anbindung an authentik](docs/betrieb/authentik.md), [Sichern und Zurückspielen](docs/betrieb/sicherung.md) - [Konventionen](CLAUDE.md) — Sprache, Schichtung, feste Regeln - [Änderungen](CHANGELOG.md) · [Entwicklungstagebuch](docs/devlog.md) diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..9abc3c5 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,1058 @@ +#!/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 "$@" diff --git a/deploy/proxmox/laufzettel.sh b/deploy/proxmox/laufzettel.sh new file mode 100755 index 0000000..97171fd --- /dev/null +++ b/deploy/proxmox/laufzettel.sh @@ -0,0 +1,963 @@ +#!/usr/bin/env bash +# +# Laufzettel in einen frischen LXC auf Proxmox VE ausrollen. +# +# Aufruf auf der Proxmox-Shell als root: +# +# bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/Laufzettel/raw/branch/main/deploy/proxmox/laufzettel.sh)" +# +# Dieses Skript legt den Container an, startet ihn und ruft darin deploy/install.sh +# auf. Es installiert selbst nichts: die gesamte Installationslogik steht in +# install.sh, damit der native Weg (Debian ohne Proxmox) und der LXC-Weg nicht +# auseinanderlaufen (#019). +# +# Trockenlauf ohne Proxmox-Host: bash deploy/proxmox/laufzettel.sh --dry-run +# +set -Eeuo pipefail + +# --------------------------------------------------------------------------- +# Feste Werte +# --------------------------------------------------------------------------- + +readonly SKRIPTNAME="laufzettel.sh" +readonly REPO_ROH="https://git.wilde-server.de/florian.wilde/Laufzettel/raw/branch" +readonly REPO_GIT="https://git.wilde-server.de/florian.wilde/Laufzettel.git" +readonly ENV_DATEI="/etc/laufzettel/laufzettel.env" +# Leihgabe von install.sh, kein Aufbewahrungsort: dieses Skript liest das +# Erstpasswort daraus und löscht die Datei sofort wieder. Siehe abschluss_zeigen(). +readonly PASSWORT_DATEI="/root/laufzettel-erstpasswort" + +# Deutsche Anführungszeichen als Variablen: in doppelten Anführungszeichen +# hält shellcheck sie für einen Tippfehler (SC1111), als Literal in einfachen +# ist es eindeutig. Lesbar bleibt der Text trotzdem richtig. +readonly ANF_AUF='„' +readonly ANF_ZU='“' + +# Vorgaben des Standardwegs. Der Erweitert-Dialog nimmt sie als Vorbelegung. +readonly STD_HOSTNAME="laufzettel" +readonly STD_DEBIAN="13" +readonly STD_KERNE="2" +readonly STD_RAM="2048" +readonly STD_PLATTE="8" +readonly STD_SWAP="512" +readonly STD_BRUECKE="vmbr0" +readonly STD_IP="dhcp" +readonly STD_UNPRIV="1" + +# Laufzeitwerte, aus dem Dialog oder aus den Vorgaben. +CTID="" +HOSTNAME_CT="$STD_HOSTNAME" +DEBIAN="$STD_DEBIAN" +KERNE="$STD_KERNE" +RAM="$STD_RAM" +PLATTE="$STD_PLATTE" +SWAP="$STD_SWAP" +BRUECKE="$STD_BRUECKE" +IP_WUNSCH="$STD_IP" +GATEWAY="" +UNPRIV="$STD_UNPRIV" +SPEICHER_ROOT="" +SPEICHER_VORLAGE="" +VORLAGE="" +CT_IP="" + +TROCKEN="nein" +MODUS="frage" # frage | standard | erweitert +NUR_AKTUALISIEREN="nein" +ZWEIG="main" +# Steht auf ja, sobald "pct create" gelaufen ist, und wieder auf nein, sobald +# der Container fertig eingerichtet ist. Nur dazwischen darf das Aufräumen ihn +# wieder entfernen. +HALB_ANGELEGT="nein" +# Ergebnis der letzten Leseoperation, siehe pve_lesen(). +ANTWORT="" +# Nummer des laufenden Abschnitts. Kommt aus der Position, wird nie gespeichert. +SCHRITT=0 + +# --------------------------------------------------------------------------- +# Ausgabe +# --------------------------------------------------------------------------- + +# Farbe nur, wenn die Ausgabe wirklich an ein Terminal geht — sonst landen die +# Steuerzeichen in Protokollen und Pipes. +if [ -t 1 ]; then + F_AUS=$'\033[0m' + F_FETT=$'\033[1m' + F_GRAU=$'\033[90m' + F_LIME=$'\033[38;5;190m' + F_ROT=$'\033[31m' + F_GELB=$'\033[33m' +else + F_AUS="" + F_FETT="" + F_GRAU="" + F_LIME="" + F_ROT="" + F_GELB="" +fi + +kopf() { + SCHRITT=$((SCHRITT + 1)) + printf '\n%s%02d · %s%s\n' "$F_FETT" "$SCHRITT" "$1" "$F_AUS" + printf '%s%s%s\n' "$F_GRAU" "------------------------------------------------------------" "$F_AUS" +} + +info() { printf ' %s\n' "$1"; } +notiz() { printf ' %s%s%s\n' "$F_GRAU" "$1" "$F_AUS"; } +erfolg() { printf ' %sok%s %s\n' "$F_LIME" "$F_AUS" "$1"; } +warnung() { printf ' %sAchtung%s %s\n' "$F_GELB" "$F_AUS" "$1" >&2; } +fehlertext() { printf ' %sFehler%s %s\n' "$F_ROT" "$F_AUS" "$1" >&2; } + +# Bricht mit einem Satz ab, der sagt, was fehlt. Der ERR-Trap wird vorher +# abgeräumt, damit ein absichtlicher Abbruch nicht als Programmfehler mit +# Zeilennummer hinterhergemeldet wird. +abbrechen() { + trap - ERR + fehlertext "$1" + exit 1 +} + +# Wie abbrechen(), lässt den Container aber ausdrücklich stehen. Für alles, was +# nach dem Start schiefgeht: dort steckt meist ein Netzfehler, den man sich +# ansehen will, und die Meldung verspricht, dass der Container noch da ist. +abbrechen_und_behalten() { + HALB_ANGELEGT="nein" + abbrechen "$1" +} + +# --------------------------------------------------------------------------- +# Spinner +# --------------------------------------------------------------------------- + +SPINNER_PID="" + +# Zeigt einen Spinner, solange ein langer Schritt läuft. Ohne Terminal (Pipe, +# Protokoll, Trockenlauf) bleibt es bei einer Zeile Text, damit die Ausgabe +# lesbar bleibt. +spinner_an() { + if [ ! -t 1 ] || [ "$TROCKEN" = "ja" ]; then + info "$1 …" + return 0 + fi + local text="$1" + ( + # Der Unterprozess setzt eigene Traps, damit ein Abbruch im Hauptprozess + # nicht erst durch die Fehlerbehandlung des Spinners läuft. + trap 'exit 0' TERM INT + # Der Backslash steht als $'\\', damit er nicht wie ein missglückter + # Maskierungsversuch aussieht (shellcheck SC1003). + local zeichen=('|' '/' '-' $'\\') + local i=0 + while :; do + i=$(((i + 1) % ${#zeichen[@]})) + printf '\r %s%s%s %s' "$F_LIME" "${zeichen[i]}" "$F_AUS" "$text" + sleep 0.2 + done + ) & + SPINNER_PID=$! +} + +spinner_aus() { + if [ -n "$SPINNER_PID" ]; then + kill "$SPINNER_PID" 2>/dev/null || true + wait "$SPINNER_PID" 2>/dev/null || true + SPINNER_PID="" + printf '\r\033[2K' + fi +} + +# --------------------------------------------------------------------------- +# Abbruch und Aufräumen +# --------------------------------------------------------------------------- + +# ERR-Trap mit Zeilennummer: bei einem Fehler soll niemand raten müssen, +# welcher der vielen pct-Aufrufe es war. +bei_fehler() { + local status="$1" zeile="$2" befehl="$3" + # Nur die erste Meldung zählt: set -e lässt den Fehler aus der Funktion + # heraus noch einmal aufschlagen, und der zweite Aufschlag kennt die + # Zeilennummer nicht mehr. + trap - ERR + spinner_aus + fehlertext "Zeile $zeile, Status $status: $befehl" +} + +# Läuft in jedem Fall. Räumt nur weg, was dieser Lauf selbst angelegt hat, und +# nur solange im Container noch nichts steckt, was jemand ansehen wollen könnte: +# sobald install.sh angelaufen ist, bleibt der Container stehen und wird +# ausdrücklich benannt, statt still verschwunden zu sein. +aufraeumen() { + local status=$? + spinner_aus + if [ "$status" -ne 0 ] && [ "$HALB_ANGELEGT" = "ja" ] && [ -n "$CTID" ]; then + warnung "Container $CTID ist nur halb angelegt und wird wieder entfernt." + pve_ausfuehren pct stop "$CTID" --skiplock || true + pve_ausfuehren pct destroy "$CTID" --force --purge || true + fi + return "$status" +} + +trap 'bei_fehler "$?" "$LINENO" "$BASH_COMMAND"' ERR +trap aufraeumen EXIT +trap 'fehlertext "Durch Signal abgebrochen."; exit 130' INT TERM + +# --------------------------------------------------------------------------- +# Proxmox-Aufrufe +# --------------------------------------------------------------------------- + +# Setzt ein Argument so, dass man es von Hand nachtippen kann. Harmlose Zeichen +# bleiben nackt — "printf %q" würde jedes Komma in --net0 mit einem Backslash +# versehen, und der Aufruf wäre zwar richtig, aber nicht mehr zu lesen. +argument_zeigen() { + case "$1" in + "" | *[!A-Za-z0-9_@%+=:,./-]*) + printf "'%s'" "${1//\'/\'\\\'\'}" + ;; + *) + printf '%s' "$1" + ;; + esac +} + +# Zeigt einen Befehl so, dass man ihn von Hand nachtippen kann. +trocken_zeigen() { + local teil ausgabe="" + for teil in "$@"; do + ausgabe="$ausgabe $(argument_zeigen "$teil")" + done + printf ' %s[trocken]%s%s\n' "$F_GELB" "$F_AUS" "$ausgabe" +} + +# Zeigt einen Befehl, der als Text vorliegt, weil er in einer Pipeline steckt. +trocken_text() { + printf ' %s[trocken]%s %s\n' "$F_GELB" "$F_AUS" "$1" +} + +# Jeder ändernde Aufruf (pct, pveam, pvesh) läuft hierüber. Im Trockenlauf wird +# er nur ausgegeben. +pve_ausfuehren() { + if [ "$TROCKEN" = "ja" ]; then + trocken_zeigen "$@" + return 0 + fi + "$@" +} + +# Lesende Aufrufe. Das Ergebnis steht danach in $ANTWORT und wird nicht per +# Kommandosubstitution zurückgegeben: im Trockenlauf sollen auch die +# Lesebefehle auf der Standardausgabe erscheinen und dürfen nicht im +# eingefangenen Ergebnis landen. Das erste Argument ist der Platzhalter, mit +# dem der Trockenlauf weiterrechnet — nur so läuft er ohne PVE-Host bis zum +# Abschlussbildschirm durch. Das zweite Argument ist der Befehl als Text: die +# Leser stecken in Pipelines und werden darum von kleinen Funktionen gekapselt, +# und deren Namen wollte im Trockenlauf niemand sehen. +pve_lesen() { + local platzhalter="$1" anzeige="$2" + shift 2 + if [ "$TROCKEN" = "ja" ]; then + trocken_text "$anzeige" + ANTWORT="$platzhalter" + if [ -n "$platzhalter" ]; then + notiz "Im Trockenlauf angenommen: $platzhalter" + else + notiz "Im Trockenlauf angenommen: leer, also kein Treffer" + fi + return 0 + fi + ANTWORT="$("$@")" +} + +# --------------------------------------------------------------------------- +# Hilfe +# --------------------------------------------------------------------------- + +hilfe_zeigen() { + cat <<'ENDE' +laufzettel.sh — rollt Laufzettel in einen frischen LXC auf Proxmox VE aus. + +Aufruf auf der Proxmox-Shell als root: + + bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/Laufzettel/raw/branch/main/deploy/proxmox/laufzettel.sh)" + +Was es tut: + + 1. Prüft root und Proxmox VE 8 oder 9. + 2. Fragt per whiptail Standard gegen Erweitert. Standard nimmt die nächste + freie CTID, Hostname laufzettel, Debian 13, 2 Kerne, 2 GB RAM, 8 GB Platte, + unprivilegiert, DHCP auf vmbr0. Ohne Terminal oder ohne whiptail gelten + die Standardwerte. + 3. Holt die Debian-Vorlage per pveam, falls sie noch fehlt. + 4. Legt den Container an (pct create), startet ihn, wartet auf das Netz. + 5. Führt deploy/install.sh im Container aus: PostgreSQL, Node, Build, + Migration, Seed, systemd-Dienst. + 6. Zeigt Adresse, Admin-Kennung und das erzeugte Passwort. + + Ein zweiter Aufruf erkennt einen vorhandenen Container am Hostnamen und + bietet an, ihn zu aktualisieren, statt einen zweiten anzulegen. + +Optionen: + + --dry-run Gibt alle pct-, pveam- und pvesh-Aufrufe nur aus, legt + nichts an und braucht keinen Proxmox-Host. Auch die + Prüfungen auf root und PVE werden übersprungen. + --standard Ohne Dialog, alle Standardwerte nehmen. + --erweitert Direkt in den Erweitert-Dialog. + --aktualisieren Nur aktualisieren, ohne Rückfrage. Bricht ab, wenn es + keinen Container mit dem Hostnamen laufzettel gibt. + --zweig Git-Zweig, aus dem install.sh geholt wird (Vorgabe: main). + --hilfe, --help Diese Hilfe. + +Alles Weitere steht in docs/betrieb/. +ENDE +} + +# --------------------------------------------------------------------------- +# Argumente +# --------------------------------------------------------------------------- + +argumente_lesen() { + while [ $# -gt 0 ]; do + case "$1" in + --dry-run | --trockenlauf) + TROCKEN="ja" + ;; + --standard) + MODUS="standard" + ;; + --erweitert) + MODUS="erweitert" + ;; + --aktualisieren) + NUR_AKTUALISIEREN="ja" + ;; + --zweig) + [ $# -ge 2 ] || abbrechen "--zweig braucht einen Namen." + ZWEIG="$2" + shift + ;; + --hilfe | --help | -h) + hilfe_zeigen + exit 0 + ;; + *) + trap - ERR + fehlertext "Unbekannte Option: $1" + printf '\n' + hilfe_zeigen + exit 2 + ;; + esac + shift + done +} + +# --------------------------------------------------------------------------- +# Umgebung +# --------------------------------------------------------------------------- + +umgebung_pruefen() { + kopf "Umgebung" + + if [ "$TROCKEN" = "ja" ]; then + notiz "Trockenlauf: Prüfung auf root und auf Proxmox VE 8/9 übersprungen." + notiz "Es wird nichts angelegt, nichts geladen, nichts gestartet." + return 0 + fi + + [ "$(id -u)" -eq 0 ] || abbrechen "Dieses Skript muss als root auf der Proxmox-Shell laufen." + + command -v pveversion >/dev/null 2>&1 || abbrechen \ + "pveversion fehlt — das ist kein Proxmox-VE-Host. Für Debian ohne Proxmox gibt es deploy/install.sh." + command -v pct >/dev/null 2>&1 || abbrechen "pct fehlt — Proxmox VE ist unvollständig installiert." + command -v pveam >/dev/null 2>&1 || abbrechen "pveam fehlt — Proxmox VE ist unvollständig installiert." + command -v pvesm >/dev/null 2>&1 || abbrechen "pvesm fehlt — Proxmox VE ist unvollständig installiert." + + # pveversion liefert zum Beispiel: pve-manager/8.4.1/2a5fa54a8503f96d + local voll haupt + voll="$(pveversion | sed -n 's#^pve-manager/\([0-9][0-9.]*\)/.*#\1#p' | head -n 1)" + haupt="${voll%%.*}" + case "$haupt" in + 8 | 9) + erfolg "Proxmox VE $voll" + ;; + "") + abbrechen "Die Version von Proxmox VE ist nicht erkennbar (pveversion sagt: $(pveversion | head -n 1))." + ;; + *) + abbrechen "Proxmox VE $voll wird nicht unterstützt — dieses Skript ist für PVE 8 und 9 gebaut." + ;; + esac + + erfolg "Als root auf $(hostname)" +} + +# --------------------------------------------------------------------------- +# Dialog +# --------------------------------------------------------------------------- + +# Liest einen Wert per whiptail in die genannte Variable, deren bisheriger +# Inhalt die Vorgabe ist. Der Weg über eine Namensreferenz statt über eine +# Kommandosubstitution ist Absicht: bricht jemand den Dialog ab, muss das +# ganze Skript enden, und ein „exit“ in einer Kommandosubstitution beendet nur +# die Subshell — der Wert wäre leer und das Skript liefe weiter. +frage_wert() { + local -n ziel="$1" + local titel="$2" wert + if ! wert="$(whiptail --title "Laufzettel einrichten" \ + --inputbox "$titel" 9 64 "$ziel" 3>&1 1>&2 2>&3)"; then + abbrechen "Abgebrochen. Es wurde nichts angelegt." + fi + [ -n "$wert" ] || wert="$ziel" + ziel="$wert" +} + +zahl_pruefen() { + case "$2" in + '' | *[!0-9]*) abbrechen "$1 muss eine Zahl sein, war aber $ANF_AUF$2$ANF_ZU." ;; + esac +} + +dialog_fuehren() { + kopf "Werte" + + # Ohne Terminal oder ohne whiptail gibt es keinen Dialog. Dann gilt der + # Standardweg, und das wird ausdrücklich gesagt — auch wenn --erweitert + # verlangt war, denn fragen kann das Skript dann niemanden. + if [ "$MODUS" != "standard" ]; then + if [ ! -t 0 ]; then + MODUS="standard" + notiz "Kein Terminal an der Standardeingabe — es gelten die Standardwerte." + elif ! command -v whiptail >/dev/null 2>&1; then + MODUS="standard" + notiz "whiptail ist nicht installiert — es gelten die Standardwerte." + fi + fi + + if [ "$MODUS" = "frage" ]; then + local wahl + if ! wahl="$(whiptail --title "Laufzettel einrichten" \ + --menu "Wie soll der Container aussehen?" 13 68 2 \ + "standard" "$STD_KERNE Kerne, $((STD_RAM / 1024)) GB RAM, $STD_PLATTE GB Platte, DHCP" \ + "erweitert" "Jeden Wert selbst setzen" 3>&1 1>&2 2>&3)"; then + abbrechen "Abgebrochen. Es wurde nichts angelegt." + fi + MODUS="$wahl" + fi + + # Die nächste freie CTID kommt immer aus dem Cluster, im Erweitert-Weg nur + # als Vorbelegung. + pve_lesen "100" "pvesh get /cluster/nextid" naechste_ctid_holen + CTID="$ANTWORT" + [ -n "$CTID" ] || abbrechen "pvesh get /cluster/nextid hat keine CTID geliefert." + + if [ "$MODUS" = "erweitert" ]; then + frage_wert CTID "CTID des neuen Containers" + frage_wert HOSTNAME_CT "Hostname" + frage_wert DEBIAN "Debian-Hauptversion (13 ist erprobt)" + frage_wert KERNE "Kerne" + frage_wert RAM "Arbeitsspeicher in MB" + frage_wert SWAP "Swap in MB" + frage_wert PLATTE "Plattengröße in GB" + frage_wert BRUECKE "Netzwerkbrücke" + frage_wert IP_WUNSCH "IP-Adresse: dhcp oder zum Beispiel 192.168.1.50/24" + if [ "$IP_WUNSCH" != "dhcp" ]; then + frage_wert GATEWAY "Gateway zur festen IP-Adresse" + [ -n "$GATEWAY" ] || abbrechen "Zu einer festen IP-Adresse gehört ein Gateway." + fi + frage_wert SPEICHER_ROOT "Speicher für das Wurzeldateisystem" + frage_wert SPEICHER_VORLAGE "Speicher für die Debian-Vorlage" + if whiptail --title "Laufzettel einrichten" \ + --yesno "Unprivilegierten Container anlegen?\n\nEmpfohlen ist ja." 10 60; then + UNPRIV="1" + else + UNPRIV="0" + fi + fi + + zahl_pruefen "Die CTID" "$CTID" + zahl_pruefen "Die Zahl der Kerne" "$KERNE" + zahl_pruefen "Der Arbeitsspeicher" "$RAM" + zahl_pruefen "Die Plattengröße" "$PLATTE" + zahl_pruefen "Der Swap" "$SWAP" + zahl_pruefen "Die Debian-Version" "$DEBIAN" + [ "$CTID" -ge 100 ] || abbrechen "Die CTID muss mindestens 100 sein, war aber $CTID." + [ "$KERNE" -gt 0 ] || abbrechen "Der Container braucht mindestens einen Kern." + [ "$RAM" -ge 1024 ] || abbrechen "Unter 1024 MB reicht der Speicher für ${ANF_AUF}next build$ANF_ZU nicht." + [ "$PLATTE" -ge 8 ] || abbrechen "Unter 8 GB reicht die Platte für Node, pnpm-Store und PostgreSQL nicht." + + [ -n "$SPEICHER_ROOT" ] || abbrechen "Ohne Speicher für das Wurzeldateisystem geht es nicht." + [ -n "$SPEICHER_VORLAGE" ] || abbrechen "Ohne Speicher für die Vorlage geht es nicht." + + info "CTID $CTID · $HOSTNAME_CT · Debian $DEBIAN · $KERNE Kerne · $RAM MB RAM · $PLATTE GB Platte" + if [ "$UNPRIV" = "1" ]; then + info "unprivilegiert · $BRUECKE · $IP_WUNSCH" + else + info "privilegiert · $BRUECKE · $IP_WUNSCH" + fi +} + +# Alle Leser enden auf „|| true“: ein leeres Ergebnis soll beim Aufrufer eine +# verständliche Meldung auslösen und nicht den ERR-Trap mit einer Zeilennummer, +# die niemandem weiterhilft. Ohne das würde schon ein grep ohne Treffer wegen +# pipefail als Programmfehler durchschlagen. +naechste_ctid_holen() { + # pvesh gibt die Zahl je nach Ausgabeformat in Anführungszeichen zurück. + pvesh get /cluster/nextid 2>/dev/null | tr -d '"[:space:]' || true +} + +# --------------------------------------------------------------------------- +# Speicher +# --------------------------------------------------------------------------- + +# Erster aktive Speicher, der den gewünschten Inhalt aufnehmen kann. Die +# Reihenfolge bevorzugt, was auf einem Standard-PVE üblich ist. +speicher_suchen() { + local inhalt="$1" liste bevorzugt + liste="$(pvesm status --content "$inhalt" 2>/dev/null | + awk 'NR > 1 && $3 == "active" { print $1 }' || true)" + for bevorzugt in local-lvm local-zfs local-btrfs local; do + if printf '%s\n' "$liste" | grep -qx -- "$bevorzugt"; then + printf '%s' "$bevorzugt" + return 0 + fi + done + printf '%s\n' "$liste" | head -n 1 || true +} + +speicher_waehlen() { + kopf "Speicher" + + pve_lesen "local-lvm" "pvesm status --content rootdir" speicher_suchen rootdir + SPEICHER_ROOT="$ANTWORT" + [ -n "$SPEICHER_ROOT" ] || abbrechen \ + "Kein aktiver Speicher mit Inhalt ${ANF_AUF}rootdir$ANF_ZU gefunden — ohne den kann kein Container angelegt werden." + + pve_lesen "local" "pvesm status --content vztmpl" speicher_suchen vztmpl + SPEICHER_VORLAGE="$ANTWORT" + [ -n "$SPEICHER_VORLAGE" ] || abbrechen \ + "Kein aktiver Speicher mit Inhalt ${ANF_AUF}vztmpl$ANF_ZU gefunden — dort müsste die Debian-Vorlage liegen." + + erfolg "Wurzeldateisystem auf $SPEICHER_ROOT, Vorlage auf $SPEICHER_VORLAGE" +} + +# --------------------------------------------------------------------------- +# Debian-Vorlage +# --------------------------------------------------------------------------- + +# Neueste passende Vorlage aus der Liste von pveam. Der Dateiname ändert sich +# mit jedem Punktrelease (debian-13-standard_13.1-1_amd64.tar.zst), darum wird +# er gesucht und nicht festgeschrieben. +vorlage_suchen() { + pveam available --section system 2>/dev/null | + awk '{ print $2 }' | + grep -E "^debian-$1-standard_" | + sort -V | + tail -n 1 || true +} + +vorlage_vorhanden() { + if pveam list "$1" 2>/dev/null | awk '{ print $1 }' | grep -qF -- "vztmpl/$2"; then + printf 'ja' + else + printf 'nein' + fi +} + +vorlage_besorgen() { + kopf "Debian-Vorlage" + + spinner_an "Vorlagenliste auffrischen" + pve_ausfuehren pveam update >/dev/null 2>&1 || + warnung "pveam update ist fehlgeschlagen, es gilt die zwischengespeicherte Liste." + spinner_aus + + pve_lesen "debian-$DEBIAN-standard_$DEBIAN.1-1_amd64.tar.zst" \ + "pveam available --section system" vorlage_suchen "$DEBIAN" + VORLAGE="$ANTWORT" + [ -n "$VORLAGE" ] || abbrechen \ + "In ${ANF_AUF}pveam available$ANF_ZU steht keine Vorlage debian-$DEBIAN-standard. Andere Debian-Version wählen oder die Vorlage von Hand laden." + + pve_lesen "nein" "pveam list $SPEICHER_VORLAGE" vorlage_vorhanden "$SPEICHER_VORLAGE" "$VORLAGE" + if [ "$ANTWORT" = "ja" ]; then + erfolg "Vorlage $VORLAGE liegt schon auf $SPEICHER_VORLAGE" + else + spinner_an "Vorlage $VORLAGE laden" + pve_ausfuehren pveam download "$SPEICHER_VORLAGE" "$VORLAGE" + spinner_aus + erfolg "Vorlage $VORLAGE geladen" + fi +} + +# --------------------------------------------------------------------------- +# Container anlegen und starten +# --------------------------------------------------------------------------- + +container_anlegen() { + kopf "Container anlegen" + + # Ohne firewall=…: auf der Befehlszeile ist die Firewall am Gerät ohnehin aus, + # und der Aufruf soll genau das zeigen, was er tut. + local netz="name=eth0,bridge=$BRUECKE" + if [ "$IP_WUNSCH" = "dhcp" ]; then + netz="$netz,ip=dhcp" + else + netz="$netz,ip=$IP_WUNSCH,gw=$GATEWAY" + fi + + # nesting=1 ist hier keine Bequemlichkeit: die systemd-Unit aus install.sh + # arbeitet mit ProtectSystem=strict und PrivateTmp=yes, und diese Sandkästen + # brauchen im unprivilegierten LXC eigene Mount-Namensräume (#019). + # Ab hier darf das Aufräumen den Container wieder entfernen. + HALB_ANGELEGT="ja" + spinner_an "Container $CTID anlegen" + pve_ausfuehren pct create "$CTID" "$SPEICHER_VORLAGE:vztmpl/$VORLAGE" \ + --hostname "$HOSTNAME_CT" \ + --ostype debian \ + --arch amd64 \ + --cores "$KERNE" \ + --memory "$RAM" \ + --swap "$SWAP" \ + --rootfs "$SPEICHER_ROOT:$PLATTE" \ + --net0 "$netz" \ + --unprivileged "$UNPRIV" \ + --features nesting=1 \ + --onboot 1 \ + --timezone host \ + --description "Laufzettel — Checklisten für wiederkehrende IT-Arbeiten. Angelegt von deploy/proxmox/$SKRIPTNAME." + spinner_aus + erfolg "Container $CTID angelegt" + + spinner_an "Container starten" + pve_ausfuehren pct start "$CTID" + spinner_aus + erfolg "Container $CTID gestartet" +} + +# Wartet, bis im Container Befehle laufen und Namen aufgelöst werden. Ohne das +# scheitert das erste apt-get mit einem irritierenden DNS-Fehler. +netz_abwarten() { + kopf "Netz" + + if [ "$TROCKEN" = "ja" ]; then + trocken_zeigen pct exec "$CTID" -- test -d /run/systemd/system + trocken_zeigen pct exec "$CTID" -- ip -4 -o addr show scope global + trocken_zeigen pct exec "$CTID" -- getent hosts deb.debian.org + CT_IP="192.0.2.42" + notiz "Im Trockenlauf wird nicht gewartet; als Adresse steht hier die Beispiel-IP $CT_IP." + return 0 + fi + + local versuch + spinner_an "Auf den Start des Containers warten" + for ((versuch = 1; versuch <= 60; versuch++)); do + if pct exec "$CTID" -- test -d /run/systemd/system >/dev/null 2>&1; then + break + fi + if [ "$versuch" -eq 60 ]; then + spinner_aus + abbrechen_und_behalten "Container $CTID ist nach 120 Sekunden nicht betriebsbereit. Er bleibt stehen, nachsehen mit: pct enter $CTID" + fi + sleep 2 + done + spinner_aus + + spinner_an "Auf IP-Adresse und Namensauflösung warten" + for ((versuch = 1; versuch <= 60; versuch++)); do + CT_IP="$(pct exec "$CTID" -- ip -4 -o addr show scope global 2>/dev/null | + awk 'NR == 1 { split($4, teile, "/"); print teile[1] }')" + if [ -n "$CT_IP" ] && pct exec "$CTID" -- getent hosts deb.debian.org >/dev/null 2>&1; then + break + fi + if [ "$versuch" -eq 60 ]; then + spinner_aus + abbrechen_und_behalten "Container $CTID hat nach 120 Sekunden kein brauchbares Netz. Brücke $BRUECKE und DHCP prüfen; er bleibt stehen, nachsehen mit: pct enter $CTID" + fi + sleep 2 + done + spinner_aus + + erfolg "Container $CTID erreichbar unter $CT_IP" +} + +# --------------------------------------------------------------------------- +# Laufzettel einrichten +# --------------------------------------------------------------------------- + +# install.sh wird als einzelne Datei per curl geholt, das Repo wird hier nicht +# geklont: install.sh legt den Quellbaum selbst nach /opt/laufzettel und braucht +# ihn dort auch für den Aktualisierungsweg (git pull). Ein zweiter Klon daneben +# wäre eine zweite Wahrheit. Geholt wird von derselben Adresse, von der auch +# dieses Skript kam — wer einen eigenen Spiegel nutzt, bekommt beide Teile aus +# demselben Spiegel. +anwendung_einrichten() { + local zweck="$1" # einrichten | aktualisieren + kopf "Laufzettel $zweck" + + # Ab hier wird der Container nicht mehr weggeräumt: scheitert install.sh, will + # man das Protokoll darin lesen und nicht vor einem verschwundenen Container + # stehen. Jeder Fehler ab hier nennt darum die CTID. + HALB_ANGELEGT="nein" + + local roh_url="$REPO_ROH/$ZWEIG/deploy/install.sh" + + spinner_an "Paketliste und Grundwerkzeuge" + if ! pve_ausfuehren pct exec "$CTID" -- bash -c \ + 'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq curl git ca-certificates'; then + spinner_aus + abbrechen "apt-get ist im Container $CTID fehlgeschlagen. Nachsehen mit: pct enter $CTID" + fi + spinner_aus + erfolg "curl, git und ca-certificates sind vorhanden" + + # Erst herunterladen, dann ausführen: „curl | bash“ würde einen abgebrochenen + # Download stillschweigend als halbes Skript starten. + spinner_an "install.sh holen" + if ! pve_ausfuehren pct exec "$CTID" -- bash -c \ + "curl -fsSL --retry 3 -o /root/laufzettel-install.sh '$roh_url' && test -s /root/laufzettel-install.sh"; then + spinner_aus + abbrechen "install.sh war unter $roh_url nicht zu holen. Zweig richtig geschrieben? Kommt der Container an den Gitea-Server?" + fi + spinner_aus + erfolg "install.sh aus Zweig $ZWEIG geholt" + + # APP_URL und BIND_ADDR stehen erst hier fest, weil erst jetzt die IP bekannt + # ist. An APP_URL hängen die QR-Codes und die OIDC-Rückrufadresse. Bei einer + # Aktualisierung werden beide bewusst nicht mitgegeben: wer einen Reverse + # Proxy davorgesetzt und APP_URL auf den richtigen Namen gestellt hat, soll + # den nicht beim nächsten Aufruf gegen eine IP-Adresse getauscht bekommen. + local umgebung="LAUFZETTEL_REPO='$REPO_GIT' LAUFZETTEL_ZWEIG='$ZWEIG'" + umgebung="$umgebung LAUFZETTEL_PASSWORT_DATEI='$PASSWORT_DATEI'" + if [ "$zweck" = "einrichten" ]; then + umgebung="APP_URL='http://$CT_IP:3000' BIND_ADDR=0.0.0.0 $umgebung" + fi + + # Hier läuft ausdrücklich kein Spinner: install.sh meldet seine neun Schritte + # selbst, und das dauert Minuten. Ein Spinner würde diese Meldungen + # überschreiben und wäre das Gegenteil von Fortschrittsanzeige. + info "install.sh läuft: PostgreSQL, Node, Build, Migration, Seed. Das dauert einige Minuten." + notiz "Ab hier spricht install.sh selbst." + printf '\n' + if ! pve_ausfuehren pct exec "$CTID" -- bash -c "$umgebung bash /root/laufzettel-install.sh"; then + abbrechen "install.sh ist im Container $CTID fehlgeschlagen. Der Container bleibt stehen: pct enter $CTID, dann journalctl -u laufzettel -n 50" + fi + printf '\n' + erfolg "install.sh ist durchgelaufen" +} + +# Fragt von außen nach, ob der Dienst antwortet. install.sh prüft dasselbe +# selbst, aber nur auf seiner Bindeadresse im Container — erreichbar aus dem Netz +# ist damit noch nichts bewiesen. Ein Befund bricht nicht ab: die Installation +# steht dann schon, und die Adresse gehört auf den Abschlussbildschirm. +gesundheit_pruefen() { + kopf "Erreichbarkeit" + + local adresse="http://$CT_IP:3000/api/gesundheit" + if [ "$TROCKEN" = "ja" ]; then + trocken_text "curl -fsS $adresse" + notiz "Im Trockenlauf wird nicht gefragt." + return 0 + fi + + local versuch antwort="" + spinner_an "Auf Antwort von $adresse warten" + for ((versuch = 1; versuch <= 30; versuch++)); do + antwort="$(curl -fsS --max-time 5 "$adresse" 2>/dev/null || true)" + case "$antwort" in + *'"ok"'*) break ;; + esac + antwort="" + sleep 2 + done + spinner_aus + + if [ -n "$antwort" ]; then + erfolg "$adresse antwortet: $antwort" + else + warnung "$adresse antwortet nicht. Dienst: pct exec $CTID -- systemctl status laufzettel · Protokoll: pct exec $CTID -- journalctl -u laufzettel -n 50" + fi +} + +# --------------------------------------------------------------------------- +# Abschluss +# --------------------------------------------------------------------------- + +# Wie das Passwort hierher kommt: install.sh zeigt das beim ersten Lauf erzeugte +# Administrator-Passwort genau einmal auf seiner eigenen Ausgabe an — und die +# läuft hier hinter einem Spinner durch pct exec vorbei. In die Umgebungsdatei +# schreibt install.sh es ausdrücklich nicht, in der Datenbank steht nur der +# argon2-Hash. Für genau diesen Fall hat install.sh --passwort-datei +# beziehungsweise LAUFZETTEL_PASSWORT_DATEI: eine Datei mit 0600 und root, aus +# der ein maschineller Aufrufer das Passwort abliest und die er danach löscht. +# Das ist der Weg hier — lesen, einmal anzeigen, sofort wegwerfen. Die Ausgabe +# von install.sh mitzuschneiden und zu durchsuchen wäre von deren +# Formulierungen abhängig und würde das Passwort zusätzlich durch Pipes und +# Protokolldateien schleifen. +# +# Die Kennung dagegen steht in der Umgebungsdatei und wird von dort gelesen. +erstpasswort_lesen() { + pct exec "$CTID" -- sh -c \ + "sed -n 's/^SEED_ADMIN_PASSWORD=//p' '$PASSWORT_DATEI' 2>/dev/null | tail -n 1" 2>/dev/null || true +} + +env_wert_lesen() { + pct exec "$CTID" -- sh -c "sed -n 's/^$1=//p' '$ENV_DATEI' | tail -n 1" 2>/dev/null | + tr -d '\r' | sed -e 's/^"//' -e 's/"$//' || true +} + +abschluss_zeigen() { + local kennung passwort text + + kopf "Fertig" + + pve_lesen "admin@firma.example" "pct exec $CTID -- grep SEED_ADMIN_EMAIL $ENV_DATEI" \ + env_wert_lesen SEED_ADMIN_EMAIL + kennung="$ANTWORT" + [ -n "$kennung" ] || kennung="steht in $ENV_DATEI" + + if [ "$TROCKEN" = "ja" ]; then + trocken_text "pct exec $CTID -- cat $PASSWORT_DATEI" + passwort="(im Trockenlauf gibt es keines)" + else + pve_lesen "" "pct exec $CTID -- cat $PASSWORT_DATEI" erstpasswort_lesen + passwort="$ANTWORT" + fi + + # Die Leihgabe wird zurückgegeben, gelesen oder nicht: das Passwort soll nicht + # im Klartext im Container liegen bleiben. + pve_ausfuehren pct exec "$CTID" -- rm -f "$PASSWORT_DATEI" || true + + if [ -z "$passwort" ]; then + passwort="unverändert (das Konto bestand schon)" + fi + + text="$( + cat </dev/null 2>&1; then + whiptail --title "Laufzettel — fertig" --msgbox "$text" 30 78 + fi + + printf '%s\n' "$text" | sed 's/^/ /' + printf '\n' +} + +# --------------------------------------------------------------------------- +# Vorhandenen Container erkennen +# --------------------------------------------------------------------------- + +container_status() { + pct status "$1" 2>/dev/null | awk '{ print $2 }' || true +} + +# „pct list“ gibt VMID, Status, gegebenenfalls Lock und den Namen aus. Der Name +# ist die letzte Spalte, und Hostnamen enthalten keine Leerzeichen — darum $NF. +container_mit_hostname() { + pct list 2>/dev/null | awk -v gesucht="$1" 'NR > 1 && $NF == gesucht { print $1 }' | + head -n 1 || true +} + +# Liefert 0, wenn der vorhandene Container aktualisiert werden soll. +aktualisierung_anbieten() { + local id="$1" + if [ "$NUR_AKTUALISIEREN" = "ja" ]; then + notiz "--aktualisieren war gesetzt — Container $id wird aktualisiert." + return 0 + fi + if [ ! -t 0 ] || ! command -v whiptail >/dev/null 2>&1; then + notiz "Kein Dialog möglich — Container $id wird aktualisiert." + return 0 + fi + whiptail --title "Laufzettel ist schon da" \ + --yes-button "Aktualisieren" --no-button "Abbrechen" \ + --yesno "Container $id trägt schon den Hostnamen $ANF_AUF$STD_HOSTNAME$ANF_ZU. + +Aktualisieren heißt: install.sh erneut ausführen — git pull, Build, Migration, Dienst neu starten. Geheimnisse und Daten bleiben unberührt. + +Einen zweiten Laufzettel legt dieses Skript nicht daneben; dafür im Erweitert-Weg einen anderen Hostnamen wählen." 17 72 +} + +# --------------------------------------------------------------------------- +# Ablauf +# --------------------------------------------------------------------------- + +haupt() { + argumente_lesen "$@" + + printf '\n%sLaufzettel — Proxmox-LXC%s\n' "$F_FETT" "$F_AUS" + if [ "$TROCKEN" = "ja" ]; then + printf '%sTrockenlauf: es wird nur gezeigt, was geschehen würde.%s\n' "$F_GELB" "$F_AUS" + fi + + umgebung_pruefen + + # Vor allem anderen: gibt es Laufzettel hier schon? + kopf "Vorhandener Container" + # Im Trockenlauf gibt es kein pct list. Mit --aktualisieren wird darum eine + # vorhandene CTID angenommen, sonst keine — so lässt sich jeder der beiden + # Wege trocken durchspielen. + local angenommen="" + [ "$NUR_AKTUALISIEREN" = "ja" ] && angenommen="100" + pve_lesen "$angenommen" "pct list" container_mit_hostname "$STD_HOSTNAME" + local vorhanden="$ANTWORT" + if [ -z "$vorhanden" ] && [ "$NUR_AKTUALISIEREN" = "ja" ]; then + abbrechen "Kein Container mit dem Hostnamen $STD_HOSTNAME — mit --aktualisieren wird keiner angelegt." + fi + if [ -z "$vorhanden" ]; then + erfolg "Kein Container mit dem Hostnamen $STD_HOSTNAME — es wird ein neuer angelegt." + speicher_waehlen + dialog_fuehren + vorlage_besorgen + container_anlegen + netz_abwarten + anwendung_einrichten "einrichten" + gesundheit_pruefen + abschluss_zeigen + return 0 + fi + + info "Container $vorhanden trägt den Hostnamen $STD_HOSTNAME." + if ! aktualisierung_anbieten "$vorhanden"; then + abbrechen "Abgebrochen. Es wurde nichts geändert." + fi + CTID="$vorhanden" + # Der Container kann gestoppt sein. Erst fragen, dann starten — „pct start“ + # auf einem laufenden Container meldet nur einen Fehler, den niemand braucht. + pve_lesen "running" "pct status $CTID" container_status "$CTID" + if [ "$ANTWORT" = "running" ]; then + info "Container $CTID läuft." + else + spinner_an "Container $CTID starten" + pve_ausfuehren pct start "$CTID" + spinner_aus + fi + netz_abwarten + anwendung_einrichten "aktualisieren" + gesundheit_pruefen + abschluss_zeigen +} + +haupt "$@" diff --git a/docker-compose.yml b/docker-compose.yml index e5996ac..b39a449 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,29 @@ services: target: bauen restart: "no" environment: - DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-laufzettel}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-laufzettel}?schema=public} + # Immer aus den Einzelteilen, **nie** aus einer DATABASE_URL der .env: dort + # steht die Adresse fuer die Entwicklung (localhost und ein offener Port), + # und im Container zeigt sie ins Leere. Wer den dokumentierten Weg ging, + # bekam einen Stand, der nicht startet — gefunden bei der Abnahme zu #019. + # Im Compose-Stand heisst die Datenbank immer `postgres`, das ist der + # Dienstname im Netz `intern`. + DATABASE_URL: postgresql://${POSTGRES_USER:-laufzettel}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-laufzettel}?schema=public + # + # Fuer die Migrationen braucht es nichts weiter — wohl aber fuer den Seed, + # der ueber denselben Dienst gefahren wird: + # `docker compose run --rm migrate pnpm db:seed` (siehe README). + # + # APP_SECRET verschluesselt das Bind-Passwort der Beispiel-Verzeichnis- + # quelle. Ohne es brach der Seed mit einem Stapelverlauf ab, und zwar + # nachdem er die Vorlagen schon angelegt hatte — gefunden bei der Abnahme + # zu #019: der dokumentierte Befehl scheiterte auf einer frischen + # Installation. + APP_SECRET: ${APP_SECRET:?APP_SECRET fehlt in der .env} + SEED_MODE: ${SEED_MODE:-INTERN} + SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-} + SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-} + SEED_ORG_NAME: ${SEED_ORG_NAME:-} + SEED_ORG_KUERZEL: ${SEED_ORG_KUERZEL:-} depends_on: postgres: condition: service_healthy @@ -55,7 +77,13 @@ services: GIT_SHA: ${GIT_SHA:-} restart: unless-stopped environment: - DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-laufzettel}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-laufzettel}?schema=public} + # Immer aus den Einzelteilen, **nie** aus einer DATABASE_URL der .env: dort + # steht die Adresse fuer die Entwicklung (localhost und ein offener Port), + # und im Container zeigt sie ins Leere. Wer den dokumentierten Weg ging, + # bekam einen Stand, der nicht startet — gefunden bei der Abnahme zu #019. + # Im Compose-Stand heisst die Datenbank immer `postgres`, das ist der + # Dienstname im Netz `intern`. + DATABASE_URL: postgresql://${POSTGRES_USER:-laufzettel}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-laufzettel}?schema=public APP_SECRET: ${APP_SECRET:?APP_SECRET fehlt in der .env} APP_URL: ${APP_URL:?APP_URL fehlt in der .env} AUTH_LOCAL_ENABLED: ${AUTH_LOCAL_ENABLED:-false} @@ -83,7 +111,11 @@ services: - intern - extern healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/gesundheit"] + # 127.0.0.1, nicht localhost: busybox-wget loest localhost zuerst nach ::1 + # auf, Next lauscht aber nur auf 0.0.0.0:3000. Der Healthcheck schlug + # deshalb dauerhaft fehl, waehrend die App einwandfrei antwortete - + # gefunden bei der Abnahme zu #019. + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/gesundheit"] interval: 30s timeout: 5s retries: 5 diff --git a/docs/abnahme-mvp.md b/docs/abnahme-mvp.md index 546ed8a..a378fd7 100644 --- a/docs/abnahme-mvp.md +++ b/docs/abnahme-mvp.md @@ -5,91 +5,100 @@ Punkt ist entweder abgehakt oder hat ein Folge-Issue — nichts bleibt stillschw ## Betrieb -- [ ] `docker compose up` startet App und Datenbank, Migrationen laufen automatisch -- [ ] Seed läuft per dokumentiertem Befehl, nicht automatisch -- [ ] Die Datenbank ist vom Host aus **nicht** erreichbar (`nc -z localhost 5432` schlägt fehl), aus `web` schon +- [x] `docker compose up` startet App und Datenbank, Migrationen laufen automatisch +- [x] Seed läuft per dokumentiertem Befehl, nicht automatisch +- [x] Die Datenbank ist vom Host aus **nicht** erreichbar (`nc -z localhost 5432` schlägt fehl), aus `web` schon - [ ] Der Proxmox-One-Liner rollt einen LXC aus, in dem die App läuft und PostgreSQL nur am Unix-Socket hört -- [ ] Erneuter Aufruf des Skripts aktualisiert, ohne Daten oder Geheimnisse zu verlieren -- [ ] `shellcheck --severity=warning` über alle Skripte ohne Befund -- [ ] Läuft hinter einem Reverse Proxy ohne fest verdrahtete Adressen +- [x] Erneuter Aufruf des Skripts aktualisiert, ohne Daten oder Geheimnisse zu verlieren +- [x] `shellcheck --severity=warning` über alle Skripte ohne Befund +- [x] Läuft hinter einem Reverse Proxy ohne fest verdrahtete Adressen + +> **Zeile 4 bleibt offen, mit Ansage:** hier stand kein Proxmox-Host zur Verfügung. Belegt ist beides +> drumherum — `laufzettel.sh --dry-run` und `--dry-run --aktualisieren` laufen vollständig durch, und +> der Inhalt des Containers, `deploy/install.sh`, lief zweimal in `debian:13` und einmal unter echtem +> systemd durch, mit `ss -ltn` ohne einen einzigen lauschenden TCP-Port. Offen ist allein die +> `pct`-Schicht. Die Gegenprobe steht in [docs/betrieb/proxmox.md](betrieb/proxmox.md) („Was du beim +> ersten Lauf selbst nachprüfen musst“), das Folge-Issue ist +> [#024](issues/024-proxmox-echtlauf.md). Bedingung dafür: der Einzeiler lädt von `main` — er wird +> erst nach dem Merge prüfbar. ## Anmeldung und Rechte -- [ ] Login per lokalem Konto funktioniert -- [ ] OIDC ist konfigurierbar und für authentik dokumentiert, inkl. Gruppen für Rollen -- [ ] Bei `AUTH_LOCAL_ENABLED=false` ist der lokale Login weg und der Endpunkt lehnt ab -- [ ] Rollenrechte: jede Rolle gegen jede Aktion getestet, Prüfung ausschließlich serverseitig -- [ ] Eine Rollenänderung greift ohne neue Anmeldung; ein deaktivierter Benutzer wird abgewiesen -- [ ] Passwörter stehen nur als argon2-Hash in der Datenbank, nie in einer API-Antwort +- [x] Login per lokalem Konto funktioniert +- [x] OIDC ist konfigurierbar und für authentik dokumentiert, inkl. Gruppen für Rollen +- [x] Bei `AUTH_LOCAL_ENABLED=false` ist der lokale Login weg und der Endpunkt lehnt ab +- [x] Rollenrechte: jede Rolle gegen jede Aktion getestet, Prüfung ausschließlich serverseitig +- [x] Eine Rollenänderung greift ohne neue Anmeldung; ein deaktivierter Benutzer wird abgewiesen +- [x] Passwörter stehen nur als argon2-Hash in der Datenbank, nie in einer API-Antwort ## Betriebsmodus -- [ ] Hauptablauf läuft in `INTERN` ohne jede Kundenanzeige (Ausnahme: die Einstellungsseite und der Wechsel-Dialog zeigen die eigene Organisation — dort ist sie zu pflegen) -- [ ] Hauptablauf läuft in `DIENSTLEISTUNG` mit Kundenwahl -- [ ] Wechsel `INTERN → DIENSTLEISTUNG` übernimmt alle Daten auf den ersten Kunden -- [ ] Wechsel zurück wird bei zwei nicht archivierten Kunden abgelehnt -- [ ] Im Modus `INTERN` ignoriert die Service-Schicht eine mitgeschickte `kundeId` -- [ ] Der Modus ist ohne Neustart umschaltbar, jeder Wechsel steht im `Protokoll` +- [x] Hauptablauf läuft in `INTERN` ohne jede Kundenanzeige (Ausnahme: die Einstellungsseite und der Wechsel-Dialog zeigen die eigene Organisation — dort ist sie zu pflegen) +- [x] Hauptablauf läuft in `DIENSTLEISTUNG` mit Kundenwahl +- [x] Wechsel `INTERN → DIENSTLEISTUNG` übernimmt alle Daten auf den ersten Kunden +- [x] Wechsel zurück wird bei zwei nicht archivierten Kunden abgelehnt +- [x] Im Modus `INTERN` ignoriert die Service-Schicht eine mitgeschickte `kundeId` +- [x] Der Modus ist ohne Neustart umschaltbar, jeder Wechsel steht im `Protokoll` ## Vorlagen -- [ ] Veröffentlichte Version ist unveränderlich; „Bearbeiten“ erzeugt eine tiefe Kopie als Entwurf -- [ ] Höchstens ein Entwurf je Vorlage -- [ ] Veröffentlichen einer neuen Version verändert bestehende Läufe nicht -- [ ] Export und anschließender Import ergeben eine inhaltsgleiche Vorlage -- [ ] Unbekannte Platzhalter werden im Editor als Warnung angezeigt +- [x] Veröffentlichte Version ist unveränderlich; „Bearbeiten“ erzeugt eine tiefe Kopie als Entwurf +- [x] Höchstens ein Entwurf je Vorlage +- [x] Veröffentlichen einer neuen Version verändert bestehende Läufe nicht +- [x] Export und anschließender Import ergeben eine inhaltsgleiche Vorlage +- [x] Unbekannte Platzhalter werden im Editor als Warnung angezeigt ## Läufe und Archiv -- [ ] Lauf ohne Mitarbeiter und ohne Gerät lässt sich starten, abschließen und archivieren -- [ ] Abschließen scheitert, solange ein Pflichtschritt offen ist -- [ ] `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz -- [ ] „Abschnitt als nicht zutreffend markieren“ setzt alle Schritte mit einer Notiz -- [ ] Archiv-Schreibschutz greift für Kopfdaten, Schrittergebnisse und Kommentare -- [ ] Nur `ADMIN` holt einen archivierten Lauf zurück, mit `Protokoll`-Eintrag -- [ ] Im Archiv wird über Titel, Notizen, Werte und Kommentare gefunden -- [ ] Druckansicht eines Laufs ist sauber und hell +- [x] Lauf ohne Mitarbeiter und ohne Gerät lässt sich starten, abschließen und archivieren +- [x] Abschließen scheitert, solange ein Pflichtschritt offen ist +- [x] `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz +- [x] „Abschnitt als nicht zutreffend markieren“ setzt alle Schritte mit einer Notiz +- [x] Archiv-Schreibschutz greift für Kopfdaten, Schrittergebnisse und Kommentare +- [x] Nur `ADMIN` holt einen archivierten Lauf zurück, mit `Protokoll`-Eintrag +- [x] Im Archiv wird über Titel, Notizen, Werte und Kommentare gefunden +- [x] Druckansicht eines Laufs ist sauber und hell ## Mitarbeiter, Geräte, QR -- [ ] Mitarbeitersuche liefert standardmäßig nur aktive, Umschalter zeigt auch deaktivierte -- [ ] Ein von der Geräteseite gestarteter Lauf hat den Besitzer vorbelegt -- [ ] `/t/LZ-7K3F9Q` ohne Anmeldung führt nach dem Login auf die Geräteseite -- [ ] „Vorlage zuweisen“ erzeugt einen `OFFEN`-Lauf, der unter „Zugewiesen“ erscheint; „Starten“ setzt `IN_ARBEIT` -- [ ] Unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis -- [ ] `tagCode`: richtiges Alphabet, eindeutig, unveränderlich, nie wiederverwendet -- [ ] Etikettendruck in beiden Layouts sauber; QR maschinell zurücklesbar +- [x] Mitarbeitersuche liefert standardmäßig nur aktive, Umschalter zeigt auch deaktivierte +- [x] Ein von der Geräteseite gestarteter Lauf hat den Besitzer vorbelegt +- [x] `/t/LZ-7K3F9Q` ohne Anmeldung führt nach dem Login auf die Geräteseite +- [x] „Vorlage zuweisen“ erzeugt einen `OFFEN`-Lauf, der unter „Zugewiesen“ erscheint; „Starten“ setzt `IN_ARBEIT` +- [x] Unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis +- [x] `tagCode`: richtiges Alphabet, eindeutig, unveränderlich, nie wiederverwendet +- [x] Etikettendruck in beiden Layouts sauber; QR maschinell zurücklesbar ## Platzhalter und Befehle -- [ ] Onboarding: Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen -- [ ] Abteilung „Buchhaltung“ wählen → `-Path "OU=Buchhaltung,…"` und Gruppen `G-Buchhaltung, G-Alle` -- [ ] Kopieren liefert exakt diesen Text, zeichengenau -- [ ] „Jörg Müller-Lüdenscheid“ ergibt gültige Vorschläge (`joerg.mueller-luedenscheid@…`, `jmuellerluedensch`) -- [ ] `{{generiertes_passwort}}` erscheint im Befehl, steht aber nicht in Datenbank, Druckansicht oder `Protokoll` -- [ ] Werte mit Anführungszeichen werden für die jeweilige Shell korrekt maskiert -- [ ] „Alle Befehle als Skript kopieren“ liefert die Schritte in richtiger Reihenfolge -- [ ] Unbekannte Platzhalter bleiben stehen; ersetzte Werte sind HTML-escaped +- [x] Onboarding: Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen +- [x] Abteilung „Buchhaltung“ wählen → `-Path "OU=Buchhaltung,…"` und Gruppen `G-Buchhaltung, G-Alle` +- [x] Kopieren liefert exakt diesen Text, zeichengenau +- [x] „Jörg Müller-Lüdenscheid“ ergibt gültige Vorschläge (`joerg.mueller-luedenscheid@…`, `jmuellerluedensch`) +- [x] `{{generiertes_passwort}}` erscheint im Befehl, steht aber nicht in Datenbank, Druckansicht oder `Protokoll` +- [x] Werte mit Anführungszeichen werden für die jeweilige Shell korrekt maskiert +- [x] „Alle Befehle als Skript kopieren“ liefert die Schritte in richtiger Reihenfolge +- [x] Unbekannte Platzhalter bleiben stehen; ersetzte Werte sind HTML-escaped ## Verzeichnis (LDAP) -- [ ] Erster Abgleich legt Mitarbeiter an, zweiter aktualisiert sie ohne Duplikate -- [ ] Entfernte oder deaktivierte Konten werden `aktiv = false`, nie gelöscht -- [ ] `{{ou}}` wird korrekt aus dem `dn` abgeleitet -- [ ] Das Bind-Passwort erscheint in keiner API-Antwort und keinem Log -- [ ] Zwei gleichzeitige Abgleiche derselben Quelle: der zweite wird von der Sperre abgewiesen +- [x] Erster Abgleich legt Mitarbeiter an, zweiter aktualisiert sie ohne Duplikate +- [x] Entfernte oder deaktivierte Konten werden `aktiv = false`, nie gelöscht +- [x] `{{ou}}` wird korrekt aus dem `dn` abgeleitet +- [x] Das Bind-Passwort erscheint in keiner API-Antwort und keinem Log +- [x] Zwei gleichzeitige Abgleiche derselben Quelle: der zweite wird von der Sperre abgewiesen ## Oberfläche -- [ ] Am Handy kein horizontales Scrollen — bei 390 px auf allen Hauptseiten geprüft -- [ ] Gestaltung nach Vorgabe: dunkel, ein Akzent, keine Emojis, keine Schatten, keine Verläufe -- [ ] Version mit Git-SHA unten links +- [x] Am Handy kein horizontales Scrollen — bei 390 px auf allen Hauptseiten geprüft +- [x] Gestaltung nach Vorgabe: dunkel, ein Akzent, keine Emojis, keine Schatten, keine Verläufe +- [x] Version mit Git-SHA unten links ## Projektorganisation -- [ ] Issues für alle Phasen und MVP-Meilensteine existieren -- [ ] `CHANGELOG.md` und `docs/devlog.md` sind gepflegt +- [x] Issues für alle Phasen und MVP-Meilensteine existieren +- [x] `CHANGELOG.md` und `docs/devlog.md` sind gepflegt - [ ] `v0.1.0` ist getaggt, Gitea-Release mit Changelog-Auszug -- [ ] `docs/anleitung/` vollständig mit Screenshots und als PDF -- [ ] `docs/screenshots/` enthält die Meilenstein-Bilder -- [ ] `pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku` grün +- [x] `docs/anleitung/` vollständig mit Screenshots und als PDF +- [x] `docs/screenshots/` enthält die Meilenstein-Bilder +- [x] `pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku` grün diff --git a/docs/betrieb/README.md b/docs/betrieb/README.md new file mode 100644 index 0000000..eed633a --- /dev/null +++ b/docs/betrieb/README.md @@ -0,0 +1,67 @@ +# Laufzettel betreiben + +Diese Sammlung richtet sich an den, der Laufzettel betreibt, aber nicht gebaut hat. Sie führt von +einem leeren Server bis zur ersten Anmeldung, und danach durch Aktualisierung, Sicherung und +Fehlersuche. Wer die Anwendung *benutzen* will, liest stattdessen die +[Anleitung](../anleitung/README.md). + +Es gibt zwei Wege in den Betrieb. Beide sind gleichwertig und führen zum selben Stand. + +| Weg | Für wen | Was du mitbringen musst | +|---|---|---| +| **[Docker Compose](inbetriebnahme.md)** | Es gibt schon einen Docker-Host, und weitere Dienste laufen dort ebenso. Aktualisieren heißt neu bauen. | Docker mit Compose-Plugin | +| **[Nativ im Proxmox-LXC](proxmox.md)** | Es gibt einen Proxmox-Host und keinen Docker. Ein Skript legt den Container an und installiert darin alles — PostgreSQL, Node, die Anwendung, einen systemd-Dienst. | Proxmox VE 8 oder 9, oder ein leeres Debian 13 | + +Nimm den Compose-Weg, wenn du schwankst: er ist der Weg, gegen den hier entwickelt und getestet +wird. Der `pct`-Teil des Proxmox-Skripts ist ungetestet — das steht so, und warum, in +[`proxmox.md`](proxmox.md). + +## Was auf beiden Wegen gilt + +Vier Dinge sind keine Empfehlung, sondern Teil des Aufbaus. Sie zu übergehen heißt, einen anderen +Stand zu betreiben als den beschriebenen. + +**Die Datenbank ist von außen nicht erreichbar.** Im Compose-Stand hat `postgres` kein +`ports:`-Mapping und hängt nur im Netz `intern`, das `internal: true` ist — ein Netz ohne Gateway. +Nativ läuft PostgreSQL mit `listen_addresses = ''`, also nur am Unix-Socket. Dieselbe Regel, zwei +Mittel. Daraus folgt, dass jede Sicherung und jeder Blick in die Daten über den Dienstnamen +beziehungsweise den Socket läuft, nie über `psql -h localhost`. `tests/betriebsstand.test.ts` hält +das für den Compose-Stand fest, damit ein `ports:`-Mapping nicht versehentlich zurückkommt. + +**Die Anwendung bindet auf `127.0.0.1`** und spricht kein HTTPS. Davor gehört ein Reverse Proxy, der +das Zertifikat hält — siehe [`reverse-proxy.md`](reverse-proxy.md). Ohne Proxy erreicht die +Anwendung niemand außer dem Host selbst, und das ist die richtige Voreinstellung: ein Dienst, der +aus Versehen im LAN steht, fällt keinem auf. + +**`APP_URL` ist die öffentliche Adresse und wird nicht geraten.** Aus ihr entstehen die QR-Adressen +auf den gedruckten Aufklebern und die OIDC-Rücksprungadresse. Sie später zu ändern ist möglich, aber +teuer — der Grund steht am Anfang von [`reverse-proxy.md`](reverse-proxy.md). Leg die Adresse fest, +bevor der erste Aufkleber gedruckt wird. + +**Geheimnisse entstehen im Zielsystem.** `APP_SECRET`, das Datenbank-Passwort und das erste +Admin-Passwort werden dort erzeugt, wo sie gelten, und stehen in keinem Repository und in keiner +Vorlage. `APP_SECRET` gehört mit in die Sicherung: ohne ihn sind nach dem Zurückspielen alle +Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter unlesbar. Siehe +[`sicherung.md`](sicherung.md). + +## Die Kapitel + +- **[Inbetriebnahme mit Docker Compose](inbetriebnahme.md)** — von null bis zur ersten Anmeldung, + mit der vollständigen Tabelle der Umgebungsvariablen, dem Seed und der Aktualisierung +- **[Der native Weg im Proxmox-LXC](proxmox.md)** — `deploy/proxmox/laufzettel.sh` und + `deploy/install.sh`, was sie anlegen, wie ein zweiter Aufruf aktualisiert, und die Prüfliste für + den ersten echten Lauf +- **[Reverse Proxy](reverse-proxy.md)** — Caddy und nginx als vollständiges Beispiel, TLS, + Weiterleitungen, welche Kopfzeilen nötig sind und welche schaden +- **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang +- **[Sichern und Zurückspielen](sicherung.md)** — auf beiden Wegen, was in die Sicherung gehört und + was nicht, und wie du prüfst, dass das Zurückspielen wirklich funktioniert + +## Wo sonst noch etwas steht + +- [`../../README.md`](../../README.md) — Kurzfassung für Entwickler, Betriebsmodi, Seed-Inhalt +- [`../../.env.example`](../../.env.example) — jede Umgebungsvariable mit Kommentar, zum Kopieren +- [`../architektur.md`](../architektur.md) — warum es so gebaut ist; Abschnitt 6 fasst die + Betriebsregel zusammen +- [`../anleitung/08-fehlerbehebung.md`](../anleitung/08-fehlerbehebung.md) — Fehlermeldungen der + Oberfläche. Wer eine Meldung im Browser sieht, ist dort besser bedient als hier diff --git a/docs/betrieb/inbetriebnahme.md b/docs/betrieb/inbetriebnahme.md new file mode 100644 index 0000000..65b1461 --- /dev/null +++ b/docs/betrieb/inbetriebnahme.md @@ -0,0 +1,260 @@ +# Inbetriebnahme mit Docker Compose + +Von einem Host mit Docker bis zur ersten Anmeldung. Rechne mit zwanzig Minuten, davon zehn für den +ersten Bau des Abbilds. + +Der andere Weg — nativ in einem Proxmox-LXC, ohne Docker — steht in [`proxmox.md`](proxmox.md). + +## 1. Voraussetzungen + +- Docker mit dem Compose-Plugin (`docker compose version` muss antworten, nicht `docker-compose`) +- 2 GB RAM und 8 GB Platte reichen; der erste Bau braucht kurzzeitig mehr +- `git`, `openssl` +- ein Reverse Proxy für TLS — auf demselben Host oder davor, siehe + [`reverse-proxy.md`](reverse-proxy.md) +- ein DNS-Name, der auf den Proxy zeigt, **bevor** du anfängst: er wird zu `APP_URL` + +Node und pnpm brauchst du **nicht** auf dem Host. Beides steckt im Abbild. + +## 2. Quellcode holen + +```bash +sudo mkdir -p /opt/laufzettel +sudo chown "$USER" /opt/laufzettel +git clone https://git.wilde-server.de/florian.wilde/Laufzettel.git /opt/laufzettel +cd /opt/laufzettel +``` + +Das Verzeichnis ist frei wählbar; alle folgenden Befehle laufen darin, weil `docker compose` die +`docker-compose.yml` und die `.env` im aktuellen Verzeichnis sucht. + +## 3. Die `.env` anlegen + +```bash +cp .env.example .env +openssl rand -base64 48 # das wird APP_SECRET +openssl rand -base64 24 # das wird POSTGRES_PASSWORD +``` + +Dann `.env` öffnen und mindestens diese fünf Werte setzen: + +```dotenv +APP_URL=https://laufzettel.firma.de +APP_SECRET= +POSTGRES_PASSWORD= +SEED_ADMIN_EMAIL=admin@firma.de +SEED_ADMIN_PASSWORD= +``` + +Und für die erste Anmeldung, bis OIDC steht: + +```dotenv +AUTH_LOCAL_ENABLED=true +``` + +> **`APP_URL` ist die öffentliche Adresse, nicht die interne.** Hinter einem Reverse Proxy ist es +> dessen Adresse mit `https://`, nicht `http://127.0.0.1:3000`. Aus ihr entstehen die QR-Adressen auf +> den gedruckten Aufklebern; sie später zu ändern kostet einen Stapel neuer Aufkleber. Der Grund +> steht ausführlich in [`reverse-proxy.md`](reverse-proxy.md). + +> **Fehlen `APP_URL`, `APP_SECRET` oder `POSTGRES_PASSWORD`, startet der Stand gar nicht.** Das ist +> Absicht: `docker compose` bricht mit `APP_SECRET fehlt in der .env` ab, statt mit einem +> Standardwert zu laufen, den jeder kennt. + +**`DATABASE_URL` lässt du für den Compose-Stand in Ruhe.** Die Adresse zur Datenbank baut sich im +Container immer aus `POSTGRES_USER`, `POSTGRES_PASSWORD` und `POSTGRES_DB` zusammen, mit dem +Dienstnamen `postgres` als Host. Der Wert in der `.env` gilt nur für Werkzeuge, die *außerhalb* der +Container laufen — `pnpm dev`, `pnpm prisma …` von einer Entwicklungsmaschine aus. Bis zur Abnahme +von #019 las der Compose-Stand ihn mit, und wer dem dokumentierten Weg folgte, bekam einen Stand, der +nicht startet: in der `.env.example` steht dort `localhost`, und im Container zeigt das ins Leere. + +### Alle Umgebungsvariablen + +Jede steht mit Kommentar in [`../../.env.example`](../../.env.example). **Pflicht** sind `APP_URL`, +`APP_SECRET` und `POSTGRES_PASSWORD`. + +| Variable | Bedeutung | +|---|---| +| `APP_URL` | Die **öffentliche** Adresse der Anwendung, mit Schema und ohne Schrägstrich am Ende. Steckt in den QR-Codes der Aufkleber und in der OIDC-Rücksprungadresse. Entscheidet außerdem, ob das Sitzungs-Cookie das `Secure`-Flag bekommt (bei `https://` ja). | +| `APP_SECRET` | Schlüssel für die Sitzungs-Cookies und für die Verschlüsselung des LDAP-Bind-Passworts. Mindestens 32 Zeichen, erzeugen mit `openssl rand -base64 48`. **Ändert man ihn, sind alle Sitzungen ungültig und gespeicherte Bind-Passwörter unlesbar.** Gehört in die Sicherung. | +| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | Zugangsdaten der Datenbank im Container. Nach dem ersten Start liegen sie im Volume; sie nachträglich in der `.env` zu ändern ändert die Datenbank **nicht** und macht den Stand nur unerreichbar. | +| `DATABASE_URL` | Nur für Werkzeuge außerhalb der Container. Der Compose-Stand baut seine Adresse selbst; siehe oben. | +| `BIND_ADDR`, `APP_PORT` | Wohin der Webdienst auf dem Host gebunden wird. Standard `127.0.0.1:3000` — davor gehört ein Reverse Proxy. Nur wenn der Proxy auf einem **anderen** Rechner läuft, gehört hier die LAN-Adresse des Hosts hin, nie `0.0.0.0` ohne Firewall. | +| `AUTH_LOCAL_ENABLED` | `true` schaltet die Anmeldung mit E-Mail und Passwort frei. Für die erste Anmeldung und als Notfallzugang, wenn der Identitätsanbieter ausfällt. Auf `false` verschwindet das Formular **und** der Endpunkt lehnt ab. | +| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | Der Identitätsanbieter. Leer heißt: kein OIDC. Discovery über `/.well-known/openid-configuration`. | +| `OIDC_NAME` | Beschriftung des Anmeldeknopfs, etwa „Firmenanmeldung“. | +| `OIDC_SCOPE` | Angefragte Bereiche. Der Standard `openid profile email groups` passt zu [`authentik.md`](authentik.md); nur ändern, wenn der Anbieter die Gruppen unter einem anderen Bereich liefert. | +| `OIDC_GROUP_ADMIN`, `OIDC_GROUP_EDITOR`, `OIDC_GROUP_TECHNICIAN`, `OIDC_GROUP_VIEWER` | Welche Gruppe welche Rolle bekommt. Die Rolle wird bei **jeder** Anmeldung neu gesetzt; eine hier in der Oberfläche geänderte Rolle überschreibt der nächste Login. | +| `OIDC_CUSTOMER_GROUP_PREFIX` | Nur im Modus `DIENSTLEISTUNG`: Gruppen mit diesem Präfix (etwa `lz-kunde-MUS`) binden einen Benutzer an einen Kunden. Leer heißt: keine Einschränkung. | +| `ZEITPLAN_AKTIV` | `false` schaltet den Verzeichnis-Abgleich nach Zeitplan ab. Der Zeitplan läuft im Prozess der Anwendung und sieht jede Minute nach, welche Quelle fällig ist. | +| `APP_VERSION`, `GIT_SHA` | Erscheinen unten links in der Oberfläche. Im Compose-Stand sind es Build-Argumente — sie wirken erst beim nächsten `--build`. | +| `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD` | Der Administrator, den der Seed anlegt. Das Passwort wird nur als argon2-Hash gespeichert. Fehlt eines, überspringt der Seed ihn — und dann kommst du nicht hinein. | +| `SEED_MODE` | `INTERN` (Standard) oder `DIENSTLEISTUNG`, `SERVICE` als Alias. Betrifft nur den **ersten** Seed-Lauf; danach steht der Modus in der Datenbank und wird in der Oberfläche umgeschaltet. | +| `SEED_ORG_NAME`, `SEED_ORG_KUERZEL` | Name und Kürzel der eigenen Organisation. Das Kürzel (2 bis 6 Großbuchstaben oder Ziffern) steht auf Etiketten und in der Hostname-Regel. | +| `TEST_DATABASE_URL` | Nur für die Tests. Im Betrieb ohne Bedeutung. | + +## 4. Starten + +```bash +docker compose up -d --build +``` + +Was dabei passiert, in dieser Reihenfolge: + +1. `postgres` startet und wird über `pg_isready` als gesund gemeldet +2. `migrate` läuft **einmal** und spielt `prisma migrate deploy` ein, dann beendet es sich +3. `web` startet, sobald `migrate` sauber durch ist + +Ein beendeter `migrate`-Container ist also kein Fehler, sondern das erwartete Ergebnis. Nachsehen: + +```bash +docker compose ps +docker compose logs -f web +``` + +## 5. Den Seed einmal von Hand fahren + +**Der Seed läuft absichtlich nicht automatisch.** Ein Einmal-Dienst, der bei jedem Start Daten +anlegt, ist ein Dienst, der irgendwann Daten anlegt, die niemand wollte. Du startest ihn selbst, +genau einmal: + +```bash +docker compose run --rm migrate pnpm db:seed +``` + +Der Seed legt an: + +| | Inhalt | +|---|---| +| Grundbestand | Betriebsmodus, eigene Organisation, Administrator aus `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORD` | +| Vorlagen | die sieben Abläufe als **veröffentlichte Version 1** — PC-Neuinstallation, Onboarding, Offboarding, Server-Inbetriebnahme, Firewall-Rollout, Patchday, Restore-Test | +| Beispielbestand | 3 Abteilungen, 5 Personen, `PC-001` und `NB-001` mit Tag-Code, 5 vorgedruckte Tags, Schnellstart-Vorgaben je Gerätetyp | +| Verzeichnis | eine **deaktivierte** Beispielquelle zum Abschauen — Adresse und Bind-Passwort ersetzen, dann einschalten | + +**Er richtet ein, er räumt nicht auf.** Ein zweiter Lauf ändert nichts: eine bearbeitete Vorlage, +eine geänderte Ziel-OU oder ein umgeschalteter Betriebsmodus überstehen ihn. Du kannst ihn also +gefahrlos wiederholen, wenn du nicht sicher bist, ob er durchgelaufen ist. + +Mit `SEED_MODE=DIENSTLEISTUNG` kommen zwei Demo-Kunden dazu. Willst du ohne Beispieldaten starten, +setz `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORD` und lösch die Beispiele hinterher in der +Oberfläche — die Vorlagen lässt du besser stehen, sie sind der eigentliche Wert. + +## 6. Erste Anmeldung + +Erst prüfen, ob die Anwendung überhaupt antwortet — vom Host, denn nach außen bindet sie nicht: + +```bash +curl -s http://127.0.0.1:3000/api/gesundheit +# {"status":"ok"} +``` + +Antwortet das mit `{"status":"fehler",…}` und Status 503, erreicht die Anwendung die Datenbank nicht. +Kommt gar nichts, läuft `web` nicht — `docker compose logs web`. + +Dann den Reverse Proxy einrichten ([`reverse-proxy.md`](reverse-proxy.md)), `https://laufzettel.firma.de` +öffnen und mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORD` anmelden. + +Danach der Reihe nach: + +1. **Admin → Einstellungen** — Betriebsmodus prüfen, Organisationsname und Kürzel +2. **[authentik anbinden](authentik.md)** und `AUTH_LOCAL_ENABLED` wieder auf `false` setzen — bis + auf ein einziges lokales Admin-Konto mit Passwort im Passwortmanager, für den Fall, dass der + Anbieter ausfällt +3. **Sicherung einrichten**, bevor echte Daten drin sind: [`sicherung.md`](sicherung.md) + +Wie es weitergeht, steht in der [Anleitung](../anleitung/README.md). + +## Warum die Datenbank zu ist, und was daraus folgt + +Der Aufbau ist bewusst so und in `tests/betriebsstand.test.ts` festgehalten: + +- `postgres` hat **kein** `ports:`-Mapping, nur `expose` — auf dem Host gibt es keinen Port 5432 +- `postgres` hängt ausschließlich im Netz `intern`, das `internal: true` ist: ein Netz ohne Gateway. + Weder Host noch LAN kommen hinein, und die Datenbank kommt auch nicht heraus +- nur `web` hängt zusätzlich im Netz `extern`, weil es den Reverse Proxy, den Identitätsanbieter und + das LDAP-Verzeichnis erreichen muss +- `web` bindet auf `127.0.0.1:3000` + +Ein Datenbankport, den es nicht gibt, muss nicht mit einem Passwort verteidigt werden. Das ist die +ganze Begründung. + +Daraus folgt dreierlei, und das ist der Teil, der im Alltag zählt: + +**Kein `psql` vom Host.** `psql -h localhost -p 5432` findet nichts. Wer in die Daten sehen will, +geht durch den Container: + +```bash +docker compose exec postgres psql -U laufzettel laufzettel +``` + +**Die Sicherung läuft über den Dienstnamen**, nicht über einen Port. Der vollständige Weg steht in +[`sicherung.md`](sicherung.md): + +```bash +docker compose exec -T postgres pg_dump -U laufzettel laufzettel | gzip > sicherung-$(date +%F).sql.gz +``` + +**Externe Werkzeuge kommen nicht daran** — kein pgAdmin vom Arbeitsplatz, kein +Monitoring-Agent per TCP. Wer das braucht, öffnet den Port nicht, sondern legt einen SSH-Tunnel auf +den Host und von dort `docker compose exec`. Ein `ports:`-Mapping in die `docker-compose.yml` zu +schreiben lässt den Test rot werden; das ist sein Zweck. + +Prüfen lässt sich beides in zwei Zeilen: + +```bash +nc -z localhost 5432 && echo "FEHLER: offen" || echo "dicht" +docker compose exec web nc -z postgres 5432 && echo "von innen erreichbar" +``` + +**`docker-compose.dev.yml` ist nicht für den Betrieb.** Diese Datei öffnet PostgreSQL auf +`127.0.0.1:5434` und benutzt das Passwort `laufzettel`. Sie ist für die Entwicklung da und hat auf +einem Server nichts zu suchen. + +## Aktualisieren + +```bash +cd /opt/laufzettel +git pull +docker compose up -d --build +``` + +Die Migrationen laufen dabei von selbst: `migrate` startet vor `web` und läuft durch, bevor die +Anwendung hochkommt. Der Seed läuft **nicht** mit — richtig so, er würde nichts ändern, aber er +gehört nicht in eine Aktualisierung. + +Vor dem Aktualisieren eine Sicherung ziehen. Eine Migration ist nicht rückwärts fahrbar. + +Zwischen zwei Versionen lohnt ein Blick in [`../../CHANGELOG.md`](../../CHANGELOG.md). + +## Anhalten, neu starten, wegräumen + +```bash +docker compose restart web # nur die Anwendung neu starten, etwa nach .env-Änderungen +docker compose down # alles anhalten, Daten bleiben im Volume +docker compose down -v # löscht das Volume und damit die Datenbank +``` + +> **`down -v` löscht die Datenbank.** Das Volume heißt `laufzettel_datenbank`, und mit ihm gehen alle +> Läufe, alle Vorlagen und alle Tag-Codes. Ein vergebener Tag-Code wird nie wiederverwendet — nach +> einem `down -v` ohne Sicherung sind alle gedruckten Aufkleber wertlos, weil ihre Codes in der +> neuen Datenbank nicht existieren. + +Eine Änderung an der `.env` greift erst nach `docker compose up -d`; `restart` allein liest die +Datei nicht neu. Änderungen an `APP_VERSION` oder `GIT_SHA` sind Build-Argumente und brauchen +`--build`. + +## Wenn es klemmt + +| Symptom | Ursache und was zu tun ist | +|---|---| +| `APP_SECRET fehlt in der .env` beim `up` | Genau das. Der Stand bricht absichtlich ab, statt mit einem Standardwert zu starten. Gilt ebenso für `APP_URL` und `POSTGRES_PASSWORD`. | +| `web` startet nicht, `migrate` steht auf `exited (1)` | `docker compose logs migrate`. Meist erreicht die Migration die Datenbank nicht — oder du hast `POSTGRES_PASSWORD` nach dem ersten Start geändert. Das Passwort steckt dann noch im Volume. | +| `/api/gesundheit` antwortet mit 503 | Die Anwendung läuft, die Datenbank antwortet nicht. `docker compose ps` — ist `postgres` gesund? | +| Der Seed bricht mittendrin ab | Bis zur Abnahme von #019 fehlte dem Einmal-Dienst `APP_SECRET`, und das Bind-Passwort der Beispiel-Verzeichnisquelle ließ sich nicht verschlüsseln — abgebrochen wurde erst, nachdem die Vorlagen schon standen. Steht dein `docker-compose.yml` auf dem aktuellen Stand, ist das behoben; ein erneuter Lauf holt den Rest nach. | +| Nach dem Login wieder auf der Anmeldeseite | Das Cookie ist `Secure`, die Verbindung aber `http`. `APP_URL` auf `https://…` setzen und HTTPS über den Proxy anbieten. | +| Die QR-Codes zeigen auf `localhost:3000` | `APP_URL` ist nicht oder falsch gesetzt. Die Oberfläche warnt unter **Admin** und in der Etikettenansicht davor. Aufkleber, die so gedruckt wurden, sind wertlos — bevor du weiterdruckst, `APP_URL` richtigstellen und `docker compose up -d`. | +| Jeder Benutzer bekommt `LESER` | Der Claim `groups` fehlt im Token. Siehe [`authentik.md`](authentik.md), Abschnitt 8. | +| Keine Knöpfe tun etwas, die Seite sieht aber richtig aus | Du siehst einen Entwicklungsstand, der nicht hydriert. Im Compose-Stand kann das nicht passieren — dort läuft immer der gebaute Stand. Prüf, ob du tatsächlich den Compose-Stand vor dir hast und nicht ein `pnpm dev` auf demselben Host. | + +Fehlermeldungen, die in der Oberfläche erscheinen, stehen in +[`../anleitung/08-fehlerbehebung.md`](../anleitung/08-fehlerbehebung.md). diff --git a/docs/betrieb/proxmox.md b/docs/betrieb/proxmox.md new file mode 100644 index 0000000..8fd91a6 --- /dev/null +++ b/docs/betrieb/proxmox.md @@ -0,0 +1,286 @@ +# Der native Weg: Proxmox-LXC und Debian 13 + +Ohne Docker. Ein Skript legt auf dem Proxmox-Host einen unprivilegierten Debian-13-Container an und +installiert darin alles: PostgreSQL 17 nur am Unix-Socket, Node 24, die Anwendung als +systemd-Dienst. Wer schon ein leeres Debian 13 hat — als LXC, als virtuelle Maschine oder auf Blech — +überspringt den ersten Teil und ruft `deploy/install.sh` direkt auf. + +Der andere Weg, mit Docker Compose, steht in [`inbetriebnahme.md`](inbetriebnahme.md). + +> **Der Proxmox-Teil ist nicht automatisch geprüft.** Für diese Arbeit stand kein PVE-Host zur +> Verfügung; alles, was `pct` anfasst, ist geschrieben und gelesen, aber nie gelaufen. +> `deploy/install.sh` dagegen ist geprüft — es installiert in einem `debian:13`-Abbild vollständig +> durch und bringt mit `--test` seine eigene Selbstprüfung mit. Was du beim ersten echten Lauf +> nachsehen musst, steht am Ende dieser Seite in einer Prüfliste. Das ist keine Floskel: geh sie +> durch. + +## Was am Ende auf dem System steht + +| Ort | Was | +|---|---| +| `/opt/laufzettel` | Der Quellcode als Git-Arbeitsverzeichnis, Eigentümer `laufzettel`. Aktualisiert wird mit `git pull`, deshalb ein Checkout und kein Tarball. | +| `/opt/laufzettel/.next/standalone` | Der gebaute Server. Von hier startet `node server.js`. | +| `/etc/laufzettel/laufzettel.env` | **Die Geheimnisse dieser Installation**: `APP_SECRET`, `DATABASE_URL` mit Passwort, `APP_URL`. Modus 0600, Eigentümer root. Das Skript überschreibt diese Datei nie — auch nicht beim Aktualisieren. | +| `/etc/systemd/system/laufzettel.service` | Der Dienst. Läuft als `laufzettel`, mit `NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` und `Restart=on-failure`. | +| `/var/cache/laufzettel` | Heimatverzeichnis des Dienstnutzers — nur der Zwischenspeicher von pnpm und corepack, kein Arbeitskonto. | +| `/etc/postgresql/17/main/conf.d/laufzettel.conf` | Eine Zeile: `listen_addresses = ''`. | +| Datenbank `laufzettel` | Rolle `laufzettel` mit erzeugtem Passwort, erreichbar nur über den Socket `/run/postgresql`. | + +Der Dienstnutzer `laufzettel` ist ein Systemkonto mit `/usr/sbin/nologin` als Shell und ohne +Heimatverzeichnis unter `/home`. Anmelden kann sich damit niemand. + +## Weg A: der One-Liner auf dem Proxmox-Host + +In der Shell des PVE-Hosts, als root: + +```bash +bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/Laufzettel/raw/branch/main/deploy/proxmox/laufzettel.sh)" +``` + +Das Skript prüft zuerst, ob es auf einem PVE 8 oder 9 als root läuft, holt bei Bedarf die +Debian-13-Vorlage über `pveam`, und fragt dann per Dialog: **Standard** oder **Erweitert**. + +**Standard** nimmt diese Werte, ohne zu fragen: + +| | | +|---|---| +| CTID | die nächste freie | +| Hostname | `laufzettel` | +| Vorlage | Debian 13 | +| Kerne | 2 | +| RAM | 2 GB | +| Platte | 8 GB | +| Swap | 512 MB | +| Privilegien | unprivilegiert, mit `nesting=1` — die systemd-Unit braucht es | +| Netz | DHCP auf `vmbr0` | + +Das ist auf einen kleinen Betrieb zugeschnitten und reicht für einige Tausend Läufe. Der erste Bau +braucht kurzzeitig mehr RAM als der Betrieb — wenn `pnpm build` mit `Killed` abbricht, ist das die +Ursache; dann im Erweitert-Dialog 4 GB geben und den Wert danach zurückdrehen. + +**Erweitert** fragt dieselben Werte einzeln ab, dazu eine feste IP statt DHCP, die Bridge und die +beiden Speicher getrennt: einen für das Wurzeldateisystem des Containers (Inhalt `rootdir`) und +einen für die Debian-Vorlage (Inhalt `vztmpl`). Vorbelegt ist jeweils der erste aktive Speicher, der +den Inhalt aufnehmen kann; findet das Skript keinen, bricht es mit dieser Begründung ab. + +Ein **zweiter Aufruf** erkennt einen vorhandenen Container am Hostnamen und bietet an, ihn zu +aktualisieren, statt einen zweiten anzulegen. + +Das Skript installiert selbst nichts. Es legt den Container an, holt `deploy/install.sh` hinein und +ruft es dort auf — damit der Weg mit und ohne Proxmox nicht auseinanderläuft. + +| Schalter | Bedeutung | +|---|---| +| `--dry-run` (auch `--trockenlauf`) | Gibt jeden `pct`-, `pveam`- und `pvesh`-Aufruf nur aus. Legt nichts an, lädt nichts, und überspringt auch die Prüfung auf root und PVE — läuft also auf jedem Rechner. | +| `--standard` | Ohne Dialog, alle Standardwerte nehmen. | +| `--erweitert` | Direkt in den Erweitert-Dialog. | +| `--aktualisieren` | Nur aktualisieren, ohne Rückfrage. Bricht ab, wenn es keinen Container mit dem Hostnamen `laufzettel` gibt — es wird also keiner angelegt. Gedacht für den zweiten und jeden weiteren Aufruf, etwa aus einem Wartungsfenster heraus. | +| `--zweig ` | Git-Zweig, aus dem `install.sh` geholt wird. Standard `main`. | +| `--hilfe` | Alle Schalter. | + +### Erst einmal nur hinsehen + +```bash +bash laufzettel.sh --dry-run +``` + +Nimm das beim ersten Mal: der ausgegebene `pct create` lässt sich lesen, prüfen und notfalls von Hand +abschicken. Der Trockenlauf braucht keinen PVE-Host und läuft bis zum Abschlussbildschirm durch. + +## Weg B: `install.sh` in einem vorhandenen Debian 13 + +Ob das Debian in einem LXC steckt, in einer VM oder auf Blech, ist dem Skript gleich. Es braucht +root, `apt` und Internet. + +```bash +apt install -y git +git clone https://git.wilde-server.de/florian.wilde/Laufzettel.git /tmp/laufzettel-quelle +sudo bash /tmp/laufzettel-quelle/deploy/install.sh --url https://laufzettel.firma.de --admin-mail admin@firma.de +``` + +Die Schalter: + +| Schalter | Bedeutung | +|---|---| +| `--url ` | Die **öffentliche** Adresse, also `APP_URL`. Ohne Angabe nimmt das Skript `http://:` — das läuft, aber die QR-Codes auf den Aufklebern tragen dann diese Adresse. Setz sie richtig, bevor du druckst. | +| `--admin-mail ` | Kennung des ersten Administrators. Standard `admin@firma.example`. | +| `--port ` | Port des Webdienstes. Standard 3000. | +| `--bind ` | Wohin gebunden wird. Standard `127.0.0.1` — davor gehört ein Reverse Proxy. Läuft der Proxy auf einem anderen Rechner, gehört hier die IP des Containers hin, dazu eine Firewall-Regel. | +| `--modus ` | `INTERN` (Standard) oder `DIENSTLEISTUNG`. | +| `--zweig `, `--quelle ` | Anderer Zweig oder andere Git-Quelle. Für Testläufe nimmt `--quelle` auch einen lokalen Pfad, wenn dort ein Git-Arbeitsverzeichnis liegt. | +| `--passwort-datei ` | Legt das beim ersten Lauf erzeugte Administrator-Passwort **zusätzlich** in diese Datei (0600, `root`), in den Zeilen `SEED_ADMIN_EMAIL=` und `SEED_ADMIN_PASSWORD=`. Gedacht für Aufrufer, die die Ausgabe nicht mitlesen können — genau so holt sich der Proxmox-Weg das Passwort für seinen Abschlussbildschirm. **Wer den Schalter benutzt, löscht die Datei nach dem Ablesen**, sonst liegt das Passwort dauerhaft im Klartext im System. | +| `--test` | Nach der Installation eine Selbstprüfung fahren, siehe unten. | +| `--hilfe` | Alle Schalter. | + +Dieselben Werte nimmt das Skript auch aus der **Umgebung** — für Aufrufer ohne Befehlszeile, also +`pct exec` im Proxmox-Weg. Ein Schalter schlägt die Umgebung. Die Namen sind dieselben wie in +`.env.example`, damit es nur eine Schreibweise gibt: `APP_URL`, `BIND_ADDR`, `APP_PORT`, +`SEED_ADMIN_EMAIL`, `SEED_MODE`, dazu `LAUFZETTEL_REPO` (wie `--quelle`), `LAUFZETTEL_ZWEIG` (wie +`--zweig`) und `LAUFZETTEL_PASSWORT_DATEI` (wie `--passwort-datei`). + +> **`--url`, `--port`, `--bind`, `--admin-mail` und `--modus` wirken nur beim ersten Lauf.** Danach +> steht die Wahrheit in `/etc/laufzettel/laufzettel.env`, und diese Datei wird nie überschrieben. Ein +> zweiter Aufruf mit anderen Werten warnt und ignoriert sie. Zum Ändern: die Datei bearbeiten und +> `systemctl restart laufzettel`. + +Das Skript läuft in neun Schritten und sagt bei jedem, was es tut. Rechne mit fünf bis fünfzehn +Minuten, das meiste davon `pnpm install` und `pnpm build`. + +## Der Abschlussbildschirm + +Auf Weg A siehst du **zwei** Abschlüsse: erst den von `install.sh` aus dem Container, dann den des +Proxmox-Skripts, das noch einmal Adresse, Kennung und Passwort zeigt und sagt, wie du in den +Container kommst (`pct enter `). + +**Das erzeugte Admin-Passwort steht in `laufzettel.env` absichtlich nicht** — dort liegen nur +`APP_SECRET` und das Datenbankpasswort. Damit der Abschlussbildschirm es trotzdem zeigen kann, +obwohl `install.sh` hinter `pct exec` läuft, gibt das Skript ihm `LAUFZETTEL_PASSWORT_DATEI` mit: +`install.sh` legt das Passwort einmalig in `/root/laufzettel-erstpasswort` (0600, `root`), der +Aufrufer liest es von dort und **löscht die Datei sofort wieder**, gelesen oder nicht. Beim zweiten +Aufruf — beim Aktualisieren — entsteht kein neues Passwort, und der Abschluss zeigt an dieser Stelle +„unverändert (das Konto bestand schon)“. Das ist der Normalfall, kein Fehler. + +Die Kennung dagegen wird aus `laufzettel.env` gelesen; steht dort nichts, zeigt der Abschluss den +Verweis auf die Datei statt einer Adresse. + +Der Abschluss von `install.sh` sieht so aus: + +``` +Laufzettel ist eingerichtet. + + Adresse https://laufzettel.firma.de + Dienst horcht 127.0.0.1:3000 + Administrator admin@firma.de + Passwort xY7k2mQ4pL9wR3tN8vZa + + Dieses Passwort wird genau hier einmal angezeigt. In der Datenbank steht + nur der argon2-Hash, in /etc/laufzettel/laufzettel.env steht es nicht. +``` + +> **Schreib das Passwort auf, bevor du das Fenster schließt.** Ohne `--passwort-datei` steht es in +> keiner Datei — weder in `laufzettel.env` noch im Journal — und es wird nie ein zweites Mal +> angezeigt. Ist es weg, hilft nur ein neues über `psql` beziehungsweise, wenn OIDC schon läuft, der +> Weg über den Identitätsanbieter. + +Bestand das Admin-Konto schon — also beim zweiten Lauf — erzeugt das Skript kein neues Passwort und +schreibt an dieser Stelle `Passwort unveraendert (Konto bestand schon)`. Das ist der +Normalfall beim Aktualisieren. + +Der lokale Login ist auf diesem Weg von Anfang an eingeschaltet +(`AUTH_LOCAL_ENABLED="true"` in der Umgebungsdatei), denn sonst käme niemand hinein. Sobald OIDC +steht, setz ihn auf `false` — bis auf das eine Notfallkonto, siehe +[`authentik.md`](authentik.md), Abschnitt 7. + +## Was danach zu tun ist + +1. **Reverse Proxy davorstellen.** Der Dienst bindet auf `127.0.0.1` und spricht kein HTTPS; ohne + Proxy erreicht ihn nur der Container selbst. Siehe [`reverse-proxy.md`](reverse-proxy.md). +2. **Anmelden** und unter **Admin → Einstellungen** Organisationsname und Kürzel setzen. Das Skript + schreibt `SEED_ORG_NAME="Eigene Organisation"` und `SEED_ORG_KUERZEL="EIGEN"` — das Kürzel steht + später auf Etiketten, es lohnt sich, es früh richtigzustellen. +3. **[authentik anbinden](authentik.md)**: die `OIDC_*`-Zeilen in + `/etc/laufzettel/laufzettel.env` sind auskommentiert vorbereitet. Ausfüllen, + `systemctl restart laufzettel`. +4. **Sicherung einrichten**, bevor echte Daten drin sind: [`sicherung.md`](sicherung.md). + +## Den Dienst bedienen + +```bash +systemctl status laufzettel +systemctl restart laufzettel # nach jeder Änderung an laufzettel.env +journalctl -u laufzettel -f # Protokoll mitlesen +journalctl -u laufzettel -n 100 --no-pager +``` + +Eigene Zusätze zur Unit gehören nach `/etc/systemd/system/laufzettel.service.d/` als Drop-in. +Änderungen direkt in `laufzettel.service` überschreibt der nächste Lauf des Installationsskripts. + +Nachsehen, ob die Anwendung antwortet: + +```bash +curl -s http://127.0.0.1:3000/api/gesundheit +# {"status":"ok"} +``` + +In die Datenbank sehen — über den Socket, denn einen Port gibt es nicht: + +```bash +sudo -u postgres psql laufzettel +``` + +## Aktualisieren + +Dasselbe Skript noch einmal aufrufen: + +```bash +sudo bash /opt/laufzettel/deploy/install.sh +``` + +Es holt den Quellcode (`git pull --ff-only`), baut neu, spielt die Migrationen ein, fährt den Seed +(der nichts ändert, was schon da ist) und startet den Dienst neu. **Geheimnisse und Daten bleiben:** +`laufzettel.env` wird nicht angefasst, die Datenbank bleibt stehen, das Admin-Passwort wird nicht neu +erzeugt. + +Vorher eine Sicherung ziehen. Eine Migration fährt nicht rückwärts. + +Schlägt `git pull --ff-only` fehl, liegen in `/opt/laufzettel` eigene Änderungen. Nachsehen mit: + +```bash +sudo -u laufzettel git -C /opt/laufzettel status +``` + +## Die Selbstprüfung + +```bash +sudo bash /opt/laufzettel/deploy/install.sh --test +``` + +Prüft vier Dinge und nennt jedes einzeln mit `[ok]` oder `[FEHLER]`: + +1. `ss -ltn` zeigt **keinen** Port 5432 — PostgreSQL spricht kein TCP +2. Die Datenbank ist über den Socket erreichbar, Migration und Seed sind gelaufen (mindestens sieben + Vorlagen) +3. `/etc/laufzettel/laufzettel.env` hat `600 root:root` +4. `/api/gesundheit` antwortet mit `status ok` + +Das ist der schnellste Weg, nach einem Umzug oder einer Aktualisierung zu prüfen, dass der Stand noch +der beschriebene ist. + +## Was du beim ersten Lauf selbst nachprüfen musst + +Der `pct`-Teil ist ungetestet — kein PVE-Host stand zur Verfügung. Diese Liste ist die Gegenprobe. +Geh sie in dieser Reihenfolge durch; jede Zeile nennt den Schritt, das erwartete Ergebnis und was zu +tun ist, wenn es abweicht. + +| Schritt | Erwartet | Wenn es abweicht | +|---|---|---| +| `bash laufzettel.sh --dry-run` auf dem PVE-Host | Ein vollständiger `pct create`-Aufruf mit CTID, Vorlage, `--cores 2`, `--memory 2048`, `--rootfs …:8`, `--unprivileged 1`, `--net0 name=eth0,bridge=vmbr0,ip=dhcp`. Es wird **nichts** angelegt. | Fehlt ein Wert oder steht ein leerer Platzhalter im Aufruf, den Befehl **nicht** von Hand abschicken. Stattdessen den Container über die PVE-Oberfläche anlegen und Weg B gehen. | +| Vorlage vorhanden | `pveam list local` nennt ein `debian-13-standard`-Archiv. Fehlt es, holt das Skript es mit `pveam download`. | Findet `pveam available` keine Debian-13-Vorlage, ist der Vorlagenindex alt: `pveam update`. Danach noch einmal. | +| `pct create` läuft durch | Der Container erscheint in `pct list` mit dem Hostnamen `laufzettel`. | Meldet PVE „storage does not support container directories“, ist der gewählte Speicher kein Verzeichnis- oder ZFS-Speicher. Im Erweitert-Dialog einen anderen wählen (`local-lvm`, `local-zfs`). | +| Der Container bekommt eine Adresse | `pct exec -- ip -4 addr` zeigt eine Adresse auf `eth0`, und `pct exec -- ping -c1 deb.debian.org` kommt durch. | Ohne DHCP im Netz bleibt die Adresse leer und die Installation scheitert beim ersten `apt`. Container anhalten, im Erweitert-Dialog eine feste IP samt Gateway setzen. | +| `install.sh` läuft im Container | Neun Schritte, jeder mit Meldung, am Ende der Abschlussbildschirm mit Adresse, Kennung und Passwort. | Bricht ein Schritt ab, steht die Ursache in der Meldung. Der Lauf ist wiederholbar: `pct enter `, dann `bash /opt/laufzettel/deploy/install.sh` erneut. | +| **Kein TCP-Port 5432** | `pct exec -- ss -ltn` zeigt 5432 **nicht**. | Steht der Port da, hat `listen_addresses = ''` nicht gegriffen: prüfen, ob `/etc/postgresql/17/main/postgresql.conf` die Zeile `include_dir = 'conf.d'` enthält, dann `systemctl restart postgresql@17-main`. | +| Der Dienst läuft | `pct exec -- systemctl is-active laufzettel` sagt `active`, und `curl` auf `/api/gesundheit` antwortet `{"status":"ok"}`. | `pct exec -- journalctl -u laufzettel -n 50 --no-pager`. Häufigste Ursache: der Bau ist an zu wenig RAM gescheitert und `.next/standalone` ist unvollständig. | +| Umgebungsdatei geschützt | `stat -c '%a %U:%G' /etc/laufzettel/laufzettel.env` → `600 root:root`. | Rechte richtigstellen: `chown root:root`, `chmod 0600`. Und prüfen, wer in der Zwischenzeit lesen konnte. | +| Neustart übersteht alles | `pct reboot `, danach antwortet `/api/gesundheit` ohne Zutun wieder. | Kommt der Dienst nicht von selbst, fehlt das `enable`: `systemctl enable laufzettel` und `systemctl enable postgresql@17-main`. | +| Zweiter Lauf aktualisiert | `install.sh` noch einmal: „laufzettel.env ist vorhanden und bleibt unangetastet“, „Datenbank laufzettel vorhanden — Daten bleiben, wie sie sind“, Passwort „unverändert“. | Wird ein **neues** Admin-Passwort angezeigt, war das Konto nicht auffindbar — dann steht in `laufzettel.env` eine andere `SEED_ADMIN_EMAIL` als erwartet. Nachsehen, bevor du weitermachst. | +| **Der Dienst ist von außen erreichbar** | `curl http://:3000/api/gesundheit` vom PVE-Host aus antwortet `{"status":"ok"}`. | Antwortet nur `curl` **im** Container, aber nicht von außen, steht in `/etc/laufzettel/laufzettel.env` noch `HOSTNAME="127.0.0.1"`. Das ist die richtige Voreinstellung für einen Proxy im selben Container, aber nicht für einen Proxy davor: auf `0.0.0.0` setzen (oder auf die Container-IP), `systemctl restart laufzettel`, und eine Firewall-Regel, die den Port nur dem Proxy erlaubt. | +| **`APP_URL` passt zum Namen, unter dem du später zugreifst** | In `laufzettel.env` steht die öffentliche Adresse. Ohne `--url` setzt `install.sh` `http://:3000`. | Richtigstellen, **bevor** der erste Aufkleber gedruckt wird: Datei bearbeiten, `systemctl restart laufzettel`. Siehe [`reverse-proxy.md`](reverse-proxy.md). | +| Die Anwendung ist bedienbar | Anmelden, einen Lauf öffnen, **einen Schritt abhaken**, Seite neu laden, Haken ist noch da. | Bleibt der Haken nicht, ist der Reverse Proxy falsch eingestellt — mit hoher Wahrscheinlichkeit die `Host`-Kopfzeile. Siehe [`reverse-proxy.md`](reverse-proxy.md). | +| Die Sicherung funktioniert | Einmal `pg_dump` ziehen und in eine zweite, leere Datenbank zurückspielen. | Siehe [`sicherung.md`](sicherung.md). Eine Sicherung, die nie zurückgespielt wurde, ist keine. | + +Der letzte Punkt ist der wichtigste. Alles davor kann man reparieren. + +## Wenn es klemmt + +| Symptom | Ursache und was zu tun ist | +|---|---| +| „Das Skript … braucht root“ | Mit `sudo bash install.sh` aufrufen, nicht ohne. | +| „Erwartet ist Debian 13, gefunden …“ | Nur eine Warnung, der Lauf geht weiter. Auf älteren Debians kann die PostgreSQL-Fassung abweichen; das Skript nimmt dann das Metapaket `postgresql`. | +| „runuser fehlt (Paket util-linux)“ | Ein sehr schlankes Abbild. `apt install util-linux`. | +| „/opt/laufzettel ist nicht leer, enthält aber kein Git-Arbeitsverzeichnis“ | Dort liegt ein alter Stand ohne `.git`. Verschieben oder löschen, dann neu starten. | +| `pnpm build` bricht mit `Killed` ab | Zu wenig RAM. Dem Container vorübergehend 4 GB geben (`pct set --memory 4096`), erneut laufen lassen, danach zurückdrehen. | +| „Der Dienst antwortet nach 60 s nicht“ | `journalctl -u laufzettel -n 50 --no-pager`. Meist fehlt etwas in `laufzettel.env` oder die Datenbank ist nicht erreichbar. | +| „In `laufzettel.env` fehlt `DATABASE_URL`, `APP_SECRET` oder `APP_URL`“ | Die Datei ist beschädigt. Ergänzen ist der richtige Weg. Löschen legt sie neu an — dabei geht das bisherige `APP_SECRET` verloren: alle Sitzungen enden, und die verschlüsselt gespeicherten LDAP-Bind-Passwörter sind unlesbar und müssen neu eingegeben werden. | +| `psql: FATAL: password authentication failed` | Das Passwort in `DATABASE_URL` passt nicht zur Rolle. Das Skript setzt es nur dann neu, wenn es die Umgebungsdatei selbst neu angelegt hat. Von Hand: `sudo -u postgres psql -c '\password laufzettel'` und den Wert in `laufzettel.env` nachziehen. | +| Kein systemd im Container | Das Skript erkennt es, schreibt die Unit trotzdem und sagt, wie der Dienst von Hand zu starten ist. Tritt bei einem `docker run debian:13` auf, nicht in einem LXC. | diff --git a/docs/betrieb/reverse-proxy.md b/docs/betrieb/reverse-proxy.md new file mode 100644 index 0000000..4c9c7eb --- /dev/null +++ b/docs/betrieb/reverse-proxy.md @@ -0,0 +1,239 @@ +# Reverse Proxy + +Laufzettel spricht selbst kein HTTPS und bindet standardmäßig auf `127.0.0.1:3000`. Davor gehört ein +Reverse Proxy, der das Zertifikat hält und die Anfragen weiterreicht. Das gilt auf beiden +Betriebswegen — im [Compose-Stand](inbetriebnahme.md) wie [nativ im LXC](proxmox.md). + +## Zuerst: die Adresse, die auf dem Aufkleber landet + +`APP_URL` ist die **öffentliche** Adresse der Anwendung. Sie wird nicht aus der eingehenden Anfrage +erraten, sondern gesetzt, und zwar aus einem Grund, der Papier betrifft: + +**Aus `APP_URL` entstehen die QR-Adressen auf den gedruckten Aufklebern.** Im QR steht +`{APP_URL}/t/{tagCode}` — nichts weiter, kein Name, keine Seriennummer. Ein Aufkleber klebt Jahre +am Gerät und lässt sich nicht nachträglich korrigieren. Wäre die Adresse aus der Anfrage geraten, +stünde auf jedem Aufkleber, der über den Proxy hinweg gedruckt wurde, der interne Hostname: +`http://laufzettel-app:3000/t/LZ-7K3F9Q`. Den erreicht ein Telefon im WLAN nie. + +Deshalb, bevor der erste Aufkleber gedruckt wird: + +1. Den DNS-Namen festlegen, unter dem die Anwendung dauerhaft erreichbar sein soll +2. `APP_URL` genau darauf setzen — mit `https://`, ohne Schrägstrich am Ende +3. Einen Aufkleber probedrucken, mit dem Telefon scannen und schauen, wo man landet + +Ändert sich `APP_URL` später doch, ist nicht alles verloren, aber unangenehm: + +- **Der Scanner in der Anwendung nimmt den Code weiterhin an**, egal von welchem Host die gescannte + Adresse kommt. Das ist ausdrücklich so gebaut: ein Haus zieht um, ein zweiter Name zeigt auf + dieselbe Anwendung — daran soll der Aufkleber im Regal nicht scheitern. +- **Unter dem QR steht der Code im Klartext** (`LZ-7K3F9Q`) und lässt sich eintippen. +- **Was nicht mehr geht, ist der bequeme Weg:** mit der Kamera-App des Telefons scannen und dem + Link folgen. Der zeigt auf die alte Adresse. + +Der einfachste Ausweg ist, den alten Namen im DNS und im Proxy als zweiten `server_name` +beziehungsweise zweiten Host weiterlaufen zu lassen und auf denselben Dienst zu leiten. Der +zweitbeste ist, neu zu drucken. Die Tag-Codes selbst bleiben dabei gültig — ein Code wird nie +geändert und nie neu vergeben. + +Die zweite Stelle, an der `APP_URL` zählt, ist die OIDC-Rücksprungadresse: `APP_URL` plus +`/api/auth/oidc/rueckruf`, zeichengenau so beim Anbieter hinterlegt. Siehe +[`authentik.md`](authentik.md). + +Und die dritte: aus dem Schema von `APP_URL` entscheidet die Anwendung, ob das Sitzungs-Cookie das +`Secure`-Flag bekommt. Steht dort `http://`, obwohl außen HTTPS läuft, ist das Cookie unnötig +schwach. Steht dort `https://`, während außen nur HTTP läuft, wirft der Browser das Cookie weg und +man landet nach jedem Login wieder auf der Anmeldeseite. + +## Caddy + +Der kürzere Weg. Caddy holt und erneuert das Zertifikat selbst, leitet HTTP auf HTTPS um und setzt +`X-Forwarded-For`, `X-Forwarded-Proto` und `X-Forwarded-Host` von sich aus richtig. + +```caddyfile +laufzettel.firma.de { + encode zstd gzip + + reverse_proxy 127.0.0.1:3000 { + # Antwortet die Anwendung hier nicht, ist sie wirklich weg – und nicht nur + # gerade beim Neustart. Der Pfad braucht keine Anmeldung. + health_uri /api/gesundheit + health_interval 30s + } +} +``` + +Läuft Caddy auf einem anderen Rechner als die Anwendung, muss diese auf eine erreichbare Adresse +binden: `BIND_ADDR` im Compose-Stand beziehungsweise `HOSTNAME` in `/etc/laufzettel/laufzettel.env` +nativ. Dann gehört eine Firewall-Regel dazu, die den Port nur dem Proxy erlaubt. + +Übernehmen: + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl reload caddy +``` + +## nginx + +Mehr Zeilen, und eine Falle. Die vollständige Datei: + +```nginx +# HTTP nur, um auf HTTPS zu leiten – und fuer die ACME-Abfrage von certbot. +server { + listen 80; + listen [::]:80; + server_name laufzettel.firma.de; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 308 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name laufzettel.firma.de; + + ssl_certificate /etc/letsencrypt/live/laufzettel.firma.de/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/laufzettel.firma.de/privkey.pem; + + # Der CSV-Import schickt die Datei als Text durch eine Server Action. nginx + # laesst von sich aus nur 1 MB durch und waere damit die erste Wand. + client_max_body_size 8m; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + + # Diese Zeile ist die wichtigste der Datei, siehe unten. + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + + # Der Lauf fragt seinen Zustand alle zehn Sekunden nach; 90 s sind + # reichlich und fangen einen langsamen Verzeichnis-Abgleich mit ab. + proxy_read_timeout 90s; + + # Next liefert Seiten stroemend aus. Ohne das haelt nginx die Antwort + # zusammen, und die Seite erscheint spuerbar spaeter auf einmal. + proxy_buffering off; + } +} +``` + +Übernehmen: + +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +Das Zertifikat holt certbot: + +```bash +sudo apt install certbot python3-certbot-nginx +sudo certbot --nginx -d laufzettel.firma.de +``` + +### Die Falle: `Host` muss durchgereicht werden + +**nginx ersetzt die Kopfzeile `Host` von sich aus** durch das Ziel von `proxy_pass`, also +`127.0.0.1:3000`. Caddy und Traefik tun das nicht, nginx schon. + +Das bricht jedes Formular in Laufzettel. Der Grund: Next prüft bei jeder Server Action — und jedes +Speichern ist eine — den `Origin` der Anfrage gegen den Host, unter dem es sich selbst wähnt +(`x-forwarded-host`, sonst `host`). Der Browser schickt `laufzettel.firma.de`, der Server liest +`127.0.0.1:3000`, die beiden passen nicht zusammen, und die Aktion wird als möglicher +CSRF-Versuch abgewiesen. Das ist eine Schutzmaßnahme, die hier auf das falsche Ziel schießt. + +Das Heimtückische daran: **Seiten laden weiterhin völlig normal.** Nur kein Haken, kein Speichern, +keine Änderung kommt durch. Wer den Proxy nach einem Screenshot abnimmt, merkt es nicht. + +`proxy_set_header Host $host;` und `proxy_set_header X-Forwarded-Host $host;` lösen das. Hinter +einem Proxy, der den öffentlichen Host in `x-forwarded-host` weitergibt, braucht es keine weitere +Einstellung in der Anwendung. Erst wenn ein Proxy das nicht kann, käme +`experimental.serverActions.allowedOrigins` in `next.config.ts` in Frage — der Weg über die +Kopfzeile ist der richtige, weil er den Schutz erhält, statt ihn aufzuweichen. + +## Welche Kopfzeilen der Proxy setzen soll + +| Kopfzeile | Warum | +|---|---| +| `Host` | Muss der öffentliche Name sein, sonst scheitern alle Server Actions. Bei nginx ausdrücklich setzen. | +| `X-Forwarded-Host` | Dasselbe nochmal, und das ist die Kopfzeile, die Next zuerst liest. | +| `X-Forwarded-Proto` | `https`, damit die Anwendung weiß, dass die Verbindung außen gesichert ist. | +| `X-Forwarded-For` | Die Adresse des Benutzers, für die Fehlersuche im Protokoll des Proxys. | + +Alles andere braucht die Anwendung nicht. Sie leitet ihre eigenen Adressen aus `APP_URL` ab, nicht +aus diesen Kopfzeilen — deshalb bleibt sie auch dann brauchbar, wenn ein Proxy sie vergisst; nur die +Server Actions eben nicht. + +## Welche Kopfzeilen der Proxy nicht setzen soll + +**Die Sicherheitskopfzeilen kommen aus der Anwendung.** Sie schickt `Strict-Transport-Security`, +`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy` und eine +`Content-Security-Policy` mit einer Nonce je Anfrage. Setz sie im Proxy nicht noch einmal: + +- Eine **zweite CSP** ist nicht die mildere, sondern eine zusätzliche. Browser wenden alle + mitgeschickten Richtlinien an, und es gilt jeweils die strengste Einschränkung. Eine + gutgemeinte zweite CSP schaltet damit zuverlässig die Anwendung ab — und zwar wieder so, dass die + Seite aussieht wie immer und nichts tut. +- **HSTS doppelt** ist harmlos, aber unsauber. Wenn du eine längere Laufzeit willst, ändere sie in + `next.config.ts`, nicht im Proxy. + +## Was noch dazugehört + +**Einbetten geht nicht.** Die CSP der Anwendung enthält `frame-ancestors 'none'`, dazu +`X-Frame-Options: DENY`. Laufzettel lässt sich nicht in ein Intranet-Portal oder ein Dashboard als +`iframe` einhängen. Verlink es stattdessen. + +**Kein Unterpfad.** Laufzettel gehört auf einen eigenen Hostnamen, nicht unter +`https://intranet.firma.de/laufzettel/`. Ein `basePath` ist nicht gesetzt, die Anwendung liefert ihre +eigenen Dateien also unter `/_next/…` aus, und die kämen unter einem Unterpfad nicht an. Die +Adressberechnung für die Aufkleber trägt einen Pfad in `APP_URL` bewusst mit — das schützt gegen den +schlimmsten Fall, macht den Unterpfad aber nicht betriebsfähig. + +**Der Healthcheck ist `/api/gesundheit`.** Er braucht keine Anmeldung, prüft die Datenbank mit und +antwortet `{"status":"ok"}` oder mit Status 503. Das ist der richtige Pfad für die Überwachung des +Proxys; `/` leitet ohne Cookie auf die Anmeldeseite um und sagt nichts über den Zustand. + +**Die Kamera braucht HTTPS.** Der QR-Scanner in der Anwendung benutzt die Kamera des Geräts, und +Browser geben sie nur über eine gesicherte Verbindung frei (`localhost` ausgenommen). Ohne TLS +scannt am Handy niemand — das ist der praktische Grund, warum der Proxy nicht optional ist. + +**Etwas Geduld beim Neustart.** Nach `docker compose up -d` oder `systemctl restart laufzettel` +braucht die Anwendung einige Sekunden. Ein Proxy mit aktivem Healthcheck fängt das ab; ohne +bekommen die ersten Anfragen ein `502`. + +## Prüfen, dass es wirklich sitzt + +Ein Screenshot beweist hier nichts — genau die Fehler, die hier passieren, lassen die Seite richtig +aussehen. Nachprüfen heißt: einmal etwas ändern. + +```bash +# 1. Die Anwendung antwortet dem Proxy +curl -s http://127.0.0.1:3000/api/gesundheit + +# 2. Der Proxy reicht durch, mit gueltigem Zertifikat +curl -s https://laufzettel.firma.de/api/gesundheit + +# 3. HTTP leitet auf HTTPS +curl -sI http://laufzettel.firma.de | head -1 + +# 4. Die Sicherheitskopfzeilen kommen genau einmal +curl -sI https://laufzettel.firma.de/anmelden | grep -ci "content-security-policy" +``` + +Und dann per Hand, im Browser: + +1. Anmelden +2. Einen Lauf öffnen und **einen Schritt abhaken** +3. Die Seite neu laden und nachsehen, ob der Haken noch da ist + +Schritt 3 ist der eigentliche Test. Bleibt der Haken nicht, ist die `Host`-Kopfzeile falsch. diff --git a/docs/betrieb/sicherung.md b/docs/betrieb/sicherung.md new file mode 100644 index 0000000..899682c --- /dev/null +++ b/docs/betrieb/sicherung.md @@ -0,0 +1,289 @@ +# Sichern und Zurückspielen + +Laufzettel ist ein Gedächtnis: was in einem archivierten Lauf steht, soll in zehn Jahren noch sagen +können, wer wann was getan hat. Dazu kommt ein handfester Grund, der auf Papier klebt — **jeder +gedruckte Aufkleber zeigt auf einen Tag-Code, der nur in der Datenbank existiert.** Ein Code wird nie +geändert und nie neu vergeben; ist die Datenbank weg, sind alle Aufkleber im Haus wertlos, und es +gibt keinen Weg, sie zurückzurechnen. + +Die Sicherung ist auf beiden Betriebswegen eine Sache von zwei Befehlen. Das Zurückspielen ist +ebenfalls einfach, aber nur, wenn man es einmal geübt hat. + +## Was gesichert werden muss + +Zwei Dinge, und nur diese zwei: + +| | Was | Wo | +|---|---|---| +| **1** | **Die Datenbank.** Alles Veränderliche steckt darin: Läufe, Vorlagen, Schrittergebnisse, Kommentare, Geräte, Personen, Tag-Codes, Protokoll. | `pg_dump`, siehe unten | +| **2** | **Die Geheimnisse.** `APP_SECRET` und das Datenbank-Passwort. | Compose: die `.env` · Nativ: `/etc/laufzettel/laufzettel.env` | + +**Warum die Geheimnisse dazugehören:** `APP_SECRET` verschlüsselt nicht nur die Sitzungs-Cookies, +sondern auch die gespeicherten LDAP-Bind-Passwörter. Spielst du eine Datenbank in eine Installation +mit neuem `APP_SECRET` zurück, sind alle Sitzungen ungültig — das ist harmlos, man meldet sich neu an +— **und jedes Bind-Passwort der Verzeichnisquellen ist unlesbar**. Der Verzeichnis-Abgleich schlägt +dann fehl, und das Passwort muss von Hand neu eingetragen werden. Merkt man erst, wenn der nächste +Abgleich fällig ist. + +Sicher die beiden Dinge getrennt und leg das Geheimnis dahin, wo auch die restlichen Zugangsdaten des +Hauses liegen — in den Passwortmanager. Eine Datei mit `APP_SECRET` neben dem Datenbank-Abzug auf +derselben Netzwerkfreigabe hebt den Zweck der Verschlüsselung auf. + +**Was nicht gesichert werden muss:** Die Anwendung hält keinen veränderlichen Zustand auf der Platte. +Kein Upload-Verzeichnis, keine erzeugten Dateien. Der Quellcode kommt aus Git, `node_modules` und +`.next` entstehen beim Bauen neu, das PDF der Anleitung liegt am Release. + +**Was der Abzug bewusst nicht enthält:** ein `{{generiertes_passwort}}`. Es wird bei jeder Anzeige neu +erzeugt und nie gespeichert — nicht im Schrittergebnis, nicht im Kommentar, nicht im Protokoll. Wer +ein so gesetztes Passwort braucht, findet es in der Sicherung nicht, und das ist Absicht. + +> **Der Abzug enthält personenbezogene Daten.** Namen, E-Mail-Adressen und Abteilungen der +> Mitarbeiter, dazu argon2-Hashes der lokalen Passwörter. Er gehört verschlüsselt aufbewahrt und +> nicht auf eine offene Freigabe. + +## Docker Compose + +### Sichern + +Der Datenbankport ist von außen geschlossen — das ändert nichts, die Sicherung läuft über den +Dienstnamen: + +```bash +cd /opt/laufzettel +docker compose exec -T postgres pg_dump -U laufzettel laufzettel | gzip > sicherung-$(date +%F).sql.gz +``` + +`-T` ist wichtig: ohne den Schalter hängt Docker ein Terminal dazwischen, und das verdirbt die +Ausgabe. Im Ergebnis liegt eine Datei von einigen hundert Kilobyte bis wenigen Megabyte. + +Wer lieber das Eigenformat von PostgreSQL will — es lässt sich selektiv und parallel zurückspielen: + +```bash +docker compose exec -T postgres pg_dump -U laufzettel -Fc laufzettel > sicherung-$(date +%F).dump +``` + +Kurz prüfen, dass die Datei nicht leer ist: + +```bash +gunzip -c sicherung-$(date +%F).sql.gz | head -5 +gunzip -c sicherung-$(date +%F).sql.gz | grep -c 'COPY public' +``` + +Das Volume heißt `laufzettel_datenbank`. Es mit `docker run --volumes-from` wegzukopieren ist +**keine** gleichwertige Sicherung: ein Dateiabzug eines laufenden PostgreSQL ist im besten Fall +brauchbar und im schlechteren still beschädigt. Nimm `pg_dump`. + +### Zurückspielen + +Die Datenbank muss dafür leer sein — ein Abzug in eine gefüllte Datenbank gespielt bringt lauter +Fehler über schon vorhandene Tabellen und hinterlässt einen Mischzustand. + +```bash +cd /opt/laufzettel + +# 1. Die Anwendung anhalten, damit niemand dazwischenschreibt +docker compose stop web + +# 2. Datenbank leeren und neu anlegen +docker compose exec -T postgres dropdb -U laufzettel laufzettel +docker compose exec -T postgres createdb -U laufzettel -O laufzettel laufzettel + +# 3. Abzug einspielen +gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel laufzettel + +# 4. Migrationen nachziehen – nötig, wenn der Abzug von einer älteren Fassung stammt +docker compose run --rm migrate pnpm exec prisma migrate deploy + +# 5. Wieder anfahren +docker compose start web +curl -s http://127.0.0.1:3000/api/gesundheit +``` + +Bei einem `-Fc`-Abzug tritt an die Stelle von Schritt 3: + +```bash +docker compose exec -T postgres pg_restore -U laufzettel -d laufzettel --no-owner < sicherung-2026-09-24.dump +``` + +**Den Seed brauchst du danach nicht.** Er würde nichts kaputt machen — er richtet ein und räumt nicht +auf —, aber alles, was er anlegen würde, steht schon im Abzug. + +## Nativ (LXC, VM, Blech) + +### Sichern + +Über den Unix-Socket, als Systemnutzer `postgres`. Einen TCP-Port gibt es nicht. + +```bash +sudo -u postgres pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%F).dump +``` + +Und das Geheimnis, einmalig und in den Passwortmanager: + +```bash +sudo cat /etc/laufzettel/laufzettel.env +``` + +`sudo -u postgres` und nicht `psql -h localhost`: die Rolle `laufzettel` hat ein Passwort, aber +`postgres` kommt über die Peer-Authentifizierung am Socket ohne eines hinein — für ein Skript, das +ohne Eingabe laufen soll, ist das der einfachere Weg. + +### Zurückspielen + +```bash +# 1. Dienst anhalten +sudo systemctl stop laufzettel + +# 2. Datenbank leeren und neu anlegen, Eigentümer bleibt die Rolle laufzettel +sudo -u postgres dropdb laufzettel +sudo -u postgres createdb -O laufzettel laufzettel + +# 3. Abzug einspielen +sudo -u postgres pg_restore -d laufzettel --no-owner /var/backups/laufzettel-2026-09-24.dump + +# 4. Migrationen nachziehen, wenn der Abzug älter ist als der Code +sudo bash /opt/laufzettel/deploy/install.sh + +# 5. Wieder anfahren und nachsehen +sudo systemctl start laufzettel +curl -s http://127.0.0.1:3000/api/gesundheit +``` + +Schritt 4 ist der Installationsaufruf, weil er die Migrationen mitbringt und den Dienst gleich +neu startet — Schritt 5 ist dann nur noch die Gegenprobe. Willst du nur die Migrationen: + +```bash +cd /opt/laufzettel +sudo -u laufzettel env $(sudo grep -v '^#' /etc/laufzettel/laufzettel.env | xargs) pnpm exec prisma migrate deploy +``` + +## Automatisch sichern + +### Nativ, als systemd-Timer + +`/etc/systemd/system/laufzettel-sicherung.service`: + +```ini +[Unit] +Description=Laufzettel sichern +After=postgresql.service + +[Service] +Type=oneshot +User=postgres +# %i-freies Einzeiler-Skript: pg_dump in eine Datei mit Datum, dann alles +# aelter als 30 Tage weg. +ExecStart=/bin/bash -c 'pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%%F).dump' +ExecStartPost=/usr/bin/find /var/backups -name 'laufzettel-*.dump' -mtime +30 -delete +``` + +`/etc/systemd/system/laufzettel-sicherung.timer`: + +```ini +[Unit] +Description=Laufzettel taeglich sichern + +[Timer] +OnCalendar=daily +# Nach einem Ausfall die verpasste Sicherung nachholen, statt sie zu ueberspringen. +Persistent=true +RandomizedDelaySec=15m + +[Install] +WantedBy=timers.target +``` + +```bash +sudo install -d -o postgres -g postgres -m 0700 /var/backups +sudo systemctl daemon-reload +sudo systemctl enable --now laufzettel-sicherung.timer +sudo systemctl list-timers laufzettel-sicherung.timer +``` + +Einmal von Hand auslösen und nachsehen, ob wirklich eine Datei entstanden ist: + +```bash +sudo systemctl start laufzettel-sicherung.service +ls -l /var/backups/ +``` + +### Compose, als Cron-Eintrag + +In der Crontab von root (`sudo crontab -e`): + +```cron +15 3 * * * cd /opt/laufzettel && docker compose exec -T postgres pg_dump -U laufzettel laufzettel | gzip > /var/backups/laufzettel-$(date +\%F).sql.gz && find /var/backups -name 'laufzettel-*.sql.gz' -mtime +30 -delete +``` + +Das `%` muss in einer Crontab als `\%` geschrieben werden, sonst schneidet cron die Zeile ab — der +häufigste Fehler an dieser Stelle, und er fällt erst auf, wenn die Datei fehlt. + +### Und dann weg vom Rechner + +Eine Sicherung, die auf derselben Platte liegt wie die Datenbank, hilft gegen einen Tippfehler und +gegen nichts sonst. Zieh die Dateien nachts auf ein anderes System — Bordmittel genügen: + +```bash +rsync -a --delete /var/backups/ sicherung@nas:/laufzettel/ +``` + +Verschlüsselt, wegen der personenbezogenen Daten. Und behalte mehr als eine: ein Schaden, der drei +Tage unbemerkt bleibt, überschreibt sonst die letzte brauchbare Fassung. + +## Einmal üben, sonst gilt es nicht + +Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung, sondern eine Datei. Der Seed +liefert eigens eine Vorlage **Restore-Test** mit — nimm sie und mach den Test einmal im Quartal zu +einem Lauf mit Datum und Ergebnis. Dann steht er im Archiv, mit Namen und Zeitpunkt, und niemand muss +sich erinnern, wann er zuletzt geprüft hat. + +Der Test selbst, ohne den Betrieb anzufassen — in eine zweite, leere Datenbank: + +```bash +# Compose +docker compose exec -T postgres createdb -U laufzettel probe +gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel probe +docker compose exec -T postgres psql -U laufzettel probe -c 'SELECT count(*) FROM "Lauf"' +docker compose exec -T postgres dropdb -U laufzettel probe +``` + +```bash +# Nativ +sudo -u postgres createdb probe +sudo -u postgres pg_restore -d probe --no-owner /var/backups/laufzettel-2026-09-24.dump +sudo -u postgres psql -d probe -c 'SELECT count(*) FROM "Lauf"' +sudo -u postgres dropdb probe +``` + +Kommt bei `count(*)` eine plausible Zahl heraus, ist die Sicherung brauchbar. Kommt eine Fehlermeldung +oder eine Null, hast du es jetzt erfahren und nicht im Ernstfall. + +## Umzug auf einen neuen Server + +Derselbe Weg, nur mit einer Zwischenstation: + +1. Auf dem alten System `pg_dump` ziehen und `APP_SECRET` sowie die restliche `.env` beziehungsweise + `laufzettel.env` mitnehmen +2. Auf dem neuen System frisch einrichten — [Compose](inbetriebnahme.md) oder + [nativ](proxmox.md) +3. **Vor** dem Zurückspielen `APP_SECRET` in der neuen Umgebungsdatei auf den alten Wert setzen, sonst + sind die LDAP-Bind-Passwörter unlesbar +4. Abzug zurückspielen wie oben, Migrationen nachziehen +5. `APP_URL` prüfen: bleibt der DNS-Name gleich, bleiben die gedruckten Aufkleber gültig. Ändert er + sich, lies den Anfang von [`reverse-proxy.md`](reverse-proxy.md), bevor du umstellst + +Die PostgreSQL-Hauptfassung sollte auf dem Zielsystem dieselbe oder eine neuere sein. Ein Abzug aus +17 lässt sich in 18 einspielen, umgekehrt nicht. + +## Wenn es klemmt + +| Symptom | Ursache und was zu tun ist | +|---|---| +| `the input device is not a TTY` | Beim `docker compose exec` fehlt `-T`. | +| Der Abzug ist wenige Bytes groß | `pg_dump` hat nicht die Datenbank erwischt, sondern eine Fehlermeldung geschrieben. Ohne Umleitung wiederholen und lesen, was es sagt. | +| `ERROR: relation "Benutzer" already exists` | Du spielst in eine gefüllte Datenbank. Erst `dropdb`/`createdb`, siehe oben. | +| `role "laufzettel" does not exist` beim Zurückspielen auf einem anderen System | Der Abzug nennt Eigentümer, die dort fehlen. `pg_restore --no-owner` beziehungsweise `pg_dump --no-owner` beim Erzeugen. | +| Nach dem Zurückspielen schlägt der Verzeichnis-Abgleich fehl | `APP_SECRET` ist nicht mehr dasselbe, und das Bind-Passwort ist unlesbar. Entweder den alten Wert eintragen und neu starten, oder unter **Admin → Verzeichnis** das Bind-Passwort neu setzen. | +| Nach dem Zurückspielen fehlen Tabellen oder Spalten | Der Abzug ist älter als der Code. `prisma migrate deploy` nachziehen, siehe Schritt 4 oben. | +| Die Anwendung startet nach dem Zurückspielen nicht | `docker compose logs web` beziehungsweise `journalctl -u laufzettel -n 50 --no-pager`. Meist läuft noch die Migration, oder sie ist gescheitert. | +| Ein Tag-Code taucht doppelt auf | Kann nach einem Zurückspielen nicht passieren; ein partieller Eindeutigkeitsindex in der Datenbank verhindert es. Siehst du es doch, ist der Abzug nicht sauber eingespielt — Datenbank leeren und noch einmal von vorn. | diff --git a/docs/devlog.md b/docs/devlog.md index 57d6e8a..c31567a 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -21,11 +21,12 @@ als Unterpunkte mit Aufgabe und Dauer. | #016 | M11 – Verzeichnis und LDAP-Abgleich | 3 h 25 min | | #017 | M12 – Seed der sieben Vorlagen | 1 h 30 min | | #018 | M13 – E2E-Tests, Anleitung, PDF | 2 h 15 min | -| | **Summe** | **29 h 55 min** | +| #019 | M14 – Betriebswege und Release v0.1.0 | 5 h 25 min | +| | **Summe** | **35 h 20 min** | | Meilenstein | Tatsächlich | Status | |---|---|---| -| v0.1.0 | 29 h 55 min | in Arbeit | +| v0.1.0 | 35 h 20 min | veröffentlicht | --- @@ -899,3 +900,143 @@ als Unterpunkte mit Aufgabe und Dauer. - #019 Betriebswege und Release `v0.1.0` — `deploy/install.sh`, der Proxmox-One-Liner, die Betriebsdokumentation und der Tag mit Release-Notes. + +--- + +## 2026-09-26 · Sitzung 15 · 10:50–16:15 + +**Issue:** #019 (M14) +**Version:** 0.1.0 + +### Erledigt + +- **`deploy/install.sh`** (1058 Zeilen): neun Schritte von den Voraussetzungen bis zum gestarteten + Dienst, PostgreSQL 17 nur am Unix-Socket (`listen_addresses = ''`), Dienstnutzer ohne Login, + Geheimnisse im Zielsystem erzeugt, systemd-Unit im Skript, `--test` als Selbstprüfung +- **`deploy/proxmox/laufzettel.sh`** (963 Zeilen): der Einzeiler im Stil der Helper-Scripts, Dialog + Standard gegen Erweitert, Trockenlauf, Erkennung eines vorhandenen Containers, `--aktualisieren` +- **`docs/betrieb/`**: Inbetriebnahme, Proxmox, Reverse Proxy, Sicherung und eine Übersicht — dazu + eine Prüfliste für den Teil, den hier niemand ausführen konnte +- **Der gehärtete Compose-Stand einmal wirklich gefahren**, nicht nur gelesen: Migrationen laufen von + selbst, der Seed bleibt ein Handgriff, `5432` ist vom Host dicht und aus `web` erreichbar +- **Abnahme gegen alle 61 Kriterien** durch einen Agenten, der den Code nicht geschrieben hat: + 57 erfüllt, drei Befunde noch vor dem Tag behoben, drei Folge-Issues (#024–#026) +- **Release `v0.1.0`**: Changelog-Abschnitt, Tag, Gitea-Release mit dem Anleitungs-PDF + +### Entscheidungen + +- **Die Umgebungsdatei wird nie überschrieben, auch nicht beim Aktualisieren.** Sonst würde ein + Aktualisierungslauf das `APP_SECRET` neu erzeugen: alle Sitzungen enden, und die verschlüsselten + LDAP-Bind-Passwörter wären unlesbar. Wer Schalter mitgibt, die deshalb nicht mehr wirken, bekommt + eine Warnung statt stillen Gehorsams. +- **Das Erstpasswort reist über eine Leihgabe, nicht über die Ausgabe.** Der Proxmox-Weg kann die + Ausgabe von `install.sh` hinter `pct exec` nicht mitlesen. Sie mitzuschneiden und zu durchsuchen + hätte das Passwort durch Pipes und Protokolldateien geschleift und von Formulierungen abgehangen. + Stattdessen legt `install.sh` es auf Wunsch in eine Datei (0600, root), der Aufrufer liest sie + einmal und löscht sie sofort — gelesen oder nicht. +- **shellcheck läuft in CI im Container**, nicht aus dem Paketverzeichnis des Runners. So prüft die + Pipeline genau die Fassung, die hier lokal geprüft wurde, und der Runner muss nichts mitbringen. +- **Kriterium 4 bleibt offen statt „im Grunde erfüllt“.** Der `pct`-Teil ist nie gelaufen; hier stand + kein Proxmox-Host. Abgehakt wird er, wenn ihn ein Mensch einmal gefahren hat — Prüfliste und + Folge-Issue #024 stehen dafür bereit. + +### Probleme + +- **Der dokumentierte Weg startete nicht.** Der Compose-Stand las `DATABASE_URL` aus der `.env` — + dort steht die Entwicklungsadresse mit `localhost`, die im Container ins Leere zeigt. Und der + Einmal-Dienst für die Migration bekam kein `APP_SECRET`, weshalb der dokumentierte Seed-Befehl + mitten im Lauf abbrach, nachdem die Vorlagen schon angelegt waren. Beides fiel erst auf, als der + Stand zum ersten Mal wirklich gefahren wurde — die Suite war die ganze Zeit grün. **Ein Fehler im + dokumentierten Weg eines Dritten ist für eine Testsuite unsichtbar.** +- **Ein Healthcheck, der nie bestehen konnte.** busybox löst `localhost` im Container zuerst nach + `::1` auf, Next lauscht nur auf IPv4. `docker compose ps` zeigte `web` dauerhaft als `unhealthy`, + während die App einwandfrei antwortete. Jedes `docker compose up --wait` wäre in den Zeitablauf + gelaufen. Wieder eine Zusage ohne Deckung — jetzt hält ein Test die Adresse fest. +- **Die CI-Pipeline konnte in keinem Job grün werden.** Dem Prüf-Job fehlte das LDAP-Testverzeichnis, + das `pnpm test` dort ausdrücklich verlangt; dem E2E-Job fehlte ein gestarteter Server, weshalb die + Vorabprüfung aus #018 sofort abgebrochen hätte; und `shellcheck` wurde aufgerufen, ohne installiert + zu sein. „Qualitätsschranken“ galt damit nur lokal. Gefunden hat das die Abnahme, nicht die Arbeit + daran. +- **Ich habe „shellcheck ohne Befund“ gemeldet, ohne es zu sein.** Der Satz kam aus einem `echo`, das + unabhängig vom Ergebnis lief, während shellcheck vier SC1111-Warnungen ausgab. Das ist derselbe + Fehler, den dieser Devlog seit #010 als teuersten beschreibt, nur diesmal in meiner eigenen + Berichterstattung. Richtiggestellt, dann behoben — und seither wird jede Behauptung über ein + Prüfergebnis am Exit-Status gemessen. +- **Zwei Skripte, eine Naht.** `laufzettel.sh` übergibt seine Werte als Umgebungsvariablen, weil + `pct exec` keine Befehlszeile durchreicht; `install.sh` las zunächst nur Schalter. Der Dienst wäre + auf `127.0.0.1` gebunden geblieben, während der Abschlussbildschirm `http://:3000` versprach. + Nahtstellen zwischen zwei Skripten prüft keine Suite — nur das Lesen beider Seiten. +- **Der README nannte Port 5433, die Entwicklungsdatenbank hört auf 5434.** Wer dem README folgte, + landete auf der Datenbank eines anderen Projekts. Der Hinweis darauf heißt „0 Treffer“ — genau die + Meldung, die in #016 und #018 schon je einen halben Abend gekostet hat. + +### Nächster Schritt + +- Phase 1 ist abgeschlossen und veröffentlicht. Offen für `v0.2.0`: #020–#026, darunter der echte + Proxmox-Lauf (#024), der die letzte offene Zeile der Abnahme schließt. + +--- + +## Phase 1 abgeschlossen: was aus dem Plan wurde + +**35 h 20 min** über vierzehn Meilensteine, geschätzt waren **148 h**. Der Faktor ist kein Verdienst, +sondern eine Eigenschaft der Arbeitsweise: parallele Sub-Agenten an Stellen, wo die Schnittstelle +vorher feststand, und ein Mensch, der nur an den Nahtstellen eingreift. Die Schätzung war für +Menschenarbeit gedacht und bleibt als Maßstab stehen, weil sie den **Umfang** beschreibt. + +### Was entstanden ist + +| | | +|---|---| +| Quelltext | rund 30.000 Zeilen unter `src/` (ohne erzeugten Prisma-Client) | +| Datenmodell | 20 Tabellen, 9 Aufzählungen, 2 Migrationen | +| Tests | 1131 Vitest-Tests, 101 Ende-zu-Ende-Abläufe (`INTERN`), 97 (`DIENSTLEISTUNG`) | +| Vorlagen | 7 Abläufe mit 155 Schritten, als veröffentlichte Version 1 im Seed | +| Dokumentation | Anleitung in neun Kapiteln (72 Seiten PDF), Architektur, Betrieb, 14 Issue-Dateien | + +### Wo die Zeit hinging + +Der teuerste Meilenstein war **#019 Betriebswege** (5 h 25) — und zwar nicht wegen der beiden +Skripte, sondern weil dort zum ersten Mal der **dokumentierte Weg eines Dritten** gefahren wurde: +Compose-Stand hochziehen, Seed nach Anleitung, Installation in einem leeren Debian. Vier Fehler lagen +genau dort, wo keine Suite hinsieht, und die Suite war die ganze Zeit grün. + +Dahinter **#014 QR-Tags** (3 h 35), **#016 Verzeichnis** (3 h 25) und **#013 Läufe** (3 h 05) — alle +drei nicht wegen des Umfangs, sondern wegen der Randfälle: ein Scanner, der die native Schnittstelle +nie benutzte; ein Abgleich, der bei einem Tippfehler die ganze Firma abgeschaltet hätte; ein Passwort, +das zweimal verschieden erzeugt wurde. Keiner dieser Fehler fiel beim Bauen auf. Alle drei fand der +unabhängige Review. + +### Was sich als richtig erwiesen hat + +- **Ein Review-Agent je Meilenstein, der den Code nicht geschrieben hat.** In **jedem** der vierzehn + fand er etwas, in neun davon etwas Ernstes. Die teuersten Funde waren nie Tippfehler, sondern + Zusagen ohne Deckung: ein Kommentar, eine Oberflächenmeldung oder ein Testname, der etwas behauptet, + das der Code nicht tut. Das ist der häufigste Fehlertyp dieses Projekts — vor Logikfehlern, weit + vor Tippfehlern. +- **Regeln in der Datenbank, nicht nur im Dienst.** Zwölf CHECK-Bedingungen, zwei partielle + Eindeutigkeitsindizes, vier zusammengesetzte Fremdschlüssel und die Trigger auf veröffentlichten + Versionen haben mehrfach Fehler abgefangen, die durch die Service-Schicht gekommen wären — zuletzt + in #017, als der Seed eine veröffentlichte Version füllen wollte. +- **Gegen den gebauten Stand prüfen.** `next dev` hydriert in dieser Umgebung nicht; ein Screenshot + beweist nichts über Bedienbarkeit. Die Regel steht seit #010 in `CLAUDE.md` und hat sich in jedem + Oberflächen-Meilenstein bewährt. + +### Was ich anders machen würde + +- **Zweimal gegen einen veralteten Server geprüft** (#016, #018), beide Male eine halbe Stunde. Die + Konsequenz steht jetzt in der Arbeitsweise: erst `ps -eo lstart` gegen `.next/BUILD_ID` halten, + dann der Seite glauben. +- **Einmal in die falsche Datenbank geschrieben** (#017): Testprozess und Server zeigten + woandershin, und die Meldung hieß „0 Treffer". Seit #018 prüft das ein `global-setup` vorab. +- **Zwei eigene Tests konnten nicht scheitern** (#017, #018). Seitdem gehört die Gegenprobe zum + Schreiben eines Tests: einmal absichtlich verletzen und sehen, ob er rot wird. +- **Parallele Agenten im selben Verzeichnis** haben sich einmal gegenseitig eine Datei überschrieben + (#018). Die Trennung muss an Dateien verlaufen, nicht an Themen. + +### Was bewusst offen bleibt + +Sechs Folge-Issues für `v0.2.0`: Vorgesetzter für von Hand angelegte Personen (#020), stabiler +Schlüssel für Seed-Vorlagen (#021), Schnellstart je Gerätetyp pflegen (#022), Protokollansicht +(#023). Dazu die Punkte, die der Abnahme-Review aufwirft. Nichts davon blockiert den Betrieb; alles +davon steht geschrieben, statt verschwiegen zu werden. diff --git a/docs/issues/019-betrieb-release.md b/docs/issues/019-betrieb-release.md index aa1c298..c6fc5ed 100644 --- a/docs/issues/019-betrieb-release.md +++ b/docs/issues/019-betrieb-release.md @@ -1,9 +1,9 @@ # #019 M14 – Betriebswege und Release v0.1.0 -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** phase-1, infra, docs -**Geschätzt:** 10 h **Tatsächlich:** – +**Geschätzt:** 10 h **Tatsächlich:** 5 h 25 min ## Ziel @@ -11,30 +11,30 @@ Zwei erprobte Wege in den Betrieb und ein sauberes erstes Release. ## Aufgaben -- [ ] `deploy/install.sh`: native Installation auf Debian 13 — PostgreSQL 17 mit `listen_addresses = ''` (nur Unix-Socket), Node 24 aus dem NodeSource-Repo, corepack/pnpm, Dienstnutzer `laufzettel` ohne Login, Quellcode nach `/opt/laufzettel`, `pnpm install`, `pnpm build`, `prisma migrate deploy`, `prisma db seed` -- [ ] Geheimnisse im Zielsystem erzeugen: `APP_SECRET` per `openssl rand -base64 48`, Zufallspasswörter für Datenbank und Admin; Ablage in `/etc/laufzettel/laufzettel.env` mit Modus 0600 und Eigentümer root -- [ ] `laufzettel.service`: systemd-Unit für den Next-standalone-Server, `User=laufzettel`, `EnvironmentFile=`, `NoNewPrivileges=yes`, `ProtectSystem=strict`, `PrivateTmp=yes`, `Restart=on-failure` -- [ ] Erneuter Aufruf aktualisiert statt neu anzulegen: `git pull`, `pnpm build`, `migrate deploy`, `systemctl restart`; Geheimnisse und Daten bleiben -- [ ] `deploy/proxmox/laufzettel.sh`: One-Liner für die Proxmox-Shell — `set -Eeuo pipefail`, `trap` mit Zeilennummer, farbige Ausgabe, Spinner, Prüfung auf PVE 8/9 und root -- [ ] `whiptail`-Dialog Standard gegen Erweitert; Standard: nächste freie CTID, Hostname `laufzettel`, Debian 13, 2 Kerne, 2 GB RAM, 8 GB Platte, unprivilegiert, DHCP auf `vmbr0` -- [ ] Debian-Vorlage bei Bedarf per `pveam` holen, `pct create`, starten, `deploy/install.sh` im Container ausführen -- [ ] `--dry-run`: gibt alle `pct`-Aufrufe nur aus, ohne etwas anzulegen -- [ ] Abschlussbildschirm: URL, Admin-Kennung, erzeugtes Passwort, Hinweis auf Reverse Proxy und `docs/betrieb/` -- [ ] `docs/betrieb/`: `inbetriebnahme.md`, `proxmox.md`, `reverse-proxy.md`, `authentik.md`, `sicherung.md` (`pg_dump` über den Dienstnamen bzw. den Unix-Socket) -- [ ] Prüfliste für den Lauf auf echtem Proxmox, da ich den `pct`-Teil hier nicht ausführen kann -- [ ] `docs/abnahme-mvp.md` abschließend durchgehen, Review-Agent gegen alle Kriterien, Befunde als Issues -- [ ] Devlog-Phasenzusammenfassung: Gesamtstunden, Stunden je Meilenstein, größte Zeitfresser, Abweichung vom Plan +- [x] `deploy/install.sh`: native Installation auf Debian 13 — PostgreSQL 17 mit `listen_addresses = ''` (nur Unix-Socket), Node 24 aus dem NodeSource-Repo, corepack/pnpm, Dienstnutzer `laufzettel` ohne Login, Quellcode nach `/opt/laufzettel`, `pnpm install`, `pnpm build`, `prisma migrate deploy`, `prisma db seed` +- [x] Geheimnisse im Zielsystem erzeugen: `APP_SECRET` per `openssl rand -base64 48`, Zufallspasswörter für Datenbank und Admin; Ablage in `/etc/laufzettel/laufzettel.env` mit Modus 0600 und Eigentümer root +- [x] `laufzettel.service`: systemd-Unit für den Next-standalone-Server, `User=laufzettel`, `EnvironmentFile=`, `NoNewPrivileges=yes`, `ProtectSystem=strict`, `PrivateTmp=yes`, `Restart=on-failure` +- [x] Erneuter Aufruf aktualisiert statt neu anzulegen: `git pull`, `pnpm build`, `migrate deploy`, `systemctl restart`; Geheimnisse und Daten bleiben +- [x] `deploy/proxmox/laufzettel.sh`: One-Liner für die Proxmox-Shell — `set -Eeuo pipefail`, `trap` mit Zeilennummer, farbige Ausgabe, Spinner, Prüfung auf PVE 8/9 und root +- [x] `whiptail`-Dialog Standard gegen Erweitert; Standard: nächste freie CTID, Hostname `laufzettel`, Debian 13, 2 Kerne, 2 GB RAM, 8 GB Platte, unprivilegiert, DHCP auf `vmbr0` +- [x] Debian-Vorlage bei Bedarf per `pveam` holen, `pct create`, starten, `deploy/install.sh` im Container ausführen +- [x] `--dry-run`: gibt alle `pct`-Aufrufe nur aus, ohne etwas anzulegen +- [x] Abschlussbildschirm: URL, Admin-Kennung, erzeugtes Passwort, Hinweis auf Reverse Proxy und `docs/betrieb/` +- [x] `docs/betrieb/`: `inbetriebnahme.md`, `proxmox.md`, `reverse-proxy.md`, `authentik.md`, `sicherung.md` (`pg_dump` über den Dienstnamen bzw. den Unix-Socket) +- [x] Prüfliste für den Lauf auf echtem Proxmox, da ich den `pct`-Teil hier nicht ausführen kann +- [x] `docs/abnahme-mvp.md` abschließend durchgehen, Review-Agent gegen alle Kriterien, Befunde als Issues +- [x] Devlog-Phasenzusammenfassung: Gesamtstunden, Stunden je Meilenstein, größte Zeitfresser, Abweichung vom Plan - [ ] `CHANGELOG.md` für `v0.1.0` mit Datum, Version in `package.json`, Tag `v0.1.0`, Gitea-Release mit Changelog-Auszug, Anleitung als PDF beigelegt - [ ] Die Anleitung als PDF (`pnpm docs:pdf`) dem Gitea-Release als Datei beilegen — im Repo liegt sie nicht, sie entsteht aus den Kapiteln und Bildern ## Akzeptanzkriterien -- [ ] `docker run --rm debian:13` mit `deploy/install.sh` installiert vollständig durch: Postgres läuft, Migration und Seed gelaufen, Dienst antwortet auf `/api/gesundheit` -- [ ] Im nativen Weg lauscht PostgreSQL auf keinem TCP-Port (`ss -ltn` zeigt 5432 nicht) -- [ ] Ein zweiter Lauf von `deploy/install.sh` aktualisiert und löscht keine Daten -- [ ] `bash deploy/proxmox/laufzettel.sh --dry-run` gibt einen plausiblen, vollständigen `pct create`-Aufruf aus -- [ ] `shellcheck --severity=warning` über alle Skripte ohne Befund -- [ ] Im Compose-Weg ist die Datenbank vom Host nicht erreichbar, aus `web` schon +- [x] `docker run --rm debian:13` mit `deploy/install.sh` installiert vollständig durch: Postgres läuft, Migration und Seed gelaufen, Dienst antwortet auf `/api/gesundheit` +- [x] Im nativen Weg lauscht PostgreSQL auf keinem TCP-Port (`ss -ltn` zeigt 5432 nicht) +- [x] Ein zweiter Lauf von `deploy/install.sh` aktualisiert und löscht keine Daten +- [x] `bash deploy/proxmox/laufzettel.sh --dry-run` gibt einen plausiblen, vollständigen `pct create`-Aufruf aus +- [x] `shellcheck --severity=warning` über alle Skripte ohne Befund +- [x] Im Compose-Weg ist die Datenbank vom Host nicht erreichbar, aus `web` schon - [ ] `pnpm pruefe:doku` grün, `v0.1.0` getaggt und als Gitea-Release veröffentlicht -- [ ] Alle Kriterien aus `docs/abnahme-mvp.md` abgehakt oder als Folge-Issue erfasst +- [x] Alle Kriterien aus `docs/abnahme-mvp.md` abgehakt oder als Folge-Issue erfasst diff --git a/docs/issues/024-proxmox-echtlauf.md b/docs/issues/024-proxmox-echtlauf.md new file mode 100644 index 0000000..83af224 --- /dev/null +++ b/docs/issues/024-proxmox-echtlauf.md @@ -0,0 +1,55 @@ +# #024 Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen + +**Status:** offen +**Im Tracker:** – +**Meilenstein:** v0.2.0 +**Labels:** phase-2, infra +**Geschätzt:** 2 h **Tatsächlich:** – + +## Ziel + +`deploy/proxmox/laufzettel.sh` ist statisch geprüft und im Trockenlauf durchgelaufen, aber **nie auf +einem Proxmox-Host ausgeführt** — hier stand keiner zur Verfügung. Der Installationsteil +(`deploy/install.sh`) dagegen ist vollständig getestet: in `debian:13` ohne systemd und in einem +privilegierten Container mit echtem systemd 257, inklusive Aktualisierungslauf, `ProtectSystem`, +Sandbox und `/api/gesundheit`. + +Ungeprüft ist damit genau die Klammer darum: `pct`, `pveam`, `pvesh` und `whiptail`. + +Dieses Issue schließt, wenn der Einzeiler einmal auf einem echten PVE-Host gelaufen ist und die +Prüfliste in [docs/betrieb/proxmox.md](../betrieb/proxmox.md) („Was du beim ersten Lauf selbst +nachprüfen musst“) abgehakt ist. Die Liste ist der Prüfplan, nicht dieses Issue. + +## Konkret ungeprüft + +- [ ] `pveversion` erkennt PVE 8 und 9 richtig; Abbruch auf einem Nicht-PVE-Rechner +- [ ] `pveam available` / `pveam download` finden die Debian-13-Standardvorlage +- [ ] `pct create` nimmt die zusammengebauten Argumente an (Speicher mit Inhalt `rootdir`, + unprivilegiert, DHCP auf `vmbr0`) +- [ ] Die Ermittlung der nächsten freien CTID über `pvesh` stimmt mit dem, was PVE vergibt +- [ ] Der Container bekommt eine Adresse, und `CT_IP` wird richtig abgelesen — davon hängen `APP_URL` + und der Abschlussbildschirm ab +- [ ] `pct exec … bash -c " bash /root/laufzettel-install.sh"` übergibt die Variablen so, + wie `install.sh` sie liest (`APP_URL`, `BIND_ADDR`, `LAUFZETTEL_REPO`, `LAUFZETTEL_ZWEIG`, + `LAUFZETTEL_PASSWORT_DATEI`) +- [ ] Das Erstpasswort wird aus `/root/laufzettel-erstpasswort` gelesen **und danach gelöscht** +- [ ] Der `whiptail`-Dialog ist auf einer PVE-Shell bedienbar (Standard und Erweitert) +- [ ] Der zweite Aufruf erkennt den Container am Hostnamen und aktualisiert statt anzulegen +- [ ] `--aktualisieren` bricht ab, wenn es keinen Container `laufzettel` gibt +- [ ] Die Ausgabe von `install.sh` erscheint im Terminal, ohne von einem Spinner überschrieben zu + werden + +## Aufgaben + +- [ ] Einzeiler auf einem PVE-Host fahren, Prüfliste abhaken +- [ ] Abweichungen als eigene Issues aufnehmen, nicht hier sammeln +- [ ] Nach dem erfolgreichen Lauf den Satz „Der `pct`-Teil ist ungetestet“ in + `docs/betrieb/proxmox.md` durch das Ergebnis ersetzen (PVE-Fassung, Datum) +- [ ] `docs/abnahme-mvp.md` Zeile 11 abhaken + +## Akzeptanzkriterien + +- [ ] Ein LXC, in dem `systemctl is-active laufzettel` „active“ meldet und `/api/gesundheit` + `status ok` liefert +- [ ] `pct exec -- ss -ltn` zeigt keinen Port 5432 +- [ ] Zweiter Aufruf: Daten, `APP_SECRET` und Datenbankpasswort unverändert diff --git a/docs/issues/025-gesundheit-fehlermeldung.md b/docs/issues/025-gesundheit-fehlermeldung.md new file mode 100644 index 0000000..69d28e9 --- /dev/null +++ b/docs/issues/025-gesundheit-fehlermeldung.md @@ -0,0 +1,35 @@ +# #025 Healthcheck gibt die rohe Datenbankfehlermeldung heraus + +**Status:** offen +**Im Tracker:** – +**Meilenstein:** v0.2.0 +**Labels:** phase-2, backend +**Geschätzt:** 1 h **Tatsächlich:** – + +## Ziel + +`src/app/api/gesundheit/route.ts` antwortet im Fehlerfall mit + +```json +{ "status": "fehler", "meldung": "" } +``` + +Der Pfad ist absichtlich ohne Anmeldung erreichbar — er steht in der Allowlist des Proxys, und +`tests/sicherheitsrichtlinie.test.ts` hält das fest. Eine Fehlermeldung von Prisma oder `pg` nennt +typischerweise Host, Port, Benutzername und Datenbanknamen. Das ist für einen öffentlich erreichbaren +Endpunkt zu viel: er soll sagen, **ob** es geht, nicht warum nicht. + +Aufgefallen beim Abnahme-Review zu [#019](019-betrieb-release.md). + +## Aufgaben + +- [ ] Nach außen nur `{ "status": "fehler" }` mit Status 503 +- [ ] Die Begründung serverseitig protokollieren, damit sie beim Suchen nicht fehlt +- [ ] Prüfen, ob `docs/betrieb/` irgendwo die Meldung als Diagnosehilfe nennt — dann auf das Log + verweisen + +## Akzeptanzkriterien + +- [ ] Test: bei nicht erreichbarer Datenbank enthält die Antwort weder Host noch Port noch + Benutzernamen +- [ ] Test: der Statuscode bleibt 503, damit Docker und der Reverse Proxy weiter richtig entscheiden diff --git a/docs/issues/026-konventionen-praezisieren.md b/docs/issues/026-konventionen-praezisieren.md new file mode 100644 index 0000000..06e31f0 --- /dev/null +++ b/docs/issues/026-konventionen-praezisieren.md @@ -0,0 +1,37 @@ +# #026 Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind + +**Status:** offen +**Im Tracker:** – +**Meilenstein:** v0.2.0 +**Labels:** phase-2, docs +**Geschätzt:** 1 h **Tatsächlich:** – + +## Ziel + +Zwei Sätze in `CLAUDE.md` (und gleichlautend in `docs/architektur.md`) beschreiben den Code nicht +genau. Beide fielen beim Abnahme-Review zu [#019](019-betrieb-release.md) auf. + +**1. Rohe Hexwerte.** Die Regel sagt „Komponenten greifen nur darauf zu, nie auf rohe Hexwerte“. +Tatsächlich stehen vier begründete Ausnahmen im Code: `#ffffff` in `drucken/lauf/[id]/page.tsx` und +`drucken/etiketten/page.tsx` (Druck ist immer hell, unabhängig vom Farbschema der Anwendung) sowie +`#0B0D10` in `layout.tsx` und `manifest.ts` (`themeColor` und das Manifest vertragen keine +CSS-Variablen). Die Regel ist strenger formuliert als gemeint — sie soll um diese Ausnahmen ergänzt +werden, nicht der Code geändert. + +**2. `pnpm test` ohne LDAP-Container.** „Vor jedem Commit“ in `CLAUDE.md` und „Qualitätsschranken“ im +README nennen `pnpm test`, aber nicht +`docker compose -f docker-compose.test.yml up -d ldap`. Ohne den Container überspringt die Suite die +Tests gegen das echte Verzeichnis — sie sagt das auf stderr, aber der Lauf sieht grün aus. Wer nur +dem dokumentierten Weg folgt, prüft den Verzeichnis-Abgleich nie. Ein Satz in beiden Dateien. + +## Aufgaben + +- [ ] Ausnahme für rohe Hexwerte in `CLAUDE.md` und `docs/architektur.md` benennen, mit Begründung +- [ ] Den LDAP-Container in `CLAUDE.md` („Vor jedem Commit“) und `README.md` aufnehmen +- [ ] Prüfen, ob sich der Hinweis maschinell absichern lässt — `pruefe:doku` könnte verlangen, dass + jede in `CLAUDE.md` genannte Schranke auch den nötigen Vorlauf nennt + +## Akzeptanzkriterien + +- [ ] Die vier Hexwerte im Code stehen in Einklang mit der Regel, ohne den Code zu ändern +- [ ] Wer `CLAUDE.md` von oben nach unten befolgt, fährt die Suite mit LDAP-Container diff --git a/docs/issues/README.md b/docs/issues/README.md index 27e6d1d..77cd287 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -23,9 +23,12 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #016 | [M11 – Verzeichnis und LDAP-Abgleich](016-verzeichnis-ldap.md) | erledigt | v0.1.0 | 12 h | 3 h 25 min | | #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | erledigt | v0.1.0 | 8 h | 1 h 30 min | | #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | erledigt | v0.1.0 | 14 h | 2 h 15 min | -| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – | +| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | erledigt | v0.1.0 | 10 h | 5 h 25 min | | #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | offen | v0.2.0 | 3 h | – | | #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | offen | v0.2.0 | 2 h | – | | #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | offen | v0.2.0 | 3 h | – | | #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | offen | v0.2.0 | 4 h | – | -| | **Summe** | | | **148 h** | **29 h 55 min** | +| #024 | [Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen](024-proxmox-echtlauf.md) | offen | v0.2.0 | 2 h | – | +| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | offen | v0.2.0 | 1 h | – | +| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | offen | v0.2.0 | 1 h | – | +| | **Summe** | | | **152 h** | **35 h 20 min** | diff --git a/tests/betriebsstand.test.ts b/tests/betriebsstand.test.ts index 988391c..9b1bb1e 100644 --- a/tests/betriebsstand.test.ts +++ b/tests/betriebsstand.test.ts @@ -40,6 +40,15 @@ describe("Betriebsstand: docker-compose.yml", () => { expect(stand.services["web"]?.ports).toEqual(["${BIND_ADDR:-127.0.0.1}:${APP_PORT:-3000}:3000"]); }); + it("prueft die Gesundheit des Webdienstes ueber IPv4, nicht ueber localhost", () => { + // busybox-wget loest localhost zuerst nach ::1 auf, Next lauscht nur auf + // 0.0.0.0:3000. Mit localhost stand web dauerhaft auf (unhealthy), obwohl + // die App antwortete (#019). + const probe = (stand.services["web"] as unknown as { healthcheck?: { test?: string[] } }).healthcheck + ?.test; + expect(probe).toEqual(["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/gesundheit"]); + }); + it("bricht ohne Geheimnisse ab, statt mit Standardwerten zu starten", () => { const roh = readFileSync("docker-compose.yml", "utf8"); expect(roh).toContain("${APP_SECRET:?");