feat(betrieb): native Installation, Proxmox-Einzeiler und Abnahme (#37)
Zwei Wege in den Betrieb, beide so weit gefahren, wie es hier moeglich war. deploy/install.sh richtet Debian 13 in neun Schritten ein: PostgreSQL 17 hoert nur am Unix-Socket, der Dienstnutzer hat kein Login, die Geheimnisse entstehen im Zielsystem und liegen 0600 bei root. Die Umgebungsdatei wird nie ueberschrieben -- auch nicht beim Aktualisieren, sonst enden alle Sitzungen und die verschluesselten Bind-Passwoerter waeren unlesbar. deploy/proxmox/laufzettel.sh legt den Container an und ruft install.sh darin auf. Der pct-Teil ist ungetestet, weil kein PVE-Host zur Verfuegung stand; die Gegenprobe steht als Pruefliste in docs/betrieb/proxmox.md, das Folge-Issue ist Nummer 38. Die Abnahme gegen alle 61 Kriterien hat drei Zusagen ohne Deckung gefunden, alle behoben: der Healthcheck des Webdienstes konnte nie bestehen (busybox loest localhost nach ::1 auf, Next lauscht nur auf IPv4), die CI-Pipeline konnte in keinem Job gruen werden (kein LDAP-Dienst, kein gestarteter Server, kein shellcheck), und der README nannte fuer die Entwicklungsdatenbank den falschen Port. Closes #19
This commit is contained in:
+10
-3
@@ -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 ----------
|
||||
|
||||
|
||||
+47
-5
@@ -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:
|
||||
|
||||
+23
-1
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Executable
+1058
File diff suppressed because it is too large
Load Diff
Executable
+963
@@ -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 <name> 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 <<ENDE
|
||||
Laufzettel läuft.
|
||||
|
||||
Adresse http://$CT_IP:3000
|
||||
Admin-Kennung $kennung
|
||||
Passwort $passwort
|
||||
|
||||
Das Passwort steht nur hier: in der Datenbank liegt allein der argon2-Hash,
|
||||
in $ENV_DATEI steht es nicht. Nach der ersten
|
||||
Anmeldung ändern. Verloren? Dann in der Anwendung ein neues setzen.
|
||||
|
||||
Reverse Proxy: der Dienst spricht nur HTTP auf Port 3000. Davor gehört ein
|
||||
Proxy mit TLS, und APP_URL im Container muss auf den Namen zeigen, unter dem
|
||||
Laufzettel dann erreichbar ist — sonst führen die QR-Codes und die
|
||||
OIDC-Rückrufadresse ins Leere. Siehe docs/betrieb/reverse-proxy.md.
|
||||
|
||||
Weiter in docs/betrieb/: inbetriebnahme.md, proxmox.md, authentik.md und
|
||||
sicherung.md (Sicherung per pg_dump über den Unix-Socket).
|
||||
|
||||
Shell im Container pct enter $CTID
|
||||
Dienst ansehen pct exec $CTID -- systemctl status laufzettel
|
||||
Gesundheitsprüfung curl http://$CT_IP:3000/api/gesundheit
|
||||
Kennung nachsehen pct exec $CTID -- grep SEED_ADMIN_EMAIL $ENV_DATEI
|
||||
ENDE
|
||||
)"
|
||||
|
||||
if [ -t 1 ] && [ "$TROCKEN" != "ja" ] && command -v whiptail >/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 "$@"
|
||||
+35
-3
@@ -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
|
||||
|
||||
+68
-59
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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=<Ausgabe von openssl rand -base64 48>
|
||||
POSTGRES_PASSWORD=<Ausgabe von openssl rand -base64 24>
|
||||
SEED_ADMIN_EMAIL=admin@firma.de
|
||||
SEED_ADMIN_PASSWORD=<ein Passwort, das du gleich brauchst>
|
||||
```
|
||||
|
||||
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 `<issuer>/.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).
|
||||
@@ -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 <name>` | 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 <adresse>` | Die **öffentliche** Adresse, also `APP_URL`. Ohne Angabe nimmt das Skript `http://<erste IP des Systems>:<port>` — das läuft, aber die QR-Codes auf den Aufklebern tragen dann diese Adresse. Setz sie richtig, bevor du druckst. |
|
||||
| `--admin-mail <adresse>` | Kennung des ersten Administrators. Standard `admin@firma.example`. |
|
||||
| `--port <nummer>` | Port des Webdienstes. Standard 3000. |
|
||||
| `--bind <adresse>` | 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 <wert>` | `INTERN` (Standard) oder `DIENSTLEISTUNG`. |
|
||||
| `--zweig <name>`, `--quelle <url\|pfad>` | Anderer Zweig oder andere Git-Quelle. Für Testläufe nimmt `--quelle` auch einen lokalen Pfad, wenn dort ein Git-Arbeitsverzeichnis liegt. |
|
||||
| `--passwort-datei <pfad>` | 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 <ctid>`).
|
||||
|
||||
**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 <ctid> -- ip -4 addr` zeigt eine Adresse auf `eth0`, und `pct exec <ctid> -- 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 <ctid>`, dann `bash /opt/laufzettel/deploy/install.sh` erneut. |
|
||||
| **Kein TCP-Port 5432** | `pct exec <ctid> -- 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 <ctid> -- systemctl is-active laufzettel` sagt `active`, und `curl` auf `/api/gesundheit` antwortet `{"status":"ok"}`. | `pct exec <ctid> -- 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 <ctid>`, 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://<container-ip>: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://<Container-IP>: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 <ctid> --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. |
|
||||
@@ -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.
|
||||
@@ -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. |
|
||||
+143
-2
@@ -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://<ip>: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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# #024 Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen
|
||||
|
||||
**Status:** offen
|
||||
**Im Tracker:** [#38](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/38)
|
||||
**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 "<Umgebung> 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 <ctid> -- ss -ltn` zeigt keinen Port 5432
|
||||
- [ ] Zweiter Aufruf: Daten, `APP_SECRET` und Datenbankpasswort unverändert
|
||||
@@ -0,0 +1,35 @@
|
||||
# #025 Healthcheck gibt die rohe Datenbankfehlermeldung heraus
|
||||
|
||||
**Status:** offen
|
||||
**Im Tracker:** [#39](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/39)
|
||||
**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": "<fehler.message>" }
|
||||
```
|
||||
|
||||
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
|
||||
@@ -0,0 +1,37 @@
|
||||
# #026 Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind
|
||||
|
||||
**Status:** offen
|
||||
**Im Tracker:** [#40](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/40)
|
||||
**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
|
||||
@@ -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** |
|
||||
|
||||
@@ -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:?");
|
||||
|
||||
Reference in New Issue
Block a user