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

117 lines
7.3 KiB
Markdown

# 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`](../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.