feat(betrieb): native Installation, Proxmox-Einzeiler und Abnahme
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Zwei Wege in den Betrieb, beide so weit gefahren, wie es hier moeglich war.

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

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

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

Closes #19
This commit is contained in:
Florian Wilde
2026-09-26 15:53:01 +02:00
parent fbbb634e56
commit bf1e52cf73
20 changed files with 3663 additions and 103 deletions
+68 -59
View File
@@ -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
+67
View File
@@ -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
+260
View File
@@ -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).
+286
View File
@@ -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. |
+239
View File
@@ -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.
+289
View File
@@ -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
View File
@@ -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.
+22 -22
View File
@@ -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
+55
View File
@@ -0,0 +1,55 @@
# #024 Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen
**Status:** offen
**Im Tracker:** –
**Meilenstein:** v0.2.0
**Labels:** phase-2, infra
**Geschätzt:** 2 h **Tatsächlich:** –
## Ziel
`deploy/proxmox/laufzettel.sh` ist statisch geprüft und im Trockenlauf durchgelaufen, aber **nie auf
einem Proxmox-Host ausgeführt** — hier stand keiner zur Verfügung. Der Installationsteil
(`deploy/install.sh`) dagegen ist vollständig getestet: in `debian:13` ohne systemd und in einem
privilegierten Container mit echtem systemd 257, inklusive Aktualisierungslauf, `ProtectSystem`,
Sandbox und `/api/gesundheit`.
Ungeprüft ist damit genau die Klammer darum: `pct`, `pveam`, `pvesh` und `whiptail`.
Dieses Issue schließt, wenn der Einzeiler einmal auf einem echten PVE-Host gelaufen ist und die
Prüfliste in [docs/betrieb/proxmox.md](../betrieb/proxmox.md) („Was du beim ersten Lauf selbst
nachprüfen musst“) abgehakt ist. Die Liste ist der Prüfplan, nicht dieses Issue.
## Konkret ungeprüft
- [ ] `pveversion` erkennt PVE 8 und 9 richtig; Abbruch auf einem Nicht-PVE-Rechner
- [ ] `pveam available` / `pveam download` finden die Debian-13-Standardvorlage
- [ ] `pct create` nimmt die zusammengebauten Argumente an (Speicher mit Inhalt `rootdir`,
unprivilegiert, DHCP auf `vmbr0`)
- [ ] Die Ermittlung der nächsten freien CTID über `pvesh` stimmt mit dem, was PVE vergibt
- [ ] Der Container bekommt eine Adresse, und `CT_IP` wird richtig abgelesen — davon hängen `APP_URL`
und der Abschlussbildschirm ab
- [ ] `pct exec … bash -c "<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:** –
**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:** –
**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
+5 -2
View File
@@ -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** |