Die Abarbeitungsansicht fragte alle zehn Sekunden nach dem Zustand. Jetzt melden vier Datenbank-Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine LISTEN-Verbindung je Prozess hoert zu, und der Route Handler schiebt weiter. Gemessen: der Haken des Kollegen ist nach 485 ms beim Zuschauer (am Handy 487 ms), vorher bis zu zehn Sekunden. Zwanzig offene Laschen kosten nichts mehr statt zwanzig Abfragen je zehn Sekunden. Das Ereignis kommt aus der Datenbank und nicht aus dem Dienst, aus demselben Grund, aus dem auch die Regeln dort stehen: ein Trigger sieht auch den Seed, eine Migration und die Reparatur per psql. Und NOTIFY erreicht jede Instanz der Anwendung, ohne einen zweiten Dienst. Welche Tabellen melden, haengt an dem, was `laufZustand` liefert — nicht daran, was das Issue vorgeschlagen hat. Mit Triggern nur auf Schrittergebnis und Lauf waere der Strom ein **Rueckschritt** gewesen: das Polling verglich den ganzen Stand und sah deshalb auch Kommentare, ein Strom sieht nur, was gemeldet wird. Der Kommentar des Kollegen waere nie angekommen, und die Seite haette ausgesehen wie immer. Es melden jetzt vier Tabellen, und `laufZustand` traegt `anzahlAnhaenge` — ein Ereignis ohne Entsprechung im Zustand loest nichts aus. Das Polling bleibt als Rueckfall an **derselben** Adresse, unterschieden nur am Accept-Kopf: eine Rechtepruefung, ein Lesemodell, eine Nutzlast. Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand rechtErforderlich vergessen kann. Bei erreichter Obergrenze antwortet der Handler gewoehnlich mit JSON; EventSource meldet darauf einen Fehler und versucht es nicht wieder, also faellt der Client von sich aus zurueck — ohne Sonderfall. Nebenbei zwei Fehler in docs/betrieb/reverse-proxy.md behoben, beide aus #52: `client_max_body_size 8m` bei `ANHANG_MAX_MB=10` haette ein 10-MB-Foto mit 413 abgewiesen, bevor die eigene Meldung zum Zuge kommt. Closes #53 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
270 lines
12 KiB
Markdown
270 lines
12 KiB
Markdown
# 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
|
||
|
||
# Die Live-Aktualisierung ist ein Ereignisstrom (#033). Caddy erkennt das
|
||
# heute an `Content-Type: text/event-stream` und reicht von sich aus
|
||
# sofort durch – ausdruecklich gesetzt bleibt es auch dann richtig, wenn
|
||
# eine Fassung das einmal anders handhabt. Ohne sofortiges Durchreichen
|
||
# sammelt der Proxy den Strom, und die Seite sieht aus, als sei sie
|
||
# haengen geblieben.
|
||
flush_interval -1
|
||
}
|
||
}
|
||
```
|
||
|
||
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, und
|
||
# seit #032 haengt jemand Fotos an Schritte. nginx laesst von sich aus nur
|
||
# 1 MB durch und waere damit die erste Wand.
|
||
#
|
||
# **Muss ueber `ANHANG_MAX_MB` liegen.** Steht dort die Vorgabe 10, dann
|
||
# reichen 8m nicht: ein 10-MB-Foto scheitert an nginx mit „413 Request
|
||
# Entity Too Large“, und die Meldung der Anwendung („Erlaubt sind hoechstens
|
||
# 10,0 MB“) kommt nie zum Zuge. Wer die Grenze in der Anwendung anhebt,
|
||
# hebt sie hier mit an — mit etwas Luft fuer den Rahmen des Formulars.
|
||
client_max_body_size 12m;
|
||
|
||
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;
|
||
|
||
# Die Abarbeitungsansicht haelt seit #033 einen **offenen** Strom auf
|
||
# /api/laeufe/<id>/zustand. Der Server schickt alle 25 s einen
|
||
# Herzschlag, damit nginx die stille Verbindung nicht schliesst; 90 s
|
||
# sind reichlich dafuer und fangen einen langsamen Verzeichnis-Abgleich
|
||
# mit ab. **Unter 30 s darf dieser Wert nicht fallen**, sonst reisst der
|
||
# Strom zwischen zwei Herzschlaegen immer wieder ab.
|
||
proxy_read_timeout 90s;
|
||
|
||
# Next liefert Seiten stroemend aus, und die Live-Aktualisierung ist ein
|
||
# Ereignisstrom. Ohne das haelt nginx die Antwort zusammen: die Seite
|
||
# erscheint spuerbar spaeter auf einmal, und vom Strom kommt gar nichts
|
||
# an — sie sieht dann aus, als sei sie haengen geblieben.
|
||
#
|
||
# Die Anwendung schickt zusaetzlich `X-Accel-Buffering: no` auf der
|
||
# Antwort des Stroms. Das wirkt auch, wenn hier `on` stuende, und deckt
|
||
# genau diesen einen Pfad ab; die Zeile hier gilt fuer alles andere.
|
||
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.
|
||
|
||
Im Fehlerfall sagt er nur `{"status":"fehler"}` und **nicht, warum** — der Pfad ist ohne Anmeldung
|
||
erreichbar, und eine Datenbankmeldung nennt Host, Port, Benutzernamen und Datenbanknamen. Die
|
||
Begründung steht im Protokoll des Prozesses: `docker compose logs web` beziehungsweise
|
||
`journalctl -u laufzettel -n 50`.
|
||
|
||
**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.
|