Files
Laufzettel/docs/betrieb/reverse-proxy.md
T
Florian Wilde bf1e52cf73
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
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
2026-09-26 15:53:01 +02:00

10 KiB
Raw Blame History

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 wie nativ im LXC.

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.

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.

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:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

nginx

Mehr Zeilen, und eine Falle. Die vollständige Datei:

# 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:

sudo nginx -t && sudo systemctl reload nginx

Das Zertifikat holt certbot:

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.

# 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.