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:
@@ -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.
|
||||
Reference in New Issue
Block a user