245 lines
11 KiB
Markdown
245 lines
11 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
|
||
}
|
||
}
|
||
```
|
||
|
||
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.
|
||
|
||
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.
|