feat(betrieb): native Installation, Proxmox-Einzeiler und Abnahme
Zwei Wege in den Betrieb, beide so weit gefahren, wie es hier moeglich war. deploy/install.sh richtet Debian 13 in neun Schritten ein: PostgreSQL 17 hoert nur am Unix-Socket, der Dienstnutzer hat kein Login, die Geheimnisse entstehen im Zielsystem und liegen 0600 bei root. Die Umgebungsdatei wird nie ueberschrieben -- auch nicht beim Aktualisieren, sonst enden alle Sitzungen und die verschluesselten Bind-Passwoerter waeren unlesbar. deploy/proxmox/laufzettel.sh legt den Container an und ruft install.sh darin auf. Der pct-Teil ist ungetestet, weil kein PVE-Host zur Verfuegung stand; die Gegenprobe steht als Pruefliste in docs/betrieb/proxmox.md, das Folge-Issue ist #024. Die Abnahme gegen alle 61 Kriterien hat drei Zusagen ohne Deckung gefunden, alle behoben: der Healthcheck des Webdienstes konnte nie bestehen (busybox loest localhost nach ::1 auf, Next lauscht nur auf IPv4), die CI-Pipeline konnte in keinem Job gruen werden (kein LDAP-Dienst, kein gestarteter Server, kein shellcheck), und der README nannte fuer die Entwicklungsdatenbank den falschen Port. Closes #19
This commit is contained in:
+68
-59
@@ -5,91 +5,100 @@ Punkt ist entweder abgehakt oder hat ein Folge-Issue — nichts bleibt stillschw
|
||||
|
||||
## Betrieb
|
||||
|
||||
- [ ] `docker compose up` startet App und Datenbank, Migrationen laufen automatisch
|
||||
- [ ] Seed läuft per dokumentiertem Befehl, nicht automatisch
|
||||
- [ ] Die Datenbank ist vom Host aus **nicht** erreichbar (`nc -z localhost 5432` schlägt fehl), aus `web` schon
|
||||
- [x] `docker compose up` startet App und Datenbank, Migrationen laufen automatisch
|
||||
- [x] Seed läuft per dokumentiertem Befehl, nicht automatisch
|
||||
- [x] Die Datenbank ist vom Host aus **nicht** erreichbar (`nc -z localhost 5432` schlägt fehl), aus `web` schon
|
||||
- [ ] Der Proxmox-One-Liner rollt einen LXC aus, in dem die App läuft und PostgreSQL nur am Unix-Socket hört
|
||||
- [ ] Erneuter Aufruf des Skripts aktualisiert, ohne Daten oder Geheimnisse zu verlieren
|
||||
- [ ] `shellcheck --severity=warning` über alle Skripte ohne Befund
|
||||
- [ ] Läuft hinter einem Reverse Proxy ohne fest verdrahtete Adressen
|
||||
- [x] Erneuter Aufruf des Skripts aktualisiert, ohne Daten oder Geheimnisse zu verlieren
|
||||
- [x] `shellcheck --severity=warning` über alle Skripte ohne Befund
|
||||
- [x] Läuft hinter einem Reverse Proxy ohne fest verdrahtete Adressen
|
||||
|
||||
> **Zeile 4 bleibt offen, mit Ansage:** hier stand kein Proxmox-Host zur Verfügung. Belegt ist beides
|
||||
> drumherum — `laufzettel.sh --dry-run` und `--dry-run --aktualisieren` laufen vollständig durch, und
|
||||
> der Inhalt des Containers, `deploy/install.sh`, lief zweimal in `debian:13` und einmal unter echtem
|
||||
> systemd durch, mit `ss -ltn` ohne einen einzigen lauschenden TCP-Port. Offen ist allein die
|
||||
> `pct`-Schicht. Die Gegenprobe steht in [docs/betrieb/proxmox.md](betrieb/proxmox.md) („Was du beim
|
||||
> ersten Lauf selbst nachprüfen musst“), das Folge-Issue ist
|
||||
> [#024](issues/024-proxmox-echtlauf.md). Bedingung dafür: der Einzeiler lädt von `main` — er wird
|
||||
> erst nach dem Merge prüfbar.
|
||||
|
||||
## Anmeldung und Rechte
|
||||
|
||||
- [ ] Login per lokalem Konto funktioniert
|
||||
- [ ] OIDC ist konfigurierbar und für authentik dokumentiert, inkl. Gruppen für Rollen
|
||||
- [ ] Bei `AUTH_LOCAL_ENABLED=false` ist der lokale Login weg und der Endpunkt lehnt ab
|
||||
- [ ] Rollenrechte: jede Rolle gegen jede Aktion getestet, Prüfung ausschließlich serverseitig
|
||||
- [ ] Eine Rollenänderung greift ohne neue Anmeldung; ein deaktivierter Benutzer wird abgewiesen
|
||||
- [ ] Passwörter stehen nur als argon2-Hash in der Datenbank, nie in einer API-Antwort
|
||||
- [x] Login per lokalem Konto funktioniert
|
||||
- [x] OIDC ist konfigurierbar und für authentik dokumentiert, inkl. Gruppen für Rollen
|
||||
- [x] Bei `AUTH_LOCAL_ENABLED=false` ist der lokale Login weg und der Endpunkt lehnt ab
|
||||
- [x] Rollenrechte: jede Rolle gegen jede Aktion getestet, Prüfung ausschließlich serverseitig
|
||||
- [x] Eine Rollenänderung greift ohne neue Anmeldung; ein deaktivierter Benutzer wird abgewiesen
|
||||
- [x] Passwörter stehen nur als argon2-Hash in der Datenbank, nie in einer API-Antwort
|
||||
|
||||
## Betriebsmodus
|
||||
|
||||
- [ ] Hauptablauf läuft in `INTERN` ohne jede Kundenanzeige (Ausnahme: die Einstellungsseite und der Wechsel-Dialog zeigen die eigene Organisation — dort ist sie zu pflegen)
|
||||
- [ ] Hauptablauf läuft in `DIENSTLEISTUNG` mit Kundenwahl
|
||||
- [ ] Wechsel `INTERN → DIENSTLEISTUNG` übernimmt alle Daten auf den ersten Kunden
|
||||
- [ ] Wechsel zurück wird bei zwei nicht archivierten Kunden abgelehnt
|
||||
- [ ] Im Modus `INTERN` ignoriert die Service-Schicht eine mitgeschickte `kundeId`
|
||||
- [ ] Der Modus ist ohne Neustart umschaltbar, jeder Wechsel steht im `Protokoll`
|
||||
- [x] Hauptablauf läuft in `INTERN` ohne jede Kundenanzeige (Ausnahme: die Einstellungsseite und der Wechsel-Dialog zeigen die eigene Organisation — dort ist sie zu pflegen)
|
||||
- [x] Hauptablauf läuft in `DIENSTLEISTUNG` mit Kundenwahl
|
||||
- [x] Wechsel `INTERN → DIENSTLEISTUNG` übernimmt alle Daten auf den ersten Kunden
|
||||
- [x] Wechsel zurück wird bei zwei nicht archivierten Kunden abgelehnt
|
||||
- [x] Im Modus `INTERN` ignoriert die Service-Schicht eine mitgeschickte `kundeId`
|
||||
- [x] Der Modus ist ohne Neustart umschaltbar, jeder Wechsel steht im `Protokoll`
|
||||
|
||||
## Vorlagen
|
||||
|
||||
- [ ] Veröffentlichte Version ist unveränderlich; „Bearbeiten“ erzeugt eine tiefe Kopie als Entwurf
|
||||
- [ ] Höchstens ein Entwurf je Vorlage
|
||||
- [ ] Veröffentlichen einer neuen Version verändert bestehende Läufe nicht
|
||||
- [ ] Export und anschließender Import ergeben eine inhaltsgleiche Vorlage
|
||||
- [ ] Unbekannte Platzhalter werden im Editor als Warnung angezeigt
|
||||
- [x] Veröffentlichte Version ist unveränderlich; „Bearbeiten“ erzeugt eine tiefe Kopie als Entwurf
|
||||
- [x] Höchstens ein Entwurf je Vorlage
|
||||
- [x] Veröffentlichen einer neuen Version verändert bestehende Läufe nicht
|
||||
- [x] Export und anschließender Import ergeben eine inhaltsgleiche Vorlage
|
||||
- [x] Unbekannte Platzhalter werden im Editor als Warnung angezeigt
|
||||
|
||||
## Läufe und Archiv
|
||||
|
||||
- [ ] Lauf ohne Mitarbeiter und ohne Gerät lässt sich starten, abschließen und archivieren
|
||||
- [ ] Abschließen scheitert, solange ein Pflichtschritt offen ist
|
||||
- [ ] `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz
|
||||
- [ ] „Abschnitt als nicht zutreffend markieren“ setzt alle Schritte mit einer Notiz
|
||||
- [ ] Archiv-Schreibschutz greift für Kopfdaten, Schrittergebnisse und Kommentare
|
||||
- [ ] Nur `ADMIN` holt einen archivierten Lauf zurück, mit `Protokoll`-Eintrag
|
||||
- [ ] Im Archiv wird über Titel, Notizen, Werte und Kommentare gefunden
|
||||
- [ ] Druckansicht eines Laufs ist sauber und hell
|
||||
- [x] Lauf ohne Mitarbeiter und ohne Gerät lässt sich starten, abschließen und archivieren
|
||||
- [x] Abschließen scheitert, solange ein Pflichtschritt offen ist
|
||||
- [x] `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz
|
||||
- [x] „Abschnitt als nicht zutreffend markieren“ setzt alle Schritte mit einer Notiz
|
||||
- [x] Archiv-Schreibschutz greift für Kopfdaten, Schrittergebnisse und Kommentare
|
||||
- [x] Nur `ADMIN` holt einen archivierten Lauf zurück, mit `Protokoll`-Eintrag
|
||||
- [x] Im Archiv wird über Titel, Notizen, Werte und Kommentare gefunden
|
||||
- [x] Druckansicht eines Laufs ist sauber und hell
|
||||
|
||||
## Mitarbeiter, Geräte, QR
|
||||
|
||||
- [ ] Mitarbeitersuche liefert standardmäßig nur aktive, Umschalter zeigt auch deaktivierte
|
||||
- [ ] Ein von der Geräteseite gestarteter Lauf hat den Besitzer vorbelegt
|
||||
- [ ] `/t/LZ-7K3F9Q` ohne Anmeldung führt nach dem Login auf die Geräteseite
|
||||
- [ ] „Vorlage zuweisen“ erzeugt einen `OFFEN`-Lauf, der unter „Zugewiesen“ erscheint; „Starten“ setzt `IN_ARBEIT`
|
||||
- [ ] Unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis
|
||||
- [ ] `tagCode`: richtiges Alphabet, eindeutig, unveränderlich, nie wiederverwendet
|
||||
- [ ] Etikettendruck in beiden Layouts sauber; QR maschinell zurücklesbar
|
||||
- [x] Mitarbeitersuche liefert standardmäßig nur aktive, Umschalter zeigt auch deaktivierte
|
||||
- [x] Ein von der Geräteseite gestarteter Lauf hat den Besitzer vorbelegt
|
||||
- [x] `/t/LZ-7K3F9Q` ohne Anmeldung führt nach dem Login auf die Geräteseite
|
||||
- [x] „Vorlage zuweisen“ erzeugt einen `OFFEN`-Lauf, der unter „Zugewiesen“ erscheint; „Starten“ setzt `IN_ARBEIT`
|
||||
- [x] Unregistrierter Tag → Registrierformular → Geräteseite; unbekannter Tag → Hinweis
|
||||
- [x] `tagCode`: richtiges Alphabet, eindeutig, unveränderlich, nie wiederverwendet
|
||||
- [x] Etikettendruck in beiden Layouts sauber; QR maschinell zurücklesbar
|
||||
|
||||
## Platzhalter und Befehle
|
||||
|
||||
- [ ] Onboarding: Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen
|
||||
- [ ] Abteilung „Buchhaltung“ wählen → `-Path "OU=Buchhaltung,…"` und Gruppen `G-Buchhaltung, G-Alle`
|
||||
- [ ] Kopieren liefert exakt diesen Text, zeichengenau
|
||||
- [ ] „Jörg Müller-Lüdenscheid“ ergibt gültige Vorschläge (`joerg.mueller-luedenscheid@…`, `jmuellerluedensch`)
|
||||
- [ ] `{{generiertes_passwort}}` erscheint im Befehl, steht aber nicht in Datenbank, Druckansicht oder `Protokoll`
|
||||
- [ ] Werte mit Anführungszeichen werden für die jeweilige Shell korrekt maskiert
|
||||
- [ ] „Alle Befehle als Skript kopieren“ liefert die Schritte in richtiger Reihenfolge
|
||||
- [ ] Unbekannte Platzhalter bleiben stehen; ersetzte Werte sind HTML-escaped
|
||||
- [x] Onboarding: Vorname und Nachname eingeben → UPN, `sam` und Mail werden vorgeschlagen
|
||||
- [x] Abteilung „Buchhaltung“ wählen → `-Path "OU=Buchhaltung,…"` und Gruppen `G-Buchhaltung, G-Alle`
|
||||
- [x] Kopieren liefert exakt diesen Text, zeichengenau
|
||||
- [x] „Jörg Müller-Lüdenscheid“ ergibt gültige Vorschläge (`joerg.mueller-luedenscheid@…`, `jmuellerluedensch`)
|
||||
- [x] `{{generiertes_passwort}}` erscheint im Befehl, steht aber nicht in Datenbank, Druckansicht oder `Protokoll`
|
||||
- [x] Werte mit Anführungszeichen werden für die jeweilige Shell korrekt maskiert
|
||||
- [x] „Alle Befehle als Skript kopieren“ liefert die Schritte in richtiger Reihenfolge
|
||||
- [x] Unbekannte Platzhalter bleiben stehen; ersetzte Werte sind HTML-escaped
|
||||
|
||||
## Verzeichnis (LDAP)
|
||||
|
||||
- [ ] Erster Abgleich legt Mitarbeiter an, zweiter aktualisiert sie ohne Duplikate
|
||||
- [ ] Entfernte oder deaktivierte Konten werden `aktiv = false`, nie gelöscht
|
||||
- [ ] `{{ou}}` wird korrekt aus dem `dn` abgeleitet
|
||||
- [ ] Das Bind-Passwort erscheint in keiner API-Antwort und keinem Log
|
||||
- [ ] Zwei gleichzeitige Abgleiche derselben Quelle: der zweite wird von der Sperre abgewiesen
|
||||
- [x] Erster Abgleich legt Mitarbeiter an, zweiter aktualisiert sie ohne Duplikate
|
||||
- [x] Entfernte oder deaktivierte Konten werden `aktiv = false`, nie gelöscht
|
||||
- [x] `{{ou}}` wird korrekt aus dem `dn` abgeleitet
|
||||
- [x] Das Bind-Passwort erscheint in keiner API-Antwort und keinem Log
|
||||
- [x] Zwei gleichzeitige Abgleiche derselben Quelle: der zweite wird von der Sperre abgewiesen
|
||||
|
||||
## Oberfläche
|
||||
|
||||
- [ ] Am Handy kein horizontales Scrollen — bei 390 px auf allen Hauptseiten geprüft
|
||||
- [ ] Gestaltung nach Vorgabe: dunkel, ein Akzent, keine Emojis, keine Schatten, keine Verläufe
|
||||
- [ ] Version mit Git-SHA unten links
|
||||
- [x] Am Handy kein horizontales Scrollen — bei 390 px auf allen Hauptseiten geprüft
|
||||
- [x] Gestaltung nach Vorgabe: dunkel, ein Akzent, keine Emojis, keine Schatten, keine Verläufe
|
||||
- [x] Version mit Git-SHA unten links
|
||||
|
||||
## Projektorganisation
|
||||
|
||||
- [ ] Issues für alle Phasen und MVP-Meilensteine existieren
|
||||
- [ ] `CHANGELOG.md` und `docs/devlog.md` sind gepflegt
|
||||
- [x] Issues für alle Phasen und MVP-Meilensteine existieren
|
||||
- [x] `CHANGELOG.md` und `docs/devlog.md` sind gepflegt
|
||||
- [ ] `v0.1.0` ist getaggt, Gitea-Release mit Changelog-Auszug
|
||||
- [ ] `docs/anleitung/` vollständig mit Screenshots und als PDF
|
||||
- [ ] `docs/screenshots/` enthält die Meilenstein-Bilder
|
||||
- [ ] `pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku` grün
|
||||
- [x] `docs/anleitung/` vollständig mit Screenshots und als PDF
|
||||
- [x] `docs/screenshots/` enthält die Meilenstein-Bilder
|
||||
- [x] `pnpm lint && pnpm typecheck && pnpm test && pnpm pruefe:doku` grün
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Laufzettel betreiben
|
||||
|
||||
Diese Sammlung richtet sich an den, der Laufzettel betreibt, aber nicht gebaut hat. Sie führt von
|
||||
einem leeren Server bis zur ersten Anmeldung, und danach durch Aktualisierung, Sicherung und
|
||||
Fehlersuche. Wer die Anwendung *benutzen* will, liest stattdessen die
|
||||
[Anleitung](../anleitung/README.md).
|
||||
|
||||
Es gibt zwei Wege in den Betrieb. Beide sind gleichwertig und führen zum selben Stand.
|
||||
|
||||
| Weg | Für wen | Was du mitbringen musst |
|
||||
|---|---|---|
|
||||
| **[Docker Compose](inbetriebnahme.md)** | Es gibt schon einen Docker-Host, und weitere Dienste laufen dort ebenso. Aktualisieren heißt neu bauen. | Docker mit Compose-Plugin |
|
||||
| **[Nativ im Proxmox-LXC](proxmox.md)** | Es gibt einen Proxmox-Host und keinen Docker. Ein Skript legt den Container an und installiert darin alles — PostgreSQL, Node, die Anwendung, einen systemd-Dienst. | Proxmox VE 8 oder 9, oder ein leeres Debian 13 |
|
||||
|
||||
Nimm den Compose-Weg, wenn du schwankst: er ist der Weg, gegen den hier entwickelt und getestet
|
||||
wird. Der `pct`-Teil des Proxmox-Skripts ist ungetestet — das steht so, und warum, in
|
||||
[`proxmox.md`](proxmox.md).
|
||||
|
||||
## Was auf beiden Wegen gilt
|
||||
|
||||
Vier Dinge sind keine Empfehlung, sondern Teil des Aufbaus. Sie zu übergehen heißt, einen anderen
|
||||
Stand zu betreiben als den beschriebenen.
|
||||
|
||||
**Die Datenbank ist von außen nicht erreichbar.** Im Compose-Stand hat `postgres` kein
|
||||
`ports:`-Mapping und hängt nur im Netz `intern`, das `internal: true` ist — ein Netz ohne Gateway.
|
||||
Nativ läuft PostgreSQL mit `listen_addresses = ''`, also nur am Unix-Socket. Dieselbe Regel, zwei
|
||||
Mittel. Daraus folgt, dass jede Sicherung und jeder Blick in die Daten über den Dienstnamen
|
||||
beziehungsweise den Socket läuft, nie über `psql -h localhost`. `tests/betriebsstand.test.ts` hält
|
||||
das für den Compose-Stand fest, damit ein `ports:`-Mapping nicht versehentlich zurückkommt.
|
||||
|
||||
**Die Anwendung bindet auf `127.0.0.1`** und spricht kein HTTPS. Davor gehört ein Reverse Proxy, der
|
||||
das Zertifikat hält — siehe [`reverse-proxy.md`](reverse-proxy.md). Ohne Proxy erreicht die
|
||||
Anwendung niemand außer dem Host selbst, und das ist die richtige Voreinstellung: ein Dienst, der
|
||||
aus Versehen im LAN steht, fällt keinem auf.
|
||||
|
||||
**`APP_URL` ist die öffentliche Adresse und wird nicht geraten.** Aus ihr entstehen die QR-Adressen
|
||||
auf den gedruckten Aufklebern und die OIDC-Rücksprungadresse. Sie später zu ändern ist möglich, aber
|
||||
teuer — der Grund steht am Anfang von [`reverse-proxy.md`](reverse-proxy.md). Leg die Adresse fest,
|
||||
bevor der erste Aufkleber gedruckt wird.
|
||||
|
||||
**Geheimnisse entstehen im Zielsystem.** `APP_SECRET`, das Datenbank-Passwort und das erste
|
||||
Admin-Passwort werden dort erzeugt, wo sie gelten, und stehen in keinem Repository und in keiner
|
||||
Vorlage. `APP_SECRET` gehört mit in die Sicherung: ohne ihn sind nach dem Zurückspielen alle
|
||||
Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter unlesbar. Siehe
|
||||
[`sicherung.md`](sicherung.md).
|
||||
|
||||
## Die Kapitel
|
||||
|
||||
- **[Inbetriebnahme mit Docker Compose](inbetriebnahme.md)** — von null bis zur ersten Anmeldung,
|
||||
mit der vollständigen Tabelle der Umgebungsvariablen, dem Seed und der Aktualisierung
|
||||
- **[Der native Weg im Proxmox-LXC](proxmox.md)** — `deploy/proxmox/laufzettel.sh` und
|
||||
`deploy/install.sh`, was sie anlegen, wie ein zweiter Aufruf aktualisiert, und die Prüfliste für
|
||||
den ersten echten Lauf
|
||||
- **[Reverse Proxy](reverse-proxy.md)** — Caddy und nginx als vollständiges Beispiel, TLS,
|
||||
Weiterleitungen, welche Kopfzeilen nötig sind und welche schaden
|
||||
- **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang
|
||||
- **[Sichern und Zurückspielen](sicherung.md)** — auf beiden Wegen, was in die Sicherung gehört und
|
||||
was nicht, und wie du prüfst, dass das Zurückspielen wirklich funktioniert
|
||||
|
||||
## Wo sonst noch etwas steht
|
||||
|
||||
- [`../../README.md`](../../README.md) — Kurzfassung für Entwickler, Betriebsmodi, Seed-Inhalt
|
||||
- [`../../.env.example`](../../.env.example) — jede Umgebungsvariable mit Kommentar, zum Kopieren
|
||||
- [`../architektur.md`](../architektur.md) — warum es so gebaut ist; Abschnitt 6 fasst die
|
||||
Betriebsregel zusammen
|
||||
- [`../anleitung/08-fehlerbehebung.md`](../anleitung/08-fehlerbehebung.md) — Fehlermeldungen der
|
||||
Oberfläche. Wer eine Meldung im Browser sieht, ist dort besser bedient als hier
|
||||
@@ -0,0 +1,260 @@
|
||||
# Inbetriebnahme mit Docker Compose
|
||||
|
||||
Von einem Host mit Docker bis zur ersten Anmeldung. Rechne mit zwanzig Minuten, davon zehn für den
|
||||
ersten Bau des Abbilds.
|
||||
|
||||
Der andere Weg — nativ in einem Proxmox-LXC, ohne Docker — steht in [`proxmox.md`](proxmox.md).
|
||||
|
||||
## 1. Voraussetzungen
|
||||
|
||||
- Docker mit dem Compose-Plugin (`docker compose version` muss antworten, nicht `docker-compose`)
|
||||
- 2 GB RAM und 8 GB Platte reichen; der erste Bau braucht kurzzeitig mehr
|
||||
- `git`, `openssl`
|
||||
- ein Reverse Proxy für TLS — auf demselben Host oder davor, siehe
|
||||
[`reverse-proxy.md`](reverse-proxy.md)
|
||||
- ein DNS-Name, der auf den Proxy zeigt, **bevor** du anfängst: er wird zu `APP_URL`
|
||||
|
||||
Node und pnpm brauchst du **nicht** auf dem Host. Beides steckt im Abbild.
|
||||
|
||||
## 2. Quellcode holen
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/laufzettel
|
||||
sudo chown "$USER" /opt/laufzettel
|
||||
git clone https://git.wilde-server.de/florian.wilde/Laufzettel.git /opt/laufzettel
|
||||
cd /opt/laufzettel
|
||||
```
|
||||
|
||||
Das Verzeichnis ist frei wählbar; alle folgenden Befehle laufen darin, weil `docker compose` die
|
||||
`docker-compose.yml` und die `.env` im aktuellen Verzeichnis sucht.
|
||||
|
||||
## 3. Die `.env` anlegen
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
openssl rand -base64 48 # das wird APP_SECRET
|
||||
openssl rand -base64 24 # das wird POSTGRES_PASSWORD
|
||||
```
|
||||
|
||||
Dann `.env` öffnen und mindestens diese fünf Werte setzen:
|
||||
|
||||
```dotenv
|
||||
APP_URL=https://laufzettel.firma.de
|
||||
APP_SECRET=<Ausgabe von openssl rand -base64 48>
|
||||
POSTGRES_PASSWORD=<Ausgabe von openssl rand -base64 24>
|
||||
SEED_ADMIN_EMAIL=admin@firma.de
|
||||
SEED_ADMIN_PASSWORD=<ein Passwort, das du gleich brauchst>
|
||||
```
|
||||
|
||||
Und für die erste Anmeldung, bis OIDC steht:
|
||||
|
||||
```dotenv
|
||||
AUTH_LOCAL_ENABLED=true
|
||||
```
|
||||
|
||||
> **`APP_URL` ist die öffentliche Adresse, nicht die interne.** Hinter einem Reverse Proxy ist es
|
||||
> dessen Adresse mit `https://`, nicht `http://127.0.0.1:3000`. Aus ihr entstehen die QR-Adressen auf
|
||||
> den gedruckten Aufklebern; sie später zu ändern kostet einen Stapel neuer Aufkleber. Der Grund
|
||||
> steht ausführlich in [`reverse-proxy.md`](reverse-proxy.md).
|
||||
|
||||
> **Fehlen `APP_URL`, `APP_SECRET` oder `POSTGRES_PASSWORD`, startet der Stand gar nicht.** Das ist
|
||||
> Absicht: `docker compose` bricht mit `APP_SECRET fehlt in der .env` ab, statt mit einem
|
||||
> Standardwert zu laufen, den jeder kennt.
|
||||
|
||||
**`DATABASE_URL` lässt du für den Compose-Stand in Ruhe.** Die Adresse zur Datenbank baut sich im
|
||||
Container immer aus `POSTGRES_USER`, `POSTGRES_PASSWORD` und `POSTGRES_DB` zusammen, mit dem
|
||||
Dienstnamen `postgres` als Host. Der Wert in der `.env` gilt nur für Werkzeuge, die *außerhalb* der
|
||||
Container laufen — `pnpm dev`, `pnpm prisma …` von einer Entwicklungsmaschine aus. Bis zur Abnahme
|
||||
von #019 las der Compose-Stand ihn mit, und wer dem dokumentierten Weg folgte, bekam einen Stand, der
|
||||
nicht startet: in der `.env.example` steht dort `localhost`, und im Container zeigt das ins Leere.
|
||||
|
||||
### Alle Umgebungsvariablen
|
||||
|
||||
Jede steht mit Kommentar in [`../../.env.example`](../../.env.example). **Pflicht** sind `APP_URL`,
|
||||
`APP_SECRET` und `POSTGRES_PASSWORD`.
|
||||
|
||||
| Variable | Bedeutung |
|
||||
|---|---|
|
||||
| `APP_URL` | Die **öffentliche** Adresse der Anwendung, mit Schema und ohne Schrägstrich am Ende. Steckt in den QR-Codes der Aufkleber und in der OIDC-Rücksprungadresse. Entscheidet außerdem, ob das Sitzungs-Cookie das `Secure`-Flag bekommt (bei `https://` ja). |
|
||||
| `APP_SECRET` | Schlüssel für die Sitzungs-Cookies und für die Verschlüsselung des LDAP-Bind-Passworts. Mindestens 32 Zeichen, erzeugen mit `openssl rand -base64 48`. **Ändert man ihn, sind alle Sitzungen ungültig und gespeicherte Bind-Passwörter unlesbar.** Gehört in die Sicherung. |
|
||||
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | Zugangsdaten der Datenbank im Container. Nach dem ersten Start liegen sie im Volume; sie nachträglich in der `.env` zu ändern ändert die Datenbank **nicht** und macht den Stand nur unerreichbar. |
|
||||
| `DATABASE_URL` | Nur für Werkzeuge außerhalb der Container. Der Compose-Stand baut seine Adresse selbst; siehe oben. |
|
||||
| `BIND_ADDR`, `APP_PORT` | Wohin der Webdienst auf dem Host gebunden wird. Standard `127.0.0.1:3000` — davor gehört ein Reverse Proxy. Nur wenn der Proxy auf einem **anderen** Rechner läuft, gehört hier die LAN-Adresse des Hosts hin, nie `0.0.0.0` ohne Firewall. |
|
||||
| `AUTH_LOCAL_ENABLED` | `true` schaltet die Anmeldung mit E-Mail und Passwort frei. Für die erste Anmeldung und als Notfallzugang, wenn der Identitätsanbieter ausfällt. Auf `false` verschwindet das Formular **und** der Endpunkt lehnt ab. |
|
||||
| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | Der Identitätsanbieter. Leer heißt: kein OIDC. Discovery über `<issuer>/.well-known/openid-configuration`. |
|
||||
| `OIDC_NAME` | Beschriftung des Anmeldeknopfs, etwa „Firmenanmeldung“. |
|
||||
| `OIDC_SCOPE` | Angefragte Bereiche. Der Standard `openid profile email groups` passt zu [`authentik.md`](authentik.md); nur ändern, wenn der Anbieter die Gruppen unter einem anderen Bereich liefert. |
|
||||
| `OIDC_GROUP_ADMIN`, `OIDC_GROUP_EDITOR`, `OIDC_GROUP_TECHNICIAN`, `OIDC_GROUP_VIEWER` | Welche Gruppe welche Rolle bekommt. Die Rolle wird bei **jeder** Anmeldung neu gesetzt; eine hier in der Oberfläche geänderte Rolle überschreibt der nächste Login. |
|
||||
| `OIDC_CUSTOMER_GROUP_PREFIX` | Nur im Modus `DIENSTLEISTUNG`: Gruppen mit diesem Präfix (etwa `lz-kunde-MUS`) binden einen Benutzer an einen Kunden. Leer heißt: keine Einschränkung. |
|
||||
| `ZEITPLAN_AKTIV` | `false` schaltet den Verzeichnis-Abgleich nach Zeitplan ab. Der Zeitplan läuft im Prozess der Anwendung und sieht jede Minute nach, welche Quelle fällig ist. |
|
||||
| `APP_VERSION`, `GIT_SHA` | Erscheinen unten links in der Oberfläche. Im Compose-Stand sind es Build-Argumente — sie wirken erst beim nächsten `--build`. |
|
||||
| `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD` | Der Administrator, den der Seed anlegt. Das Passwort wird nur als argon2-Hash gespeichert. Fehlt eines, überspringt der Seed ihn — und dann kommst du nicht hinein. |
|
||||
| `SEED_MODE` | `INTERN` (Standard) oder `DIENSTLEISTUNG`, `SERVICE` als Alias. Betrifft nur den **ersten** Seed-Lauf; danach steht der Modus in der Datenbank und wird in der Oberfläche umgeschaltet. |
|
||||
| `SEED_ORG_NAME`, `SEED_ORG_KUERZEL` | Name und Kürzel der eigenen Organisation. Das Kürzel (2 bis 6 Großbuchstaben oder Ziffern) steht auf Etiketten und in der Hostname-Regel. |
|
||||
| `TEST_DATABASE_URL` | Nur für die Tests. Im Betrieb ohne Bedeutung. |
|
||||
|
||||
## 4. Starten
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Was dabei passiert, in dieser Reihenfolge:
|
||||
|
||||
1. `postgres` startet und wird über `pg_isready` als gesund gemeldet
|
||||
2. `migrate` läuft **einmal** und spielt `prisma migrate deploy` ein, dann beendet es sich
|
||||
3. `web` startet, sobald `migrate` sauber durch ist
|
||||
|
||||
Ein beendeter `migrate`-Container ist also kein Fehler, sondern das erwartete Ergebnis. Nachsehen:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f web
|
||||
```
|
||||
|
||||
## 5. Den Seed einmal von Hand fahren
|
||||
|
||||
**Der Seed läuft absichtlich nicht automatisch.** Ein Einmal-Dienst, der bei jedem Start Daten
|
||||
anlegt, ist ein Dienst, der irgendwann Daten anlegt, die niemand wollte. Du startest ihn selbst,
|
||||
genau einmal:
|
||||
|
||||
```bash
|
||||
docker compose run --rm migrate pnpm db:seed
|
||||
```
|
||||
|
||||
Der Seed legt an:
|
||||
|
||||
| | Inhalt |
|
||||
|---|---|
|
||||
| Grundbestand | Betriebsmodus, eigene Organisation, Administrator aus `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORD` |
|
||||
| Vorlagen | die sieben Abläufe als **veröffentlichte Version 1** — PC-Neuinstallation, Onboarding, Offboarding, Server-Inbetriebnahme, Firewall-Rollout, Patchday, Restore-Test |
|
||||
| Beispielbestand | 3 Abteilungen, 5 Personen, `PC-001` und `NB-001` mit Tag-Code, 5 vorgedruckte Tags, Schnellstart-Vorgaben je Gerätetyp |
|
||||
| Verzeichnis | eine **deaktivierte** Beispielquelle zum Abschauen — Adresse und Bind-Passwort ersetzen, dann einschalten |
|
||||
|
||||
**Er richtet ein, er räumt nicht auf.** Ein zweiter Lauf ändert nichts: eine bearbeitete Vorlage,
|
||||
eine geänderte Ziel-OU oder ein umgeschalteter Betriebsmodus überstehen ihn. Du kannst ihn also
|
||||
gefahrlos wiederholen, wenn du nicht sicher bist, ob er durchgelaufen ist.
|
||||
|
||||
Mit `SEED_MODE=DIENSTLEISTUNG` kommen zwei Demo-Kunden dazu. Willst du ohne Beispieldaten starten,
|
||||
setz `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORD` und lösch die Beispiele hinterher in der
|
||||
Oberfläche — die Vorlagen lässt du besser stehen, sie sind der eigentliche Wert.
|
||||
|
||||
## 6. Erste Anmeldung
|
||||
|
||||
Erst prüfen, ob die Anwendung überhaupt antwortet — vom Host, denn nach außen bindet sie nicht:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||
# {"status":"ok"}
|
||||
```
|
||||
|
||||
Antwortet das mit `{"status":"fehler",…}` und Status 503, erreicht die Anwendung die Datenbank nicht.
|
||||
Kommt gar nichts, läuft `web` nicht — `docker compose logs web`.
|
||||
|
||||
Dann den Reverse Proxy einrichten ([`reverse-proxy.md`](reverse-proxy.md)), `https://laufzettel.firma.de`
|
||||
öffnen und mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORD` anmelden.
|
||||
|
||||
Danach der Reihe nach:
|
||||
|
||||
1. **Admin → Einstellungen** — Betriebsmodus prüfen, Organisationsname und Kürzel
|
||||
2. **[authentik anbinden](authentik.md)** und `AUTH_LOCAL_ENABLED` wieder auf `false` setzen — bis
|
||||
auf ein einziges lokales Admin-Konto mit Passwort im Passwortmanager, für den Fall, dass der
|
||||
Anbieter ausfällt
|
||||
3. **Sicherung einrichten**, bevor echte Daten drin sind: [`sicherung.md`](sicherung.md)
|
||||
|
||||
Wie es weitergeht, steht in der [Anleitung](../anleitung/README.md).
|
||||
|
||||
## Warum die Datenbank zu ist, und was daraus folgt
|
||||
|
||||
Der Aufbau ist bewusst so und in `tests/betriebsstand.test.ts` festgehalten:
|
||||
|
||||
- `postgres` hat **kein** `ports:`-Mapping, nur `expose` — auf dem Host gibt es keinen Port 5432
|
||||
- `postgres` hängt ausschließlich im Netz `intern`, das `internal: true` ist: ein Netz ohne Gateway.
|
||||
Weder Host noch LAN kommen hinein, und die Datenbank kommt auch nicht heraus
|
||||
- nur `web` hängt zusätzlich im Netz `extern`, weil es den Reverse Proxy, den Identitätsanbieter und
|
||||
das LDAP-Verzeichnis erreichen muss
|
||||
- `web` bindet auf `127.0.0.1:3000`
|
||||
|
||||
Ein Datenbankport, den es nicht gibt, muss nicht mit einem Passwort verteidigt werden. Das ist die
|
||||
ganze Begründung.
|
||||
|
||||
Daraus folgt dreierlei, und das ist der Teil, der im Alltag zählt:
|
||||
|
||||
**Kein `psql` vom Host.** `psql -h localhost -p 5432` findet nichts. Wer in die Daten sehen will,
|
||||
geht durch den Container:
|
||||
|
||||
```bash
|
||||
docker compose exec postgres psql -U laufzettel laufzettel
|
||||
```
|
||||
|
||||
**Die Sicherung läuft über den Dienstnamen**, nicht über einen Port. Der vollständige Weg steht in
|
||||
[`sicherung.md`](sicherung.md):
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres pg_dump -U laufzettel laufzettel | gzip > sicherung-$(date +%F).sql.gz
|
||||
```
|
||||
|
||||
**Externe Werkzeuge kommen nicht daran** — kein pgAdmin vom Arbeitsplatz, kein
|
||||
Monitoring-Agent per TCP. Wer das braucht, öffnet den Port nicht, sondern legt einen SSH-Tunnel auf
|
||||
den Host und von dort `docker compose exec`. Ein `ports:`-Mapping in die `docker-compose.yml` zu
|
||||
schreiben lässt den Test rot werden; das ist sein Zweck.
|
||||
|
||||
Prüfen lässt sich beides in zwei Zeilen:
|
||||
|
||||
```bash
|
||||
nc -z localhost 5432 && echo "FEHLER: offen" || echo "dicht"
|
||||
docker compose exec web nc -z postgres 5432 && echo "von innen erreichbar"
|
||||
```
|
||||
|
||||
**`docker-compose.dev.yml` ist nicht für den Betrieb.** Diese Datei öffnet PostgreSQL auf
|
||||
`127.0.0.1:5434` und benutzt das Passwort `laufzettel`. Sie ist für die Entwicklung da und hat auf
|
||||
einem Server nichts zu suchen.
|
||||
|
||||
## Aktualisieren
|
||||
|
||||
```bash
|
||||
cd /opt/laufzettel
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Die Migrationen laufen dabei von selbst: `migrate` startet vor `web` und läuft durch, bevor die
|
||||
Anwendung hochkommt. Der Seed läuft **nicht** mit — richtig so, er würde nichts ändern, aber er
|
||||
gehört nicht in eine Aktualisierung.
|
||||
|
||||
Vor dem Aktualisieren eine Sicherung ziehen. Eine Migration ist nicht rückwärts fahrbar.
|
||||
|
||||
Zwischen zwei Versionen lohnt ein Blick in [`../../CHANGELOG.md`](../../CHANGELOG.md).
|
||||
|
||||
## Anhalten, neu starten, wegräumen
|
||||
|
||||
```bash
|
||||
docker compose restart web # nur die Anwendung neu starten, etwa nach .env-Änderungen
|
||||
docker compose down # alles anhalten, Daten bleiben im Volume
|
||||
docker compose down -v # löscht das Volume und damit die Datenbank
|
||||
```
|
||||
|
||||
> **`down -v` löscht die Datenbank.** Das Volume heißt `laufzettel_datenbank`, und mit ihm gehen alle
|
||||
> Läufe, alle Vorlagen und alle Tag-Codes. Ein vergebener Tag-Code wird nie wiederverwendet — nach
|
||||
> einem `down -v` ohne Sicherung sind alle gedruckten Aufkleber wertlos, weil ihre Codes in der
|
||||
> neuen Datenbank nicht existieren.
|
||||
|
||||
Eine Änderung an der `.env` greift erst nach `docker compose up -d`; `restart` allein liest die
|
||||
Datei nicht neu. Änderungen an `APP_VERSION` oder `GIT_SHA` sind Build-Argumente und brauchen
|
||||
`--build`.
|
||||
|
||||
## Wenn es klemmt
|
||||
|
||||
| Symptom | Ursache und was zu tun ist |
|
||||
|---|---|
|
||||
| `APP_SECRET fehlt in der .env` beim `up` | Genau das. Der Stand bricht absichtlich ab, statt mit einem Standardwert zu starten. Gilt ebenso für `APP_URL` und `POSTGRES_PASSWORD`. |
|
||||
| `web` startet nicht, `migrate` steht auf `exited (1)` | `docker compose logs migrate`. Meist erreicht die Migration die Datenbank nicht — oder du hast `POSTGRES_PASSWORD` nach dem ersten Start geändert. Das Passwort steckt dann noch im Volume. |
|
||||
| `/api/gesundheit` antwortet mit 503 | Die Anwendung läuft, die Datenbank antwortet nicht. `docker compose ps` — ist `postgres` gesund? |
|
||||
| Der Seed bricht mittendrin ab | Bis zur Abnahme von #019 fehlte dem Einmal-Dienst `APP_SECRET`, und das Bind-Passwort der Beispiel-Verzeichnisquelle ließ sich nicht verschlüsseln — abgebrochen wurde erst, nachdem die Vorlagen schon standen. Steht dein `docker-compose.yml` auf dem aktuellen Stand, ist das behoben; ein erneuter Lauf holt den Rest nach. |
|
||||
| Nach dem Login wieder auf der Anmeldeseite | Das Cookie ist `Secure`, die Verbindung aber `http`. `APP_URL` auf `https://…` setzen und HTTPS über den Proxy anbieten. |
|
||||
| Die QR-Codes zeigen auf `localhost:3000` | `APP_URL` ist nicht oder falsch gesetzt. Die Oberfläche warnt unter **Admin** und in der Etikettenansicht davor. Aufkleber, die so gedruckt wurden, sind wertlos — bevor du weiterdruckst, `APP_URL` richtigstellen und `docker compose up -d`. |
|
||||
| Jeder Benutzer bekommt `LESER` | Der Claim `groups` fehlt im Token. Siehe [`authentik.md`](authentik.md), Abschnitt 8. |
|
||||
| Keine Knöpfe tun etwas, die Seite sieht aber richtig aus | Du siehst einen Entwicklungsstand, der nicht hydriert. Im Compose-Stand kann das nicht passieren — dort läuft immer der gebaute Stand. Prüf, ob du tatsächlich den Compose-Stand vor dir hast und nicht ein `pnpm dev` auf demselben Host. |
|
||||
|
||||
Fehlermeldungen, die in der Oberfläche erscheinen, stehen in
|
||||
[`../anleitung/08-fehlerbehebung.md`](../anleitung/08-fehlerbehebung.md).
|
||||
@@ -0,0 +1,286 @@
|
||||
# Der native Weg: Proxmox-LXC und Debian 13
|
||||
|
||||
Ohne Docker. Ein Skript legt auf dem Proxmox-Host einen unprivilegierten Debian-13-Container an und
|
||||
installiert darin alles: PostgreSQL 17 nur am Unix-Socket, Node 24, die Anwendung als
|
||||
systemd-Dienst. Wer schon ein leeres Debian 13 hat — als LXC, als virtuelle Maschine oder auf Blech —
|
||||
überspringt den ersten Teil und ruft `deploy/install.sh` direkt auf.
|
||||
|
||||
Der andere Weg, mit Docker Compose, steht in [`inbetriebnahme.md`](inbetriebnahme.md).
|
||||
|
||||
> **Der Proxmox-Teil ist nicht automatisch geprüft.** Für diese Arbeit stand kein PVE-Host zur
|
||||
> Verfügung; alles, was `pct` anfasst, ist geschrieben und gelesen, aber nie gelaufen.
|
||||
> `deploy/install.sh` dagegen ist geprüft — es installiert in einem `debian:13`-Abbild vollständig
|
||||
> durch und bringt mit `--test` seine eigene Selbstprüfung mit. Was du beim ersten echten Lauf
|
||||
> nachsehen musst, steht am Ende dieser Seite in einer Prüfliste. Das ist keine Floskel: geh sie
|
||||
> durch.
|
||||
|
||||
## Was am Ende auf dem System steht
|
||||
|
||||
| Ort | Was |
|
||||
|---|---|
|
||||
| `/opt/laufzettel` | Der Quellcode als Git-Arbeitsverzeichnis, Eigentümer `laufzettel`. Aktualisiert wird mit `git pull`, deshalb ein Checkout und kein Tarball. |
|
||||
| `/opt/laufzettel/.next/standalone` | Der gebaute Server. Von hier startet `node server.js`. |
|
||||
| `/etc/laufzettel/laufzettel.env` | **Die Geheimnisse dieser Installation**: `APP_SECRET`, `DATABASE_URL` mit Passwort, `APP_URL`. Modus 0600, Eigentümer root. Das Skript überschreibt diese Datei nie — auch nicht beim Aktualisieren. |
|
||||
| `/etc/systemd/system/laufzettel.service` | Der Dienst. Läuft als `laufzettel`, mit `NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` und `Restart=on-failure`. |
|
||||
| `/var/cache/laufzettel` | Heimatverzeichnis des Dienstnutzers — nur der Zwischenspeicher von pnpm und corepack, kein Arbeitskonto. |
|
||||
| `/etc/postgresql/17/main/conf.d/laufzettel.conf` | Eine Zeile: `listen_addresses = ''`. |
|
||||
| Datenbank `laufzettel` | Rolle `laufzettel` mit erzeugtem Passwort, erreichbar nur über den Socket `/run/postgresql`. |
|
||||
|
||||
Der Dienstnutzer `laufzettel` ist ein Systemkonto mit `/usr/sbin/nologin` als Shell und ohne
|
||||
Heimatverzeichnis unter `/home`. Anmelden kann sich damit niemand.
|
||||
|
||||
## Weg A: der One-Liner auf dem Proxmox-Host
|
||||
|
||||
In der Shell des PVE-Hosts, als root:
|
||||
|
||||
```bash
|
||||
bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/Laufzettel/raw/branch/main/deploy/proxmox/laufzettel.sh)"
|
||||
```
|
||||
|
||||
Das Skript prüft zuerst, ob es auf einem PVE 8 oder 9 als root läuft, holt bei Bedarf die
|
||||
Debian-13-Vorlage über `pveam`, und fragt dann per Dialog: **Standard** oder **Erweitert**.
|
||||
|
||||
**Standard** nimmt diese Werte, ohne zu fragen:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| CTID | die nächste freie |
|
||||
| Hostname | `laufzettel` |
|
||||
| Vorlage | Debian 13 |
|
||||
| Kerne | 2 |
|
||||
| RAM | 2 GB |
|
||||
| Platte | 8 GB |
|
||||
| Swap | 512 MB |
|
||||
| Privilegien | unprivilegiert, mit `nesting=1` — die systemd-Unit braucht es |
|
||||
| Netz | DHCP auf `vmbr0` |
|
||||
|
||||
Das ist auf einen kleinen Betrieb zugeschnitten und reicht für einige Tausend Läufe. Der erste Bau
|
||||
braucht kurzzeitig mehr RAM als der Betrieb — wenn `pnpm build` mit `Killed` abbricht, ist das die
|
||||
Ursache; dann im Erweitert-Dialog 4 GB geben und den Wert danach zurückdrehen.
|
||||
|
||||
**Erweitert** fragt dieselben Werte einzeln ab, dazu eine feste IP statt DHCP, die Bridge und die
|
||||
beiden Speicher getrennt: einen für das Wurzeldateisystem des Containers (Inhalt `rootdir`) und
|
||||
einen für die Debian-Vorlage (Inhalt `vztmpl`). Vorbelegt ist jeweils der erste aktive Speicher, der
|
||||
den Inhalt aufnehmen kann; findet das Skript keinen, bricht es mit dieser Begründung ab.
|
||||
|
||||
Ein **zweiter Aufruf** erkennt einen vorhandenen Container am Hostnamen und bietet an, ihn zu
|
||||
aktualisieren, statt einen zweiten anzulegen.
|
||||
|
||||
Das Skript installiert selbst nichts. Es legt den Container an, holt `deploy/install.sh` hinein und
|
||||
ruft es dort auf — damit der Weg mit und ohne Proxmox nicht auseinanderläuft.
|
||||
|
||||
| Schalter | Bedeutung |
|
||||
|---|---|
|
||||
| `--dry-run` (auch `--trockenlauf`) | Gibt jeden `pct`-, `pveam`- und `pvesh`-Aufruf nur aus. Legt nichts an, lädt nichts, und überspringt auch die Prüfung auf root und PVE — läuft also auf jedem Rechner. |
|
||||
| `--standard` | Ohne Dialog, alle Standardwerte nehmen. |
|
||||
| `--erweitert` | Direkt in den Erweitert-Dialog. |
|
||||
| `--aktualisieren` | Nur aktualisieren, ohne Rückfrage. Bricht ab, wenn es keinen Container mit dem Hostnamen `laufzettel` gibt — es wird also keiner angelegt. Gedacht für den zweiten und jeden weiteren Aufruf, etwa aus einem Wartungsfenster heraus. |
|
||||
| `--zweig <name>` | Git-Zweig, aus dem `install.sh` geholt wird. Standard `main`. |
|
||||
| `--hilfe` | Alle Schalter. |
|
||||
|
||||
### Erst einmal nur hinsehen
|
||||
|
||||
```bash
|
||||
bash laufzettel.sh --dry-run
|
||||
```
|
||||
|
||||
Nimm das beim ersten Mal: der ausgegebene `pct create` lässt sich lesen, prüfen und notfalls von Hand
|
||||
abschicken. Der Trockenlauf braucht keinen PVE-Host und läuft bis zum Abschlussbildschirm durch.
|
||||
|
||||
## Weg B: `install.sh` in einem vorhandenen Debian 13
|
||||
|
||||
Ob das Debian in einem LXC steckt, in einer VM oder auf Blech, ist dem Skript gleich. Es braucht
|
||||
root, `apt` und Internet.
|
||||
|
||||
```bash
|
||||
apt install -y git
|
||||
git clone https://git.wilde-server.de/florian.wilde/Laufzettel.git /tmp/laufzettel-quelle
|
||||
sudo bash /tmp/laufzettel-quelle/deploy/install.sh --url https://laufzettel.firma.de --admin-mail admin@firma.de
|
||||
```
|
||||
|
||||
Die Schalter:
|
||||
|
||||
| Schalter | Bedeutung |
|
||||
|---|---|
|
||||
| `--url <adresse>` | Die **öffentliche** Adresse, also `APP_URL`. Ohne Angabe nimmt das Skript `http://<erste IP des Systems>:<port>` — das läuft, aber die QR-Codes auf den Aufklebern tragen dann diese Adresse. Setz sie richtig, bevor du druckst. |
|
||||
| `--admin-mail <adresse>` | Kennung des ersten Administrators. Standard `admin@firma.example`. |
|
||||
| `--port <nummer>` | Port des Webdienstes. Standard 3000. |
|
||||
| `--bind <adresse>` | Wohin gebunden wird. Standard `127.0.0.1` — davor gehört ein Reverse Proxy. Läuft der Proxy auf einem anderen Rechner, gehört hier die IP des Containers hin, dazu eine Firewall-Regel. |
|
||||
| `--modus <wert>` | `INTERN` (Standard) oder `DIENSTLEISTUNG`. |
|
||||
| `--zweig <name>`, `--quelle <url\|pfad>` | Anderer Zweig oder andere Git-Quelle. Für Testläufe nimmt `--quelle` auch einen lokalen Pfad, wenn dort ein Git-Arbeitsverzeichnis liegt. |
|
||||
| `--passwort-datei <pfad>` | Legt das beim ersten Lauf erzeugte Administrator-Passwort **zusätzlich** in diese Datei (0600, `root`), in den Zeilen `SEED_ADMIN_EMAIL=` und `SEED_ADMIN_PASSWORD=`. Gedacht für Aufrufer, die die Ausgabe nicht mitlesen können — genau so holt sich der Proxmox-Weg das Passwort für seinen Abschlussbildschirm. **Wer den Schalter benutzt, löscht die Datei nach dem Ablesen**, sonst liegt das Passwort dauerhaft im Klartext im System. |
|
||||
| `--test` | Nach der Installation eine Selbstprüfung fahren, siehe unten. |
|
||||
| `--hilfe` | Alle Schalter. |
|
||||
|
||||
Dieselben Werte nimmt das Skript auch aus der **Umgebung** — für Aufrufer ohne Befehlszeile, also
|
||||
`pct exec` im Proxmox-Weg. Ein Schalter schlägt die Umgebung. Die Namen sind dieselben wie in
|
||||
`.env.example`, damit es nur eine Schreibweise gibt: `APP_URL`, `BIND_ADDR`, `APP_PORT`,
|
||||
`SEED_ADMIN_EMAIL`, `SEED_MODE`, dazu `LAUFZETTEL_REPO` (wie `--quelle`), `LAUFZETTEL_ZWEIG` (wie
|
||||
`--zweig`) und `LAUFZETTEL_PASSWORT_DATEI` (wie `--passwort-datei`).
|
||||
|
||||
> **`--url`, `--port`, `--bind`, `--admin-mail` und `--modus` wirken nur beim ersten Lauf.** Danach
|
||||
> steht die Wahrheit in `/etc/laufzettel/laufzettel.env`, und diese Datei wird nie überschrieben. Ein
|
||||
> zweiter Aufruf mit anderen Werten warnt und ignoriert sie. Zum Ändern: die Datei bearbeiten und
|
||||
> `systemctl restart laufzettel`.
|
||||
|
||||
Das Skript läuft in neun Schritten und sagt bei jedem, was es tut. Rechne mit fünf bis fünfzehn
|
||||
Minuten, das meiste davon `pnpm install` und `pnpm build`.
|
||||
|
||||
## Der Abschlussbildschirm
|
||||
|
||||
Auf Weg A siehst du **zwei** Abschlüsse: erst den von `install.sh` aus dem Container, dann den des
|
||||
Proxmox-Skripts, das noch einmal Adresse, Kennung und Passwort zeigt und sagt, wie du in den
|
||||
Container kommst (`pct enter <ctid>`).
|
||||
|
||||
**Das erzeugte Admin-Passwort steht in `laufzettel.env` absichtlich nicht** — dort liegen nur
|
||||
`APP_SECRET` und das Datenbankpasswort. Damit der Abschlussbildschirm es trotzdem zeigen kann,
|
||||
obwohl `install.sh` hinter `pct exec` läuft, gibt das Skript ihm `LAUFZETTEL_PASSWORT_DATEI` mit:
|
||||
`install.sh` legt das Passwort einmalig in `/root/laufzettel-erstpasswort` (0600, `root`), der
|
||||
Aufrufer liest es von dort und **löscht die Datei sofort wieder**, gelesen oder nicht. Beim zweiten
|
||||
Aufruf — beim Aktualisieren — entsteht kein neues Passwort, und der Abschluss zeigt an dieser Stelle
|
||||
„unverändert (das Konto bestand schon)“. Das ist der Normalfall, kein Fehler.
|
||||
|
||||
Die Kennung dagegen wird aus `laufzettel.env` gelesen; steht dort nichts, zeigt der Abschluss den
|
||||
Verweis auf die Datei statt einer Adresse.
|
||||
|
||||
Der Abschluss von `install.sh` sieht so aus:
|
||||
|
||||
```
|
||||
Laufzettel ist eingerichtet.
|
||||
|
||||
Adresse https://laufzettel.firma.de
|
||||
Dienst horcht 127.0.0.1:3000
|
||||
Administrator admin@firma.de
|
||||
Passwort xY7k2mQ4pL9wR3tN8vZa
|
||||
|
||||
Dieses Passwort wird genau hier einmal angezeigt. In der Datenbank steht
|
||||
nur der argon2-Hash, in /etc/laufzettel/laufzettel.env steht es nicht.
|
||||
```
|
||||
|
||||
> **Schreib das Passwort auf, bevor du das Fenster schließt.** Ohne `--passwort-datei` steht es in
|
||||
> keiner Datei — weder in `laufzettel.env` noch im Journal — und es wird nie ein zweites Mal
|
||||
> angezeigt. Ist es weg, hilft nur ein neues über `psql` beziehungsweise, wenn OIDC schon läuft, der
|
||||
> Weg über den Identitätsanbieter.
|
||||
|
||||
Bestand das Admin-Konto schon — also beim zweiten Lauf — erzeugt das Skript kein neues Passwort und
|
||||
schreibt an dieser Stelle `Passwort unveraendert (Konto bestand schon)`. Das ist der
|
||||
Normalfall beim Aktualisieren.
|
||||
|
||||
Der lokale Login ist auf diesem Weg von Anfang an eingeschaltet
|
||||
(`AUTH_LOCAL_ENABLED="true"` in der Umgebungsdatei), denn sonst käme niemand hinein. Sobald OIDC
|
||||
steht, setz ihn auf `false` — bis auf das eine Notfallkonto, siehe
|
||||
[`authentik.md`](authentik.md), Abschnitt 7.
|
||||
|
||||
## Was danach zu tun ist
|
||||
|
||||
1. **Reverse Proxy davorstellen.** Der Dienst bindet auf `127.0.0.1` und spricht kein HTTPS; ohne
|
||||
Proxy erreicht ihn nur der Container selbst. Siehe [`reverse-proxy.md`](reverse-proxy.md).
|
||||
2. **Anmelden** und unter **Admin → Einstellungen** Organisationsname und Kürzel setzen. Das Skript
|
||||
schreibt `SEED_ORG_NAME="Eigene Organisation"` und `SEED_ORG_KUERZEL="EIGEN"` — das Kürzel steht
|
||||
später auf Etiketten, es lohnt sich, es früh richtigzustellen.
|
||||
3. **[authentik anbinden](authentik.md)**: die `OIDC_*`-Zeilen in
|
||||
`/etc/laufzettel/laufzettel.env` sind auskommentiert vorbereitet. Ausfüllen,
|
||||
`systemctl restart laufzettel`.
|
||||
4. **Sicherung einrichten**, bevor echte Daten drin sind: [`sicherung.md`](sicherung.md).
|
||||
|
||||
## Den Dienst bedienen
|
||||
|
||||
```bash
|
||||
systemctl status laufzettel
|
||||
systemctl restart laufzettel # nach jeder Änderung an laufzettel.env
|
||||
journalctl -u laufzettel -f # Protokoll mitlesen
|
||||
journalctl -u laufzettel -n 100 --no-pager
|
||||
```
|
||||
|
||||
Eigene Zusätze zur Unit gehören nach `/etc/systemd/system/laufzettel.service.d/` als Drop-in.
|
||||
Änderungen direkt in `laufzettel.service` überschreibt der nächste Lauf des Installationsskripts.
|
||||
|
||||
Nachsehen, ob die Anwendung antwortet:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||
# {"status":"ok"}
|
||||
```
|
||||
|
||||
In die Datenbank sehen — über den Socket, denn einen Port gibt es nicht:
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql laufzettel
|
||||
```
|
||||
|
||||
## Aktualisieren
|
||||
|
||||
Dasselbe Skript noch einmal aufrufen:
|
||||
|
||||
```bash
|
||||
sudo bash /opt/laufzettel/deploy/install.sh
|
||||
```
|
||||
|
||||
Es holt den Quellcode (`git pull --ff-only`), baut neu, spielt die Migrationen ein, fährt den Seed
|
||||
(der nichts ändert, was schon da ist) und startet den Dienst neu. **Geheimnisse und Daten bleiben:**
|
||||
`laufzettel.env` wird nicht angefasst, die Datenbank bleibt stehen, das Admin-Passwort wird nicht neu
|
||||
erzeugt.
|
||||
|
||||
Vorher eine Sicherung ziehen. Eine Migration fährt nicht rückwärts.
|
||||
|
||||
Schlägt `git pull --ff-only` fehl, liegen in `/opt/laufzettel` eigene Änderungen. Nachsehen mit:
|
||||
|
||||
```bash
|
||||
sudo -u laufzettel git -C /opt/laufzettel status
|
||||
```
|
||||
|
||||
## Die Selbstprüfung
|
||||
|
||||
```bash
|
||||
sudo bash /opt/laufzettel/deploy/install.sh --test
|
||||
```
|
||||
|
||||
Prüft vier Dinge und nennt jedes einzeln mit `[ok]` oder `[FEHLER]`:
|
||||
|
||||
1. `ss -ltn` zeigt **keinen** Port 5432 — PostgreSQL spricht kein TCP
|
||||
2. Die Datenbank ist über den Socket erreichbar, Migration und Seed sind gelaufen (mindestens sieben
|
||||
Vorlagen)
|
||||
3. `/etc/laufzettel/laufzettel.env` hat `600 root:root`
|
||||
4. `/api/gesundheit` antwortet mit `status ok`
|
||||
|
||||
Das ist der schnellste Weg, nach einem Umzug oder einer Aktualisierung zu prüfen, dass der Stand noch
|
||||
der beschriebene ist.
|
||||
|
||||
## Was du beim ersten Lauf selbst nachprüfen musst
|
||||
|
||||
Der `pct`-Teil ist ungetestet — kein PVE-Host stand zur Verfügung. Diese Liste ist die Gegenprobe.
|
||||
Geh sie in dieser Reihenfolge durch; jede Zeile nennt den Schritt, das erwartete Ergebnis und was zu
|
||||
tun ist, wenn es abweicht.
|
||||
|
||||
| Schritt | Erwartet | Wenn es abweicht |
|
||||
|---|---|---|
|
||||
| `bash laufzettel.sh --dry-run` auf dem PVE-Host | Ein vollständiger `pct create`-Aufruf mit CTID, Vorlage, `--cores 2`, `--memory 2048`, `--rootfs …:8`, `--unprivileged 1`, `--net0 name=eth0,bridge=vmbr0,ip=dhcp`. Es wird **nichts** angelegt. | Fehlt ein Wert oder steht ein leerer Platzhalter im Aufruf, den Befehl **nicht** von Hand abschicken. Stattdessen den Container über die PVE-Oberfläche anlegen und Weg B gehen. |
|
||||
| Vorlage vorhanden | `pveam list local` nennt ein `debian-13-standard`-Archiv. Fehlt es, holt das Skript es mit `pveam download`. | Findet `pveam available` keine Debian-13-Vorlage, ist der Vorlagenindex alt: `pveam update`. Danach noch einmal. |
|
||||
| `pct create` läuft durch | Der Container erscheint in `pct list` mit dem Hostnamen `laufzettel`. | Meldet PVE „storage does not support container directories“, ist der gewählte Speicher kein Verzeichnis- oder ZFS-Speicher. Im Erweitert-Dialog einen anderen wählen (`local-lvm`, `local-zfs`). |
|
||||
| Der Container bekommt eine Adresse | `pct exec <ctid> -- ip -4 addr` zeigt eine Adresse auf `eth0`, und `pct exec <ctid> -- ping -c1 deb.debian.org` kommt durch. | Ohne DHCP im Netz bleibt die Adresse leer und die Installation scheitert beim ersten `apt`. Container anhalten, im Erweitert-Dialog eine feste IP samt Gateway setzen. |
|
||||
| `install.sh` läuft im Container | Neun Schritte, jeder mit Meldung, am Ende der Abschlussbildschirm mit Adresse, Kennung und Passwort. | Bricht ein Schritt ab, steht die Ursache in der Meldung. Der Lauf ist wiederholbar: `pct enter <ctid>`, dann `bash /opt/laufzettel/deploy/install.sh` erneut. |
|
||||
| **Kein TCP-Port 5432** | `pct exec <ctid> -- ss -ltn` zeigt 5432 **nicht**. | Steht der Port da, hat `listen_addresses = ''` nicht gegriffen: prüfen, ob `/etc/postgresql/17/main/postgresql.conf` die Zeile `include_dir = 'conf.d'` enthält, dann `systemctl restart postgresql@17-main`. |
|
||||
| Der Dienst läuft | `pct exec <ctid> -- systemctl is-active laufzettel` sagt `active`, und `curl` auf `/api/gesundheit` antwortet `{"status":"ok"}`. | `pct exec <ctid> -- journalctl -u laufzettel -n 50 --no-pager`. Häufigste Ursache: der Bau ist an zu wenig RAM gescheitert und `.next/standalone` ist unvollständig. |
|
||||
| Umgebungsdatei geschützt | `stat -c '%a %U:%G' /etc/laufzettel/laufzettel.env` → `600 root:root`. | Rechte richtigstellen: `chown root:root`, `chmod 0600`. Und prüfen, wer in der Zwischenzeit lesen konnte. |
|
||||
| Neustart übersteht alles | `pct reboot <ctid>`, danach antwortet `/api/gesundheit` ohne Zutun wieder. | Kommt der Dienst nicht von selbst, fehlt das `enable`: `systemctl enable laufzettel` und `systemctl enable postgresql@17-main`. |
|
||||
| Zweiter Lauf aktualisiert | `install.sh` noch einmal: „laufzettel.env ist vorhanden und bleibt unangetastet“, „Datenbank laufzettel vorhanden — Daten bleiben, wie sie sind“, Passwort „unverändert“. | Wird ein **neues** Admin-Passwort angezeigt, war das Konto nicht auffindbar — dann steht in `laufzettel.env` eine andere `SEED_ADMIN_EMAIL` als erwartet. Nachsehen, bevor du weitermachst. |
|
||||
| **Der Dienst ist von außen erreichbar** | `curl http://<container-ip>:3000/api/gesundheit` vom PVE-Host aus antwortet `{"status":"ok"}`. | Antwortet nur `curl` **im** Container, aber nicht von außen, steht in `/etc/laufzettel/laufzettel.env` noch `HOSTNAME="127.0.0.1"`. Das ist die richtige Voreinstellung für einen Proxy im selben Container, aber nicht für einen Proxy davor: auf `0.0.0.0` setzen (oder auf die Container-IP), `systemctl restart laufzettel`, und eine Firewall-Regel, die den Port nur dem Proxy erlaubt. |
|
||||
| **`APP_URL` passt zum Namen, unter dem du später zugreifst** | In `laufzettel.env` steht die öffentliche Adresse. Ohne `--url` setzt `install.sh` `http://<Container-IP>:3000`. | Richtigstellen, **bevor** der erste Aufkleber gedruckt wird: Datei bearbeiten, `systemctl restart laufzettel`. Siehe [`reverse-proxy.md`](reverse-proxy.md). |
|
||||
| Die Anwendung ist bedienbar | Anmelden, einen Lauf öffnen, **einen Schritt abhaken**, Seite neu laden, Haken ist noch da. | Bleibt der Haken nicht, ist der Reverse Proxy falsch eingestellt — mit hoher Wahrscheinlichkeit die `Host`-Kopfzeile. Siehe [`reverse-proxy.md`](reverse-proxy.md). |
|
||||
| Die Sicherung funktioniert | Einmal `pg_dump` ziehen und in eine zweite, leere Datenbank zurückspielen. | Siehe [`sicherung.md`](sicherung.md). Eine Sicherung, die nie zurückgespielt wurde, ist keine. |
|
||||
|
||||
Der letzte Punkt ist der wichtigste. Alles davor kann man reparieren.
|
||||
|
||||
## Wenn es klemmt
|
||||
|
||||
| Symptom | Ursache und was zu tun ist |
|
||||
|---|---|
|
||||
| „Das Skript … braucht root“ | Mit `sudo bash install.sh` aufrufen, nicht ohne. |
|
||||
| „Erwartet ist Debian 13, gefunden …“ | Nur eine Warnung, der Lauf geht weiter. Auf älteren Debians kann die PostgreSQL-Fassung abweichen; das Skript nimmt dann das Metapaket `postgresql`. |
|
||||
| „runuser fehlt (Paket util-linux)“ | Ein sehr schlankes Abbild. `apt install util-linux`. |
|
||||
| „/opt/laufzettel ist nicht leer, enthält aber kein Git-Arbeitsverzeichnis“ | Dort liegt ein alter Stand ohne `.git`. Verschieben oder löschen, dann neu starten. |
|
||||
| `pnpm build` bricht mit `Killed` ab | Zu wenig RAM. Dem Container vorübergehend 4 GB geben (`pct set <ctid> --memory 4096`), erneut laufen lassen, danach zurückdrehen. |
|
||||
| „Der Dienst antwortet nach 60 s nicht“ | `journalctl -u laufzettel -n 50 --no-pager`. Meist fehlt etwas in `laufzettel.env` oder die Datenbank ist nicht erreichbar. |
|
||||
| „In `laufzettel.env` fehlt `DATABASE_URL`, `APP_SECRET` oder `APP_URL`“ | Die Datei ist beschädigt. Ergänzen ist der richtige Weg. Löschen legt sie neu an — dabei geht das bisherige `APP_SECRET` verloren: alle Sitzungen enden, und die verschlüsselt gespeicherten LDAP-Bind-Passwörter sind unlesbar und müssen neu eingegeben werden. |
|
||||
| `psql: FATAL: password authentication failed` | Das Passwort in `DATABASE_URL` passt nicht zur Rolle. Das Skript setzt es nur dann neu, wenn es die Umgebungsdatei selbst neu angelegt hat. Von Hand: `sudo -u postgres psql -c '\password laufzettel'` und den Wert in `laufzettel.env` nachziehen. |
|
||||
| Kein systemd im Container | Das Skript erkennt es, schreibt die Unit trotzdem und sagt, wie der Dienst von Hand zu starten ist. Tritt bei einem `docker run debian:13` auf, nicht in einem LXC. |
|
||||
@@ -0,0 +1,239 @@
|
||||
# Reverse Proxy
|
||||
|
||||
Laufzettel spricht selbst kein HTTPS und bindet standardmäßig auf `127.0.0.1:3000`. Davor gehört ein
|
||||
Reverse Proxy, der das Zertifikat hält und die Anfragen weiterreicht. Das gilt auf beiden
|
||||
Betriebswegen — im [Compose-Stand](inbetriebnahme.md) wie [nativ im LXC](proxmox.md).
|
||||
|
||||
## Zuerst: die Adresse, die auf dem Aufkleber landet
|
||||
|
||||
`APP_URL` ist die **öffentliche** Adresse der Anwendung. Sie wird nicht aus der eingehenden Anfrage
|
||||
erraten, sondern gesetzt, und zwar aus einem Grund, der Papier betrifft:
|
||||
|
||||
**Aus `APP_URL` entstehen die QR-Adressen auf den gedruckten Aufklebern.** Im QR steht
|
||||
`{APP_URL}/t/{tagCode}` — nichts weiter, kein Name, keine Seriennummer. Ein Aufkleber klebt Jahre
|
||||
am Gerät und lässt sich nicht nachträglich korrigieren. Wäre die Adresse aus der Anfrage geraten,
|
||||
stünde auf jedem Aufkleber, der über den Proxy hinweg gedruckt wurde, der interne Hostname:
|
||||
`http://laufzettel-app:3000/t/LZ-7K3F9Q`. Den erreicht ein Telefon im WLAN nie.
|
||||
|
||||
Deshalb, bevor der erste Aufkleber gedruckt wird:
|
||||
|
||||
1. Den DNS-Namen festlegen, unter dem die Anwendung dauerhaft erreichbar sein soll
|
||||
2. `APP_URL` genau darauf setzen — mit `https://`, ohne Schrägstrich am Ende
|
||||
3. Einen Aufkleber probedrucken, mit dem Telefon scannen und schauen, wo man landet
|
||||
|
||||
Ändert sich `APP_URL` später doch, ist nicht alles verloren, aber unangenehm:
|
||||
|
||||
- **Der Scanner in der Anwendung nimmt den Code weiterhin an**, egal von welchem Host die gescannte
|
||||
Adresse kommt. Das ist ausdrücklich so gebaut: ein Haus zieht um, ein zweiter Name zeigt auf
|
||||
dieselbe Anwendung — daran soll der Aufkleber im Regal nicht scheitern.
|
||||
- **Unter dem QR steht der Code im Klartext** (`LZ-7K3F9Q`) und lässt sich eintippen.
|
||||
- **Was nicht mehr geht, ist der bequeme Weg:** mit der Kamera-App des Telefons scannen und dem
|
||||
Link folgen. Der zeigt auf die alte Adresse.
|
||||
|
||||
Der einfachste Ausweg ist, den alten Namen im DNS und im Proxy als zweiten `server_name`
|
||||
beziehungsweise zweiten Host weiterlaufen zu lassen und auf denselben Dienst zu leiten. Der
|
||||
zweitbeste ist, neu zu drucken. Die Tag-Codes selbst bleiben dabei gültig — ein Code wird nie
|
||||
geändert und nie neu vergeben.
|
||||
|
||||
Die zweite Stelle, an der `APP_URL` zählt, ist die OIDC-Rücksprungadresse: `APP_URL` plus
|
||||
`/api/auth/oidc/rueckruf`, zeichengenau so beim Anbieter hinterlegt. Siehe
|
||||
[`authentik.md`](authentik.md).
|
||||
|
||||
Und die dritte: aus dem Schema von `APP_URL` entscheidet die Anwendung, ob das Sitzungs-Cookie das
|
||||
`Secure`-Flag bekommt. Steht dort `http://`, obwohl außen HTTPS läuft, ist das Cookie unnötig
|
||||
schwach. Steht dort `https://`, während außen nur HTTP läuft, wirft der Browser das Cookie weg und
|
||||
man landet nach jedem Login wieder auf der Anmeldeseite.
|
||||
|
||||
## Caddy
|
||||
|
||||
Der kürzere Weg. Caddy holt und erneuert das Zertifikat selbst, leitet HTTP auf HTTPS um und setzt
|
||||
`X-Forwarded-For`, `X-Forwarded-Proto` und `X-Forwarded-Host` von sich aus richtig.
|
||||
|
||||
```caddyfile
|
||||
laufzettel.firma.de {
|
||||
encode zstd gzip
|
||||
|
||||
reverse_proxy 127.0.0.1:3000 {
|
||||
# Antwortet die Anwendung hier nicht, ist sie wirklich weg – und nicht nur
|
||||
# gerade beim Neustart. Der Pfad braucht keine Anmeldung.
|
||||
health_uri /api/gesundheit
|
||||
health_interval 30s
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Läuft Caddy auf einem anderen Rechner als die Anwendung, muss diese auf eine erreichbare Adresse
|
||||
binden: `BIND_ADDR` im Compose-Stand beziehungsweise `HOSTNAME` in `/etc/laufzettel/laufzettel.env`
|
||||
nativ. Dann gehört eine Firewall-Regel dazu, die den Port nur dem Proxy erlaubt.
|
||||
|
||||
Übernehmen:
|
||||
|
||||
```bash
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
## nginx
|
||||
|
||||
Mehr Zeilen, und eine Falle. Die vollständige Datei:
|
||||
|
||||
```nginx
|
||||
# HTTP nur, um auf HTTPS zu leiten – und fuer die ACME-Abfrage von certbot.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name laufzettel.firma.de;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 308 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name laufzettel.firma.de;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/laufzettel.firma.de/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/laufzettel.firma.de/privkey.pem;
|
||||
|
||||
# Der CSV-Import schickt die Datei als Text durch eine Server Action. nginx
|
||||
# laesst von sich aus nur 1 MB durch und waere damit die erste Wand.
|
||||
client_max_body_size 8m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Diese Zeile ist die wichtigste der Datei, siehe unten.
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# Der Lauf fragt seinen Zustand alle zehn Sekunden nach; 90 s sind
|
||||
# reichlich und fangen einen langsamen Verzeichnis-Abgleich mit ab.
|
||||
proxy_read_timeout 90s;
|
||||
|
||||
# Next liefert Seiten stroemend aus. Ohne das haelt nginx die Antwort
|
||||
# zusammen, und die Seite erscheint spuerbar spaeter auf einmal.
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Übernehmen:
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Das Zertifikat holt certbot:
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d laufzettel.firma.de
|
||||
```
|
||||
|
||||
### Die Falle: `Host` muss durchgereicht werden
|
||||
|
||||
**nginx ersetzt die Kopfzeile `Host` von sich aus** durch das Ziel von `proxy_pass`, also
|
||||
`127.0.0.1:3000`. Caddy und Traefik tun das nicht, nginx schon.
|
||||
|
||||
Das bricht jedes Formular in Laufzettel. Der Grund: Next prüft bei jeder Server Action — und jedes
|
||||
Speichern ist eine — den `Origin` der Anfrage gegen den Host, unter dem es sich selbst wähnt
|
||||
(`x-forwarded-host`, sonst `host`). Der Browser schickt `laufzettel.firma.de`, der Server liest
|
||||
`127.0.0.1:3000`, die beiden passen nicht zusammen, und die Aktion wird als möglicher
|
||||
CSRF-Versuch abgewiesen. Das ist eine Schutzmaßnahme, die hier auf das falsche Ziel schießt.
|
||||
|
||||
Das Heimtückische daran: **Seiten laden weiterhin völlig normal.** Nur kein Haken, kein Speichern,
|
||||
keine Änderung kommt durch. Wer den Proxy nach einem Screenshot abnimmt, merkt es nicht.
|
||||
|
||||
`proxy_set_header Host $host;` und `proxy_set_header X-Forwarded-Host $host;` lösen das. Hinter
|
||||
einem Proxy, der den öffentlichen Host in `x-forwarded-host` weitergibt, braucht es keine weitere
|
||||
Einstellung in der Anwendung. Erst wenn ein Proxy das nicht kann, käme
|
||||
`experimental.serverActions.allowedOrigins` in `next.config.ts` in Frage — der Weg über die
|
||||
Kopfzeile ist der richtige, weil er den Schutz erhält, statt ihn aufzuweichen.
|
||||
|
||||
## Welche Kopfzeilen der Proxy setzen soll
|
||||
|
||||
| Kopfzeile | Warum |
|
||||
|---|---|
|
||||
| `Host` | Muss der öffentliche Name sein, sonst scheitern alle Server Actions. Bei nginx ausdrücklich setzen. |
|
||||
| `X-Forwarded-Host` | Dasselbe nochmal, und das ist die Kopfzeile, die Next zuerst liest. |
|
||||
| `X-Forwarded-Proto` | `https`, damit die Anwendung weiß, dass die Verbindung außen gesichert ist. |
|
||||
| `X-Forwarded-For` | Die Adresse des Benutzers, für die Fehlersuche im Protokoll des Proxys. |
|
||||
|
||||
Alles andere braucht die Anwendung nicht. Sie leitet ihre eigenen Adressen aus `APP_URL` ab, nicht
|
||||
aus diesen Kopfzeilen — deshalb bleibt sie auch dann brauchbar, wenn ein Proxy sie vergisst; nur die
|
||||
Server Actions eben nicht.
|
||||
|
||||
## Welche Kopfzeilen der Proxy nicht setzen soll
|
||||
|
||||
**Die Sicherheitskopfzeilen kommen aus der Anwendung.** Sie schickt `Strict-Transport-Security`,
|
||||
`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy` und eine
|
||||
`Content-Security-Policy` mit einer Nonce je Anfrage. Setz sie im Proxy nicht noch einmal:
|
||||
|
||||
- Eine **zweite CSP** ist nicht die mildere, sondern eine zusätzliche. Browser wenden alle
|
||||
mitgeschickten Richtlinien an, und es gilt jeweils die strengste Einschränkung. Eine
|
||||
gutgemeinte zweite CSP schaltet damit zuverlässig die Anwendung ab — und zwar wieder so, dass die
|
||||
Seite aussieht wie immer und nichts tut.
|
||||
- **HSTS doppelt** ist harmlos, aber unsauber. Wenn du eine längere Laufzeit willst, ändere sie in
|
||||
`next.config.ts`, nicht im Proxy.
|
||||
|
||||
## Was noch dazugehört
|
||||
|
||||
**Einbetten geht nicht.** Die CSP der Anwendung enthält `frame-ancestors 'none'`, dazu
|
||||
`X-Frame-Options: DENY`. Laufzettel lässt sich nicht in ein Intranet-Portal oder ein Dashboard als
|
||||
`iframe` einhängen. Verlink es stattdessen.
|
||||
|
||||
**Kein Unterpfad.** Laufzettel gehört auf einen eigenen Hostnamen, nicht unter
|
||||
`https://intranet.firma.de/laufzettel/`. Ein `basePath` ist nicht gesetzt, die Anwendung liefert ihre
|
||||
eigenen Dateien also unter `/_next/…` aus, und die kämen unter einem Unterpfad nicht an. Die
|
||||
Adressberechnung für die Aufkleber trägt einen Pfad in `APP_URL` bewusst mit — das schützt gegen den
|
||||
schlimmsten Fall, macht den Unterpfad aber nicht betriebsfähig.
|
||||
|
||||
**Der Healthcheck ist `/api/gesundheit`.** Er braucht keine Anmeldung, prüft die Datenbank mit und
|
||||
antwortet `{"status":"ok"}` oder mit Status 503. Das ist der richtige Pfad für die Überwachung des
|
||||
Proxys; `/` leitet ohne Cookie auf die Anmeldeseite um und sagt nichts über den Zustand.
|
||||
|
||||
**Die Kamera braucht HTTPS.** Der QR-Scanner in der Anwendung benutzt die Kamera des Geräts, und
|
||||
Browser geben sie nur über eine gesicherte Verbindung frei (`localhost` ausgenommen). Ohne TLS
|
||||
scannt am Handy niemand — das ist der praktische Grund, warum der Proxy nicht optional ist.
|
||||
|
||||
**Etwas Geduld beim Neustart.** Nach `docker compose up -d` oder `systemctl restart laufzettel`
|
||||
braucht die Anwendung einige Sekunden. Ein Proxy mit aktivem Healthcheck fängt das ab; ohne
|
||||
bekommen die ersten Anfragen ein `502`.
|
||||
|
||||
## Prüfen, dass es wirklich sitzt
|
||||
|
||||
Ein Screenshot beweist hier nichts — genau die Fehler, die hier passieren, lassen die Seite richtig
|
||||
aussehen. Nachprüfen heißt: einmal etwas ändern.
|
||||
|
||||
```bash
|
||||
# 1. Die Anwendung antwortet dem Proxy
|
||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||
|
||||
# 2. Der Proxy reicht durch, mit gueltigem Zertifikat
|
||||
curl -s https://laufzettel.firma.de/api/gesundheit
|
||||
|
||||
# 3. HTTP leitet auf HTTPS
|
||||
curl -sI http://laufzettel.firma.de | head -1
|
||||
|
||||
# 4. Die Sicherheitskopfzeilen kommen genau einmal
|
||||
curl -sI https://laufzettel.firma.de/anmelden | grep -ci "content-security-policy"
|
||||
```
|
||||
|
||||
Und dann per Hand, im Browser:
|
||||
|
||||
1. Anmelden
|
||||
2. Einen Lauf öffnen und **einen Schritt abhaken**
|
||||
3. Die Seite neu laden und nachsehen, ob der Haken noch da ist
|
||||
|
||||
Schritt 3 ist der eigentliche Test. Bleibt der Haken nicht, ist die `Host`-Kopfzeile falsch.
|
||||
@@ -0,0 +1,289 @@
|
||||
# Sichern und Zurückspielen
|
||||
|
||||
Laufzettel ist ein Gedächtnis: was in einem archivierten Lauf steht, soll in zehn Jahren noch sagen
|
||||
können, wer wann was getan hat. Dazu kommt ein handfester Grund, der auf Papier klebt — **jeder
|
||||
gedruckte Aufkleber zeigt auf einen Tag-Code, der nur in der Datenbank existiert.** Ein Code wird nie
|
||||
geändert und nie neu vergeben; ist die Datenbank weg, sind alle Aufkleber im Haus wertlos, und es
|
||||
gibt keinen Weg, sie zurückzurechnen.
|
||||
|
||||
Die Sicherung ist auf beiden Betriebswegen eine Sache von zwei Befehlen. Das Zurückspielen ist
|
||||
ebenfalls einfach, aber nur, wenn man es einmal geübt hat.
|
||||
|
||||
## Was gesichert werden muss
|
||||
|
||||
Zwei Dinge, und nur diese zwei:
|
||||
|
||||
| | Was | Wo |
|
||||
|---|---|---|
|
||||
| **1** | **Die Datenbank.** Alles Veränderliche steckt darin: Läufe, Vorlagen, Schrittergebnisse, Kommentare, Geräte, Personen, Tag-Codes, Protokoll. | `pg_dump`, siehe unten |
|
||||
| **2** | **Die Geheimnisse.** `APP_SECRET` und das Datenbank-Passwort. | Compose: die `.env` · Nativ: `/etc/laufzettel/laufzettel.env` |
|
||||
|
||||
**Warum die Geheimnisse dazugehören:** `APP_SECRET` verschlüsselt nicht nur die Sitzungs-Cookies,
|
||||
sondern auch die gespeicherten LDAP-Bind-Passwörter. Spielst du eine Datenbank in eine Installation
|
||||
mit neuem `APP_SECRET` zurück, sind alle Sitzungen ungültig — das ist harmlos, man meldet sich neu an
|
||||
— **und jedes Bind-Passwort der Verzeichnisquellen ist unlesbar**. Der Verzeichnis-Abgleich schlägt
|
||||
dann fehl, und das Passwort muss von Hand neu eingetragen werden. Merkt man erst, wenn der nächste
|
||||
Abgleich fällig ist.
|
||||
|
||||
Sicher die beiden Dinge getrennt und leg das Geheimnis dahin, wo auch die restlichen Zugangsdaten des
|
||||
Hauses liegen — in den Passwortmanager. Eine Datei mit `APP_SECRET` neben dem Datenbank-Abzug auf
|
||||
derselben Netzwerkfreigabe hebt den Zweck der Verschlüsselung auf.
|
||||
|
||||
**Was nicht gesichert werden muss:** Die Anwendung hält keinen veränderlichen Zustand auf der Platte.
|
||||
Kein Upload-Verzeichnis, keine erzeugten Dateien. Der Quellcode kommt aus Git, `node_modules` und
|
||||
`.next` entstehen beim Bauen neu, das PDF der Anleitung liegt am Release.
|
||||
|
||||
**Was der Abzug bewusst nicht enthält:** ein `{{generiertes_passwort}}`. Es wird bei jeder Anzeige neu
|
||||
erzeugt und nie gespeichert — nicht im Schrittergebnis, nicht im Kommentar, nicht im Protokoll. Wer
|
||||
ein so gesetztes Passwort braucht, findet es in der Sicherung nicht, und das ist Absicht.
|
||||
|
||||
> **Der Abzug enthält personenbezogene Daten.** Namen, E-Mail-Adressen und Abteilungen der
|
||||
> Mitarbeiter, dazu argon2-Hashes der lokalen Passwörter. Er gehört verschlüsselt aufbewahrt und
|
||||
> nicht auf eine offene Freigabe.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
### Sichern
|
||||
|
||||
Der Datenbankport ist von außen geschlossen — das ändert nichts, die Sicherung läuft über den
|
||||
Dienstnamen:
|
||||
|
||||
```bash
|
||||
cd /opt/laufzettel
|
||||
docker compose exec -T postgres pg_dump -U laufzettel laufzettel | gzip > sicherung-$(date +%F).sql.gz
|
||||
```
|
||||
|
||||
`-T` ist wichtig: ohne den Schalter hängt Docker ein Terminal dazwischen, und das verdirbt die
|
||||
Ausgabe. Im Ergebnis liegt eine Datei von einigen hundert Kilobyte bis wenigen Megabyte.
|
||||
|
||||
Wer lieber das Eigenformat von PostgreSQL will — es lässt sich selektiv und parallel zurückspielen:
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres pg_dump -U laufzettel -Fc laufzettel > sicherung-$(date +%F).dump
|
||||
```
|
||||
|
||||
Kurz prüfen, dass die Datei nicht leer ist:
|
||||
|
||||
```bash
|
||||
gunzip -c sicherung-$(date +%F).sql.gz | head -5
|
||||
gunzip -c sicherung-$(date +%F).sql.gz | grep -c 'COPY public'
|
||||
```
|
||||
|
||||
Das Volume heißt `laufzettel_datenbank`. Es mit `docker run --volumes-from` wegzukopieren ist
|
||||
**keine** gleichwertige Sicherung: ein Dateiabzug eines laufenden PostgreSQL ist im besten Fall
|
||||
brauchbar und im schlechteren still beschädigt. Nimm `pg_dump`.
|
||||
|
||||
### Zurückspielen
|
||||
|
||||
Die Datenbank muss dafür leer sein — ein Abzug in eine gefüllte Datenbank gespielt bringt lauter
|
||||
Fehler über schon vorhandene Tabellen und hinterlässt einen Mischzustand.
|
||||
|
||||
```bash
|
||||
cd /opt/laufzettel
|
||||
|
||||
# 1. Die Anwendung anhalten, damit niemand dazwischenschreibt
|
||||
docker compose stop web
|
||||
|
||||
# 2. Datenbank leeren und neu anlegen
|
||||
docker compose exec -T postgres dropdb -U laufzettel laufzettel
|
||||
docker compose exec -T postgres createdb -U laufzettel -O laufzettel laufzettel
|
||||
|
||||
# 3. Abzug einspielen
|
||||
gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel laufzettel
|
||||
|
||||
# 4. Migrationen nachziehen – nötig, wenn der Abzug von einer älteren Fassung stammt
|
||||
docker compose run --rm migrate pnpm exec prisma migrate deploy
|
||||
|
||||
# 5. Wieder anfahren
|
||||
docker compose start web
|
||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||
```
|
||||
|
||||
Bei einem `-Fc`-Abzug tritt an die Stelle von Schritt 3:
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres pg_restore -U laufzettel -d laufzettel --no-owner < sicherung-2026-09-24.dump
|
||||
```
|
||||
|
||||
**Den Seed brauchst du danach nicht.** Er würde nichts kaputt machen — er richtet ein und räumt nicht
|
||||
auf —, aber alles, was er anlegen würde, steht schon im Abzug.
|
||||
|
||||
## Nativ (LXC, VM, Blech)
|
||||
|
||||
### Sichern
|
||||
|
||||
Über den Unix-Socket, als Systemnutzer `postgres`. Einen TCP-Port gibt es nicht.
|
||||
|
||||
```bash
|
||||
sudo -u postgres pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%F).dump
|
||||
```
|
||||
|
||||
Und das Geheimnis, einmalig und in den Passwortmanager:
|
||||
|
||||
```bash
|
||||
sudo cat /etc/laufzettel/laufzettel.env
|
||||
```
|
||||
|
||||
`sudo -u postgres` und nicht `psql -h localhost`: die Rolle `laufzettel` hat ein Passwort, aber
|
||||
`postgres` kommt über die Peer-Authentifizierung am Socket ohne eines hinein — für ein Skript, das
|
||||
ohne Eingabe laufen soll, ist das der einfachere Weg.
|
||||
|
||||
### Zurückspielen
|
||||
|
||||
```bash
|
||||
# 1. Dienst anhalten
|
||||
sudo systemctl stop laufzettel
|
||||
|
||||
# 2. Datenbank leeren und neu anlegen, Eigentümer bleibt die Rolle laufzettel
|
||||
sudo -u postgres dropdb laufzettel
|
||||
sudo -u postgres createdb -O laufzettel laufzettel
|
||||
|
||||
# 3. Abzug einspielen
|
||||
sudo -u postgres pg_restore -d laufzettel --no-owner /var/backups/laufzettel-2026-09-24.dump
|
||||
|
||||
# 4. Migrationen nachziehen, wenn der Abzug älter ist als der Code
|
||||
sudo bash /opt/laufzettel/deploy/install.sh
|
||||
|
||||
# 5. Wieder anfahren und nachsehen
|
||||
sudo systemctl start laufzettel
|
||||
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||
```
|
||||
|
||||
Schritt 4 ist der Installationsaufruf, weil er die Migrationen mitbringt und den Dienst gleich
|
||||
neu startet — Schritt 5 ist dann nur noch die Gegenprobe. Willst du nur die Migrationen:
|
||||
|
||||
```bash
|
||||
cd /opt/laufzettel
|
||||
sudo -u laufzettel env $(sudo grep -v '^#' /etc/laufzettel/laufzettel.env | xargs) pnpm exec prisma migrate deploy
|
||||
```
|
||||
|
||||
## Automatisch sichern
|
||||
|
||||
### Nativ, als systemd-Timer
|
||||
|
||||
`/etc/systemd/system/laufzettel-sicherung.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Laufzettel sichern
|
||||
After=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=postgres
|
||||
# %i-freies Einzeiler-Skript: pg_dump in eine Datei mit Datum, dann alles
|
||||
# aelter als 30 Tage weg.
|
||||
ExecStart=/bin/bash -c 'pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%%F).dump'
|
||||
ExecStartPost=/usr/bin/find /var/backups -name 'laufzettel-*.dump' -mtime +30 -delete
|
||||
```
|
||||
|
||||
`/etc/systemd/system/laufzettel-sicherung.timer`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Laufzettel taeglich sichern
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
# Nach einem Ausfall die verpasste Sicherung nachholen, statt sie zu ueberspringen.
|
||||
Persistent=true
|
||||
RandomizedDelaySec=15m
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo install -d -o postgres -g postgres -m 0700 /var/backups
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now laufzettel-sicherung.timer
|
||||
sudo systemctl list-timers laufzettel-sicherung.timer
|
||||
```
|
||||
|
||||
Einmal von Hand auslösen und nachsehen, ob wirklich eine Datei entstanden ist:
|
||||
|
||||
```bash
|
||||
sudo systemctl start laufzettel-sicherung.service
|
||||
ls -l /var/backups/
|
||||
```
|
||||
|
||||
### Compose, als Cron-Eintrag
|
||||
|
||||
In der Crontab von root (`sudo crontab -e`):
|
||||
|
||||
```cron
|
||||
15 3 * * * cd /opt/laufzettel && docker compose exec -T postgres pg_dump -U laufzettel laufzettel | gzip > /var/backups/laufzettel-$(date +\%F).sql.gz && find /var/backups -name 'laufzettel-*.sql.gz' -mtime +30 -delete
|
||||
```
|
||||
|
||||
Das `%` muss in einer Crontab als `\%` geschrieben werden, sonst schneidet cron die Zeile ab — der
|
||||
häufigste Fehler an dieser Stelle, und er fällt erst auf, wenn die Datei fehlt.
|
||||
|
||||
### Und dann weg vom Rechner
|
||||
|
||||
Eine Sicherung, die auf derselben Platte liegt wie die Datenbank, hilft gegen einen Tippfehler und
|
||||
gegen nichts sonst. Zieh die Dateien nachts auf ein anderes System — Bordmittel genügen:
|
||||
|
||||
```bash
|
||||
rsync -a --delete /var/backups/ sicherung@nas:/laufzettel/
|
||||
```
|
||||
|
||||
Verschlüsselt, wegen der personenbezogenen Daten. Und behalte mehr als eine: ein Schaden, der drei
|
||||
Tage unbemerkt bleibt, überschreibt sonst die letzte brauchbare Fassung.
|
||||
|
||||
## Einmal üben, sonst gilt es nicht
|
||||
|
||||
Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung, sondern eine Datei. Der Seed
|
||||
liefert eigens eine Vorlage **Restore-Test** mit — nimm sie und mach den Test einmal im Quartal zu
|
||||
einem Lauf mit Datum und Ergebnis. Dann steht er im Archiv, mit Namen und Zeitpunkt, und niemand muss
|
||||
sich erinnern, wann er zuletzt geprüft hat.
|
||||
|
||||
Der Test selbst, ohne den Betrieb anzufassen — in eine zweite, leere Datenbank:
|
||||
|
||||
```bash
|
||||
# Compose
|
||||
docker compose exec -T postgres createdb -U laufzettel probe
|
||||
gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel probe
|
||||
docker compose exec -T postgres psql -U laufzettel probe -c 'SELECT count(*) FROM "Lauf"'
|
||||
docker compose exec -T postgres dropdb -U laufzettel probe
|
||||
```
|
||||
|
||||
```bash
|
||||
# Nativ
|
||||
sudo -u postgres createdb probe
|
||||
sudo -u postgres pg_restore -d probe --no-owner /var/backups/laufzettel-2026-09-24.dump
|
||||
sudo -u postgres psql -d probe -c 'SELECT count(*) FROM "Lauf"'
|
||||
sudo -u postgres dropdb probe
|
||||
```
|
||||
|
||||
Kommt bei `count(*)` eine plausible Zahl heraus, ist die Sicherung brauchbar. Kommt eine Fehlermeldung
|
||||
oder eine Null, hast du es jetzt erfahren und nicht im Ernstfall.
|
||||
|
||||
## Umzug auf einen neuen Server
|
||||
|
||||
Derselbe Weg, nur mit einer Zwischenstation:
|
||||
|
||||
1. Auf dem alten System `pg_dump` ziehen und `APP_SECRET` sowie die restliche `.env` beziehungsweise
|
||||
`laufzettel.env` mitnehmen
|
||||
2. Auf dem neuen System frisch einrichten — [Compose](inbetriebnahme.md) oder
|
||||
[nativ](proxmox.md)
|
||||
3. **Vor** dem Zurückspielen `APP_SECRET` in der neuen Umgebungsdatei auf den alten Wert setzen, sonst
|
||||
sind die LDAP-Bind-Passwörter unlesbar
|
||||
4. Abzug zurückspielen wie oben, Migrationen nachziehen
|
||||
5. `APP_URL` prüfen: bleibt der DNS-Name gleich, bleiben die gedruckten Aufkleber gültig. Ändert er
|
||||
sich, lies den Anfang von [`reverse-proxy.md`](reverse-proxy.md), bevor du umstellst
|
||||
|
||||
Die PostgreSQL-Hauptfassung sollte auf dem Zielsystem dieselbe oder eine neuere sein. Ein Abzug aus
|
||||
17 lässt sich in 18 einspielen, umgekehrt nicht.
|
||||
|
||||
## Wenn es klemmt
|
||||
|
||||
| Symptom | Ursache und was zu tun ist |
|
||||
|---|---|
|
||||
| `the input device is not a TTY` | Beim `docker compose exec` fehlt `-T`. |
|
||||
| Der Abzug ist wenige Bytes groß | `pg_dump` hat nicht die Datenbank erwischt, sondern eine Fehlermeldung geschrieben. Ohne Umleitung wiederholen und lesen, was es sagt. |
|
||||
| `ERROR: relation "Benutzer" already exists` | Du spielst in eine gefüllte Datenbank. Erst `dropdb`/`createdb`, siehe oben. |
|
||||
| `role "laufzettel" does not exist` beim Zurückspielen auf einem anderen System | Der Abzug nennt Eigentümer, die dort fehlen. `pg_restore --no-owner` beziehungsweise `pg_dump --no-owner` beim Erzeugen. |
|
||||
| Nach dem Zurückspielen schlägt der Verzeichnis-Abgleich fehl | `APP_SECRET` ist nicht mehr dasselbe, und das Bind-Passwort ist unlesbar. Entweder den alten Wert eintragen und neu starten, oder unter **Admin → Verzeichnis** das Bind-Passwort neu setzen. |
|
||||
| Nach dem Zurückspielen fehlen Tabellen oder Spalten | Der Abzug ist älter als der Code. `prisma migrate deploy` nachziehen, siehe Schritt 4 oben. |
|
||||
| Die Anwendung startet nach dem Zurückspielen nicht | `docker compose logs web` beziehungsweise `journalctl -u laufzettel -n 50 --no-pager`. Meist läuft noch die Migration, oder sie ist gescheitert. |
|
||||
| Ein Tag-Code taucht doppelt auf | Kann nach einem Zurückspielen nicht passieren; ein partieller Eindeutigkeitsindex in der Datenbank verhindert es. Siehst du es doch, ist der Abzug nicht sauber eingespielt — Datenbank leeren und noch einmal von vorn. |
|
||||
+143
-2
@@ -21,11 +21,12 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
| #016 | M11 – Verzeichnis und LDAP-Abgleich | 3 h 25 min |
|
||||
| #017 | M12 – Seed der sieben Vorlagen | 1 h 30 min |
|
||||
| #018 | M13 – E2E-Tests, Anleitung, PDF | 2 h 15 min |
|
||||
| | **Summe** | **29 h 55 min** |
|
||||
| #019 | M14 – Betriebswege und Release v0.1.0 | 5 h 25 min |
|
||||
| | **Summe** | **35 h 20 min** |
|
||||
|
||||
| Meilenstein | Tatsächlich | Status |
|
||||
|---|---|---|
|
||||
| v0.1.0 | 29 h 55 min | in Arbeit |
|
||||
| v0.1.0 | 35 h 20 min | veröffentlicht |
|
||||
|
||||
---
|
||||
|
||||
@@ -899,3 +900,143 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
|
||||
- #019 Betriebswege und Release `v0.1.0` — `deploy/install.sh`, der Proxmox-One-Liner, die
|
||||
Betriebsdokumentation und der Tag mit Release-Notes.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-26 · Sitzung 15 · 10:50–16:15
|
||||
|
||||
**Issue:** #019 (M14)
|
||||
**Version:** 0.1.0
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **`deploy/install.sh`** (1058 Zeilen): neun Schritte von den Voraussetzungen bis zum gestarteten
|
||||
Dienst, PostgreSQL 17 nur am Unix-Socket (`listen_addresses = ''`), Dienstnutzer ohne Login,
|
||||
Geheimnisse im Zielsystem erzeugt, systemd-Unit im Skript, `--test` als Selbstprüfung
|
||||
- **`deploy/proxmox/laufzettel.sh`** (963 Zeilen): der Einzeiler im Stil der Helper-Scripts, Dialog
|
||||
Standard gegen Erweitert, Trockenlauf, Erkennung eines vorhandenen Containers, `--aktualisieren`
|
||||
- **`docs/betrieb/`**: Inbetriebnahme, Proxmox, Reverse Proxy, Sicherung und eine Übersicht — dazu
|
||||
eine Prüfliste für den Teil, den hier niemand ausführen konnte
|
||||
- **Der gehärtete Compose-Stand einmal wirklich gefahren**, nicht nur gelesen: Migrationen laufen von
|
||||
selbst, der Seed bleibt ein Handgriff, `5432` ist vom Host dicht und aus `web` erreichbar
|
||||
- **Abnahme gegen alle 61 Kriterien** durch einen Agenten, der den Code nicht geschrieben hat:
|
||||
57 erfüllt, drei Befunde noch vor dem Tag behoben, drei Folge-Issues (#024–#026)
|
||||
- **Release `v0.1.0`**: Changelog-Abschnitt, Tag, Gitea-Release mit dem Anleitungs-PDF
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **Die Umgebungsdatei wird nie überschrieben, auch nicht beim Aktualisieren.** Sonst würde ein
|
||||
Aktualisierungslauf das `APP_SECRET` neu erzeugen: alle Sitzungen enden, und die verschlüsselten
|
||||
LDAP-Bind-Passwörter wären unlesbar. Wer Schalter mitgibt, die deshalb nicht mehr wirken, bekommt
|
||||
eine Warnung statt stillen Gehorsams.
|
||||
- **Das Erstpasswort reist über eine Leihgabe, nicht über die Ausgabe.** Der Proxmox-Weg kann die
|
||||
Ausgabe von `install.sh` hinter `pct exec` nicht mitlesen. Sie mitzuschneiden und zu durchsuchen
|
||||
hätte das Passwort durch Pipes und Protokolldateien geschleift und von Formulierungen abgehangen.
|
||||
Stattdessen legt `install.sh` es auf Wunsch in eine Datei (0600, root), der Aufrufer liest sie
|
||||
einmal und löscht sie sofort — gelesen oder nicht.
|
||||
- **shellcheck läuft in CI im Container**, nicht aus dem Paketverzeichnis des Runners. So prüft die
|
||||
Pipeline genau die Fassung, die hier lokal geprüft wurde, und der Runner muss nichts mitbringen.
|
||||
- **Kriterium 4 bleibt offen statt „im Grunde erfüllt“.** Der `pct`-Teil ist nie gelaufen; hier stand
|
||||
kein Proxmox-Host. Abgehakt wird er, wenn ihn ein Mensch einmal gefahren hat — Prüfliste und
|
||||
Folge-Issue #024 stehen dafür bereit.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **Der dokumentierte Weg startete nicht.** Der Compose-Stand las `DATABASE_URL` aus der `.env` —
|
||||
dort steht die Entwicklungsadresse mit `localhost`, die im Container ins Leere zeigt. Und der
|
||||
Einmal-Dienst für die Migration bekam kein `APP_SECRET`, weshalb der dokumentierte Seed-Befehl
|
||||
mitten im Lauf abbrach, nachdem die Vorlagen schon angelegt waren. Beides fiel erst auf, als der
|
||||
Stand zum ersten Mal wirklich gefahren wurde — die Suite war die ganze Zeit grün. **Ein Fehler im
|
||||
dokumentierten Weg eines Dritten ist für eine Testsuite unsichtbar.**
|
||||
- **Ein Healthcheck, der nie bestehen konnte.** busybox löst `localhost` im Container zuerst nach
|
||||
`::1` auf, Next lauscht nur auf IPv4. `docker compose ps` zeigte `web` dauerhaft als `unhealthy`,
|
||||
während die App einwandfrei antwortete. Jedes `docker compose up --wait` wäre in den Zeitablauf
|
||||
gelaufen. Wieder eine Zusage ohne Deckung — jetzt hält ein Test die Adresse fest.
|
||||
- **Die CI-Pipeline konnte in keinem Job grün werden.** Dem Prüf-Job fehlte das LDAP-Testverzeichnis,
|
||||
das `pnpm test` dort ausdrücklich verlangt; dem E2E-Job fehlte ein gestarteter Server, weshalb die
|
||||
Vorabprüfung aus #018 sofort abgebrochen hätte; und `shellcheck` wurde aufgerufen, ohne installiert
|
||||
zu sein. „Qualitätsschranken“ galt damit nur lokal. Gefunden hat das die Abnahme, nicht die Arbeit
|
||||
daran.
|
||||
- **Ich habe „shellcheck ohne Befund“ gemeldet, ohne es zu sein.** Der Satz kam aus einem `echo`, das
|
||||
unabhängig vom Ergebnis lief, während shellcheck vier SC1111-Warnungen ausgab. Das ist derselbe
|
||||
Fehler, den dieser Devlog seit #010 als teuersten beschreibt, nur diesmal in meiner eigenen
|
||||
Berichterstattung. Richtiggestellt, dann behoben — und seither wird jede Behauptung über ein
|
||||
Prüfergebnis am Exit-Status gemessen.
|
||||
- **Zwei Skripte, eine Naht.** `laufzettel.sh` übergibt seine Werte als Umgebungsvariablen, weil
|
||||
`pct exec` keine Befehlszeile durchreicht; `install.sh` las zunächst nur Schalter. Der Dienst wäre
|
||||
auf `127.0.0.1` gebunden geblieben, während der Abschlussbildschirm `http://<ip>:3000` versprach.
|
||||
Nahtstellen zwischen zwei Skripten prüft keine Suite — nur das Lesen beider Seiten.
|
||||
- **Der README nannte Port 5433, die Entwicklungsdatenbank hört auf 5434.** Wer dem README folgte,
|
||||
landete auf der Datenbank eines anderen Projekts. Der Hinweis darauf heißt „0 Treffer“ — genau die
|
||||
Meldung, die in #016 und #018 schon je einen halben Abend gekostet hat.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- Phase 1 ist abgeschlossen und veröffentlicht. Offen für `v0.2.0`: #020–#026, darunter der echte
|
||||
Proxmox-Lauf (#024), der die letzte offene Zeile der Abnahme schließt.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 abgeschlossen: was aus dem Plan wurde
|
||||
|
||||
**35 h 20 min** über vierzehn Meilensteine, geschätzt waren **148 h**. Der Faktor ist kein Verdienst,
|
||||
sondern eine Eigenschaft der Arbeitsweise: parallele Sub-Agenten an Stellen, wo die Schnittstelle
|
||||
vorher feststand, und ein Mensch, der nur an den Nahtstellen eingreift. Die Schätzung war für
|
||||
Menschenarbeit gedacht und bleibt als Maßstab stehen, weil sie den **Umfang** beschreibt.
|
||||
|
||||
### Was entstanden ist
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Quelltext | rund 30.000 Zeilen unter `src/` (ohne erzeugten Prisma-Client) |
|
||||
| Datenmodell | 20 Tabellen, 9 Aufzählungen, 2 Migrationen |
|
||||
| Tests | 1131 Vitest-Tests, 101 Ende-zu-Ende-Abläufe (`INTERN`), 97 (`DIENSTLEISTUNG`) |
|
||||
| Vorlagen | 7 Abläufe mit 155 Schritten, als veröffentlichte Version 1 im Seed |
|
||||
| Dokumentation | Anleitung in neun Kapiteln (72 Seiten PDF), Architektur, Betrieb, 14 Issue-Dateien |
|
||||
|
||||
### Wo die Zeit hinging
|
||||
|
||||
Der teuerste Meilenstein war **#019 Betriebswege** (5 h 25) — und zwar nicht wegen der beiden
|
||||
Skripte, sondern weil dort zum ersten Mal der **dokumentierte Weg eines Dritten** gefahren wurde:
|
||||
Compose-Stand hochziehen, Seed nach Anleitung, Installation in einem leeren Debian. Vier Fehler lagen
|
||||
genau dort, wo keine Suite hinsieht, und die Suite war die ganze Zeit grün.
|
||||
|
||||
Dahinter **#014 QR-Tags** (3 h 35), **#016 Verzeichnis** (3 h 25) und **#013 Läufe** (3 h 05) — alle
|
||||
drei nicht wegen des Umfangs, sondern wegen der Randfälle: ein Scanner, der die native Schnittstelle
|
||||
nie benutzte; ein Abgleich, der bei einem Tippfehler die ganze Firma abgeschaltet hätte; ein Passwort,
|
||||
das zweimal verschieden erzeugt wurde. Keiner dieser Fehler fiel beim Bauen auf. Alle drei fand der
|
||||
unabhängige Review.
|
||||
|
||||
### Was sich als richtig erwiesen hat
|
||||
|
||||
- **Ein Review-Agent je Meilenstein, der den Code nicht geschrieben hat.** In **jedem** der vierzehn
|
||||
fand er etwas, in neun davon etwas Ernstes. Die teuersten Funde waren nie Tippfehler, sondern
|
||||
Zusagen ohne Deckung: ein Kommentar, eine Oberflächenmeldung oder ein Testname, der etwas behauptet,
|
||||
das der Code nicht tut. Das ist der häufigste Fehlertyp dieses Projekts — vor Logikfehlern, weit
|
||||
vor Tippfehlern.
|
||||
- **Regeln in der Datenbank, nicht nur im Dienst.** Zwölf CHECK-Bedingungen, zwei partielle
|
||||
Eindeutigkeitsindizes, vier zusammengesetzte Fremdschlüssel und die Trigger auf veröffentlichten
|
||||
Versionen haben mehrfach Fehler abgefangen, die durch die Service-Schicht gekommen wären — zuletzt
|
||||
in #017, als der Seed eine veröffentlichte Version füllen wollte.
|
||||
- **Gegen den gebauten Stand prüfen.** `next dev` hydriert in dieser Umgebung nicht; ein Screenshot
|
||||
beweist nichts über Bedienbarkeit. Die Regel steht seit #010 in `CLAUDE.md` und hat sich in jedem
|
||||
Oberflächen-Meilenstein bewährt.
|
||||
|
||||
### Was ich anders machen würde
|
||||
|
||||
- **Zweimal gegen einen veralteten Server geprüft** (#016, #018), beide Male eine halbe Stunde. Die
|
||||
Konsequenz steht jetzt in der Arbeitsweise: erst `ps -eo lstart` gegen `.next/BUILD_ID` halten,
|
||||
dann der Seite glauben.
|
||||
- **Einmal in die falsche Datenbank geschrieben** (#017): Testprozess und Server zeigten
|
||||
woandershin, und die Meldung hieß „0 Treffer". Seit #018 prüft das ein `global-setup` vorab.
|
||||
- **Zwei eigene Tests konnten nicht scheitern** (#017, #018). Seitdem gehört die Gegenprobe zum
|
||||
Schreiben eines Tests: einmal absichtlich verletzen und sehen, ob er rot wird.
|
||||
- **Parallele Agenten im selben Verzeichnis** haben sich einmal gegenseitig eine Datei überschrieben
|
||||
(#018). Die Trennung muss an Dateien verlaufen, nicht an Themen.
|
||||
|
||||
### Was bewusst offen bleibt
|
||||
|
||||
Sechs Folge-Issues für `v0.2.0`: Vorgesetzter für von Hand angelegte Personen (#020), stabiler
|
||||
Schlüssel für Seed-Vorlagen (#021), Schnellstart je Gerätetyp pflegen (#022), Protokollansicht
|
||||
(#023). Dazu die Punkte, die der Abnahme-Review aufwirft. Nichts davon blockiert den Betrieb; alles
|
||||
davon steht geschrieben, statt verschwiegen zu werden.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# #019 M14 – Betriebswege und Release v0.1.0
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.1.0
|
||||
**Labels:** phase-1, infra, docs
|
||||
**Geschätzt:** 10 h **Tatsächlich:** –
|
||||
**Geschätzt:** 10 h **Tatsächlich:** 5 h 25 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -11,30 +11,30 @@ Zwei erprobte Wege in den Betrieb und ein sauberes erstes Release.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] `deploy/install.sh`: native Installation auf Debian 13 — PostgreSQL 17 mit `listen_addresses = ''` (nur Unix-Socket), Node 24 aus dem NodeSource-Repo, corepack/pnpm, Dienstnutzer `laufzettel` ohne Login, Quellcode nach `/opt/laufzettel`, `pnpm install`, `pnpm build`, `prisma migrate deploy`, `prisma db seed`
|
||||
- [ ] Geheimnisse im Zielsystem erzeugen: `APP_SECRET` per `openssl rand -base64 48`, Zufallspasswörter für Datenbank und Admin; Ablage in `/etc/laufzettel/laufzettel.env` mit Modus 0600 und Eigentümer root
|
||||
- [ ] `laufzettel.service`: systemd-Unit für den Next-standalone-Server, `User=laufzettel`, `EnvironmentFile=`, `NoNewPrivileges=yes`, `ProtectSystem=strict`, `PrivateTmp=yes`, `Restart=on-failure`
|
||||
- [ ] Erneuter Aufruf aktualisiert statt neu anzulegen: `git pull`, `pnpm build`, `migrate deploy`, `systemctl restart`; Geheimnisse und Daten bleiben
|
||||
- [ ] `deploy/proxmox/laufzettel.sh`: One-Liner für die Proxmox-Shell — `set -Eeuo pipefail`, `trap` mit Zeilennummer, farbige Ausgabe, Spinner, Prüfung auf PVE 8/9 und root
|
||||
- [ ] `whiptail`-Dialog Standard gegen Erweitert; Standard: nächste freie CTID, Hostname `laufzettel`, Debian 13, 2 Kerne, 2 GB RAM, 8 GB Platte, unprivilegiert, DHCP auf `vmbr0`
|
||||
- [ ] Debian-Vorlage bei Bedarf per `pveam` holen, `pct create`, starten, `deploy/install.sh` im Container ausführen
|
||||
- [ ] `--dry-run`: gibt alle `pct`-Aufrufe nur aus, ohne etwas anzulegen
|
||||
- [ ] Abschlussbildschirm: URL, Admin-Kennung, erzeugtes Passwort, Hinweis auf Reverse Proxy und `docs/betrieb/`
|
||||
- [ ] `docs/betrieb/`: `inbetriebnahme.md`, `proxmox.md`, `reverse-proxy.md`, `authentik.md`, `sicherung.md` (`pg_dump` über den Dienstnamen bzw. den Unix-Socket)
|
||||
- [ ] Prüfliste für den Lauf auf echtem Proxmox, da ich den `pct`-Teil hier nicht ausführen kann
|
||||
- [ ] `docs/abnahme-mvp.md` abschließend durchgehen, Review-Agent gegen alle Kriterien, Befunde als Issues
|
||||
- [ ] Devlog-Phasenzusammenfassung: Gesamtstunden, Stunden je Meilenstein, größte Zeitfresser, Abweichung vom Plan
|
||||
- [x] `deploy/install.sh`: native Installation auf Debian 13 — PostgreSQL 17 mit `listen_addresses = ''` (nur Unix-Socket), Node 24 aus dem NodeSource-Repo, corepack/pnpm, Dienstnutzer `laufzettel` ohne Login, Quellcode nach `/opt/laufzettel`, `pnpm install`, `pnpm build`, `prisma migrate deploy`, `prisma db seed`
|
||||
- [x] Geheimnisse im Zielsystem erzeugen: `APP_SECRET` per `openssl rand -base64 48`, Zufallspasswörter für Datenbank und Admin; Ablage in `/etc/laufzettel/laufzettel.env` mit Modus 0600 und Eigentümer root
|
||||
- [x] `laufzettel.service`: systemd-Unit für den Next-standalone-Server, `User=laufzettel`, `EnvironmentFile=`, `NoNewPrivileges=yes`, `ProtectSystem=strict`, `PrivateTmp=yes`, `Restart=on-failure`
|
||||
- [x] Erneuter Aufruf aktualisiert statt neu anzulegen: `git pull`, `pnpm build`, `migrate deploy`, `systemctl restart`; Geheimnisse und Daten bleiben
|
||||
- [x] `deploy/proxmox/laufzettel.sh`: One-Liner für die Proxmox-Shell — `set -Eeuo pipefail`, `trap` mit Zeilennummer, farbige Ausgabe, Spinner, Prüfung auf PVE 8/9 und root
|
||||
- [x] `whiptail`-Dialog Standard gegen Erweitert; Standard: nächste freie CTID, Hostname `laufzettel`, Debian 13, 2 Kerne, 2 GB RAM, 8 GB Platte, unprivilegiert, DHCP auf `vmbr0`
|
||||
- [x] Debian-Vorlage bei Bedarf per `pveam` holen, `pct create`, starten, `deploy/install.sh` im Container ausführen
|
||||
- [x] `--dry-run`: gibt alle `pct`-Aufrufe nur aus, ohne etwas anzulegen
|
||||
- [x] Abschlussbildschirm: URL, Admin-Kennung, erzeugtes Passwort, Hinweis auf Reverse Proxy und `docs/betrieb/`
|
||||
- [x] `docs/betrieb/`: `inbetriebnahme.md`, `proxmox.md`, `reverse-proxy.md`, `authentik.md`, `sicherung.md` (`pg_dump` über den Dienstnamen bzw. den Unix-Socket)
|
||||
- [x] Prüfliste für den Lauf auf echtem Proxmox, da ich den `pct`-Teil hier nicht ausführen kann
|
||||
- [x] `docs/abnahme-mvp.md` abschließend durchgehen, Review-Agent gegen alle Kriterien, Befunde als Issues
|
||||
- [x] Devlog-Phasenzusammenfassung: Gesamtstunden, Stunden je Meilenstein, größte Zeitfresser, Abweichung vom Plan
|
||||
- [ ] `CHANGELOG.md` für `v0.1.0` mit Datum, Version in `package.json`, Tag `v0.1.0`, Gitea-Release mit Changelog-Auszug, Anleitung als PDF beigelegt
|
||||
|
||||
- [ ] Die Anleitung als PDF (`pnpm docs:pdf`) dem Gitea-Release als Datei beilegen — im Repo liegt sie nicht, sie entsteht aus den Kapiteln und Bildern
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] `docker run --rm debian:13` mit `deploy/install.sh` installiert vollständig durch: Postgres läuft, Migration und Seed gelaufen, Dienst antwortet auf `/api/gesundheit`
|
||||
- [ ] Im nativen Weg lauscht PostgreSQL auf keinem TCP-Port (`ss -ltn` zeigt 5432 nicht)
|
||||
- [ ] Ein zweiter Lauf von `deploy/install.sh` aktualisiert und löscht keine Daten
|
||||
- [ ] `bash deploy/proxmox/laufzettel.sh --dry-run` gibt einen plausiblen, vollständigen `pct create`-Aufruf aus
|
||||
- [ ] `shellcheck --severity=warning` über alle Skripte ohne Befund
|
||||
- [ ] Im Compose-Weg ist die Datenbank vom Host nicht erreichbar, aus `web` schon
|
||||
- [x] `docker run --rm debian:13` mit `deploy/install.sh` installiert vollständig durch: Postgres läuft, Migration und Seed gelaufen, Dienst antwortet auf `/api/gesundheit`
|
||||
- [x] Im nativen Weg lauscht PostgreSQL auf keinem TCP-Port (`ss -ltn` zeigt 5432 nicht)
|
||||
- [x] Ein zweiter Lauf von `deploy/install.sh` aktualisiert und löscht keine Daten
|
||||
- [x] `bash deploy/proxmox/laufzettel.sh --dry-run` gibt einen plausiblen, vollständigen `pct create`-Aufruf aus
|
||||
- [x] `shellcheck --severity=warning` über alle Skripte ohne Befund
|
||||
- [x] Im Compose-Weg ist die Datenbank vom Host nicht erreichbar, aus `web` schon
|
||||
- [ ] `pnpm pruefe:doku` grün, `v0.1.0` getaggt und als Gitea-Release veröffentlicht
|
||||
- [ ] Alle Kriterien aus `docs/abnahme-mvp.md` abgehakt oder als Folge-Issue erfasst
|
||||
- [x] Alle Kriterien aus `docs/abnahme-mvp.md` abgehakt oder als Folge-Issue erfasst
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# #024 Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen
|
||||
|
||||
**Status:** offen
|
||||
**Im Tracker:** –
|
||||
**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
|
||||
@@ -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** |
|
||||
|
||||
Reference in New Issue
Block a user