Files
rustification-server/docs/realtime/issues/03-research-ws-auth-reconnect.md
MDietermann 55a2af4756 docs(realtime): record the design decisions behind the realtime layer
Eleven resolved decision tickets plus two research notes, produced with
/wayfinder before any code was written. They live next to the code they explain
rather than in the issue tracker: they are reasoning documents, not open work,
and in a tracker they would drift from the code with no diff and no review.

The value is in the rejected alternatives. Why a counter under a row lock and
not BIGSERIAL, why an event log and not a column on messages, why resync is a
200 with a discriminated body and not a 4xx, why the fan-out trait was deleted
again, why voice is peer-to-peer with a hard six-person cap and no speculative
SFU seam, and why Linux needs a native media stack while the other three
platforms do not.

Two entries record where the spec was wrong and got corrected: created_at was
never a safe sort key, and an unread count cannot be derived without fetching
because seq counts events rather than messages.

Open work is tracked as issues, not here.
2026-08-14 19:04:41 +02:00

7.3 KiB

Recherche: WS-Auth, Heartbeat und Reconnect

Type: research Status: resolved Blocked by: — Parent: ../map.md

Question

Welche etablierten Muster gibt es für Authentifizierung, Verbindungserhalt und Reconnect bei langlebigen WebSocket-Verbindungen — und welche davon passen zu Axum 0.8 und einem JWT-basierten Auth-Modell?

Zu klären

  • Token-Übergabe beim Handshake. Die Browser-WebSocket-API erlaubt keine benutzerdefinierten Header. Übliche Auswege: Token im Query-String (landet in Server-Logs — bei diesem Bedrohungsmodell heikel), Sec-WebSocket-Protocol-Missbrauch, Cookie, oder ein erstes Auth-Frame nach dem Handshake mit kurzem Timeout. Was ist gängige Praxis, was sind die dokumentierten Nachteile? Achtung: falls die Verbindung im Tauri-Rust-Layer lebt (siehe Wo lebt die WS-Verbindung im Client), entfällt die Header-Beschränkung komplett — die Recherche soll beide Fälle abdecken.
  • Token-Ablauf bei laufender Verbindung. Das JWT ist beim Handshake gültig und läuft nach Minuten ab, während die Verbindung Stunden steht. Muster: Verbindungs-TTL mit erzwungenem Reconnect, In-Band-Re-Auth-Frame, serverseitige Revocation-Liste. Was machen andere?
  • Heartbeat. WebSocket hat Ping/Pong auf Protokollebene. Wann reicht das, wann braucht es Application-Level-Heartbeats? Übliche Intervalle. Verhalten hinter Proxies und Corporate-Firewalls (relevant: Firmenkunden sind Zielgruppe).
  • Reconnect-Strategie. Exponentielles Backoff mit Jitter — konkrete Parameter aus der Praxis. Thundering-Herd-Verhalten, wenn ein Server neu startet und alle Clients gleichzeitig zurückkommen.
  • Axum 0.8 konkret. Wie sieht ein WebSocket-Upgrade mit State-Zugriff und Middleware aus? Greift middleware::from_fn_with_state beim Upgrade-Request? Wie werden viele gleichzeitige Verbindungen sauber verwaltet und beim Shutdown beendet?
  • tokio::sync::broadcast und langsame Empfänger. Der Kanal verwirft Nachrichten für hinterherhinkende Receiver (RecvError::Lagged). Wie behandelt man das korrekt, wenn „Lagged" eigentlich „Resync jetzt" bedeuten soll? Gibt es dafür ein etabliertes Muster oder bessere Kanaltypen für diesen Zweck?

Quellenqualität

Axum-/Tokio-Dokumentation und Quellcode, RFC 6455, MDN. Praxisberichte nur mit Datum.

Erwartetes Ergebnis

Eine Markdown-Datei im Repo: pro Teilfrage die Optionen mit ihren dokumentierten Vor- und Nachteilen, jeweils belegt. Keine Empfehlung — die Entscheidung fällt im Ticket Verbindungsaufbau & Auth über WS, dieses Ticket liefert nur die Faktenlage.

Answer

Vollständiges Ergebnis: ../research/03-ws-auth-reconnect.md — Faktenlage zu allen sechs Teilfragen, Quellenverzeichnis und ein Anhang „Was nicht belegt werden konnte" mit 17 Punkten. Keine Empfehlung, wie gefordert.

Die vier Befunde, die andere Tickets verändern

1. Der Authorization-Header ist der leiseste Kanal — und das ist belegbar, nicht Folklore. In den Default-Konfigurationen von nginx, Apache und Caddy landet der Query-String im Access-Log; Caddy redigiert Authorization aktiv zu REDACTED, request.uri dagegen nicht. Kein Transportweg schützt vor dem Betreiber selbst — der Server muss das Token sehen. Der Unterschied zwischen den Optionen ist ausschließlich, was den Handshake in Logfiles, Backups und APM-Systemen überlebt. Nebenbei entkräftet: die verbreiteten Referrer- und Browser-History-Argumente gegen den Query-String ließen sich für new WebSocket(url) nicht belegen und gehören nicht ins Entscheidungsticket.

2. Sec-WebSocket-Protocol ist für dieses Bedrohungsmodell schlechter als gedacht — Caddy loggt per Default alle Header außer der Redaktionsliste, und dieser gehört nicht dazu. Dazu kommt: ein JWT ist zwar ein legaler Subprotocol-Token, aber = ist als Separator verboten (Base64-Padding), und der Browser erzwingt ein Echo des Subprotokolls durch den Server.

3. Die Browser-API verschluckt alle Fehlerdetails — der Rust-Layer nicht. WHATWG schreibt vor, dass DNS-Fehler, Connection-Refused, TLS-Fehler, Subprotocol-Mismatch und Abbruch nach dem Handshake für das Skript ununterscheidbar sein müssen: immer CloseEvent.code === 1006. Im WebView ist „Token abgelaufen" also nicht von „Server down" unterscheidbar. tungstenite::Error::Http trägt dagegen die vollständige HTTP-Response inklusive Statuscode und Body. Diese Asymmetrie zieht sich durch fast jede Teilfrage → starkes Argument in Wo lebt die WS-Verbindung im Client.

4. axum::serve(...).with_graceful_shutdown(...) beendet WebSocket-Verbindungen nicht und wartet nicht auf sie. Belegt vierfach: der close_rx-Guard hängt nur an der hyper-Task, das on_upgrade-tokio::spawn nicht; UpgradeableConnection::graceful_shutdown ist nach dem Hijack laut hyper-util-Doku „does nothing"; axum-Issue #3003 (offen seit 21.10.2024) und PR #3831 (offen seit 11.07.2026) beschreiben genau das. Das Projekt muss WS-Verbindungen selbst tracken und beenden — CancellationToken + TaskTracker sind die belegten Bausteine.

Weitere Faktenlage, kompakt

  • middleware::from_fn_with_state greift beim Upgrade-Request — das Upgrade ist ein normaler GET, 401/403 sind regulär sendbar.
  • Token-Ablauf: SignalR dokumentiert ausdrücklich, dass Auth nicht revalidiert wird. Optionen sind TTL/Reconnect (AWS API GW: 2 h / 10 min idle, hart), In-Band-Refresh (Centrifugo, Ably — Token landet dann in keinem Log) oder eine Revocation-Liste (im Betreiber-Angreifer-Modell per Definition wirkungslos).
  • Heartbeat: Protokoll-Ping ist aus dem WebView nicht auslösbar (WHATWG), im Rust-Layer antwortet tungstenite automatisch. Proxy-Korridor der dokumentierten Idle-Timeouts: Azure AppGW 20 s, GCP 30 s, nginx 60 s. Die verbreiteten Cloudflare-„100 s" und die HAProxy-Defaults sind Mythen und ließen sich nicht belegen. Etablierte Intervalle: Socket.IO 25 s, Phoenix 30 s, Centrifugo 25 s.
  • Reconnect: Praxiswerte reichen von 10 ms (Phoenix, ohne Jitter) bis 15 s (Ably). Gegen Thundering Herd sind vier Ansätze belegt: Client-Jitter, server-gesteuerter Hinweis (Retry-After, Discord Op 7), Draining und Accept-Rate-Limiting — mit Postmortems von Discord (29.04.2026) und Slack (15.03.2021).
  • RecvError::Lagged(n): n = übersprungene Nachrichten, der Receiver bleibt brauchbar, der Cursor springt auf die älteste noch vorhandene Nachricht. ⚠️ „Lagged = Resync" ist nirgends als offizielles Muster dokumentiert — gängige Community-Praxis, aber ohne Primärquelle. Belegbar sind nur die Bausteine (resubscribe(), Receiver::len() als Frühwarnung, mpsc::try_send pro Client).
  • tokio::sync::watch passt auffällig gut zum Dumb Pipe — „retains only the last sent value" ist exakt die Semantik von „der Cursor der Conversation X ist jetzt N". Kein Lag-Fehler möglich. → Fan-out-Architektur.
  • PgListener belegt eine dedizierte Postgres-Connection außerhalb des Pools (aktuell max_connections = 10), reconnected aber selbst. NOTIFY ist transaktional. Neue Gefahr: eine volle Notify-Queue lässt Transaktionen beim Commit fehlschlagen.
  • Bestandsaufnahme: axum = "0.8.9" ist ohne ws-Feature eingebunden, es gibt keinen Graceful Shutdown, und Extension<Claims> wird nirgends gelesen.