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.
This commit is contained in:
207
docs/realtime/issues/07-verbindungsaufbau-und-auth.md
Normal file
207
docs/realtime/issues/07-verbindungsaufbau-und-auth.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Verbindungsaufbau & Auth über WS
|
||||
|
||||
Type: grilling
|
||||
Status: resolved
|
||||
Blocked by: —
|
||||
Parent: ../map.md
|
||||
|
||||
## Question
|
||||
|
||||
Wie authentifiziert sich eine WebSocket-Verbindung, wie bleibt sie am Leben, und was
|
||||
passiert, wenn das Token abläuft, während sie steht?
|
||||
|
||||
Die Faktenlage liegt vor: [*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md)
|
||||
(**entblockt am 14.08.2026**), Volltext in
|
||||
[`../research/03-ws-auth-reconnect.md`](../research/03-ws-auth-reconnect.md). Dieses Ticket
|
||||
entscheidet.
|
||||
|
||||
### Vier Befunde, die die Optionen bereits verschoben haben
|
||||
|
||||
1. **Kein Transportweg schützt vor dem Betreiber** — der Server muss das Token sehen. Der
|
||||
Unterschied ist ausschließlich, was den Handshake in Logs, Backups und APM-Systemen
|
||||
**überlebt**. In den Defaults von nginx, Apache und Caddy landet der Query-String im
|
||||
Access-Log; Caddy redigiert `Authorization` aktiv, `request.uri` nicht.
|
||||
2. **`Sec-WebSocket-Protocol` ist schlechter als erwartet** — Caddy loggt per Default alle
|
||||
Header außer der Redaktionsliste, und dieser gehört nicht dazu. Dazu: `=` ist als
|
||||
Separator verboten (JWT-Base64-Padding), und der Server muss das Subprotokoll echoen.
|
||||
3. **Die Browser-API verschluckt alle Fehlerdetails** (WHATWG-Vorgabe): im WebView ist
|
||||
„Token abgelaufen" nicht von „Server down" unterscheidbar — immer `CloseEvent.code === 1006`.
|
||||
`tungstenite::Error::Http` trägt dagegen die vollständige Response. Wenn die Verbindung im
|
||||
Rust-Layer lebt, ist `Authorization: Bearer` möglich **und** die Fehlerdiagnose brauchbar;
|
||||
beides zusammen macht die Entscheidung in
|
||||
[*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md) fast zur Vorentscheidung
|
||||
für dieses Ticket.
|
||||
4. **Verworfen, nicht übernehmen:** Referrer-Leak und Browser-History als Argumente gegen den
|
||||
Query-String ließen sich für `new WebSocket(url)` **nicht** belegen. Belegbar bleiben
|
||||
Access-Logs, Log-Aggregation und deren Backups — das reicht als Argument.
|
||||
|
||||
### Zusätzlich zu entscheiden: Graceful Shutdown selbst bauen
|
||||
|
||||
`axum::serve(...).with_graceful_shutdown(...)` **beendet WebSocket-Verbindungen nicht und
|
||||
wartet nicht auf sie** — vierfach belegt (hyper-util-Doku „does nothing" nach dem Hijack,
|
||||
axum-Issue #3003 offen seit 21.10.2024, PR #3831 offen seit 11.07.2026, und der `close_rx`-Guard
|
||||
hängt nur an der hyper-Task). Solange #3831 nicht gemerged ist, muss das Projekt WS-Verbindungen
|
||||
selbst tracken. Belegte Bausteine: `CancellationToken` (ein Child-Token pro Verbindung) plus
|
||||
`TaskTracker` (auf N Tasks warten). Beim Shutdown einen Close-Frame senden — `WebSocket::close`
|
||||
existiert seit axum 0.8 nicht mehr, der Frame geht explizit über `send`; Code 1001 „going away"
|
||||
oder 1012 „service restart".
|
||||
|
||||
Weitere belegte Randbedingungen: `middleware::from_fn_with_state` **greift** beim
|
||||
Upgrade-Request (401/403 regulär sendbar); Protokoll-Ping ist aus dem WebView **nicht auslösbar**;
|
||||
dokumentierter Proxy-Idle-Korridor: Azure AppGW 20 s, GCP 30 s, nginx 60 s (die verbreiteten
|
||||
Cloudflare- und HAProxy-Werte sind Mythen und ließen sich nicht belegen).
|
||||
|
||||
**Bestandsaufnahme:** `axum = "0.8.9"` ist **ohne `ws`-Feature** eingebunden, es gibt keinen
|
||||
Graceful Shutdown, und `Extension<Claims>` wird nirgends gelesen.
|
||||
|
||||
---
|
||||
|
||||
## Answer
|
||||
|
||||
Entschieden am 14.08.2026. Bestand: Access-Token 15 Minuten (`auth.rs:22`), Refresh-Token
|
||||
30 Tage, gehasht in `auth_tokens` und löschbar — Widerruf existiert bereits auf Refresh-Ebene.
|
||||
|
||||
### 1. Handshake: `Authorization: Bearer` (abgeleitet)
|
||||
|
||||
Folgt aus [*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md): die
|
||||
Verbindung lebt in Rust, eigene Header sind also möglich. Laut Recherche der einzige Kanal,
|
||||
den die Defaults von nginx, Apache und Caddy nicht mitschreiben.
|
||||
`middleware::from_fn_with_state` greift beim Upgrade-Request, ein 401 mit Body ist regulär
|
||||
sendbar — und `tungstenite::Error::Http` trägt ihn vollständig zum Client.
|
||||
|
||||
### 2. Token-Ablauf: In-Band-Re-Auth über den offenen Socket
|
||||
|
||||
Der Client erneuert sein Access-Token wie bisher per REST und schiebt es als Frame über den
|
||||
bestehenden Socket; der Server prüft und verlängert. Kein Reconnect, keine Handshake-Welle,
|
||||
kein erneuter Manifest-Abruf — bei 15 Minuten Tokenlebensdauer und drei verbundenen Servern
|
||||
wären das sonst zwölf Reconnects pro Stunde.
|
||||
|
||||
Der Socket ist für Voice-Signaling ohnehin bidirektional; dies ist damit der **zweite**
|
||||
client→server-Frame-Typ, nicht der erste.
|
||||
|
||||
**Widerruf wirkt trotzdem:** schlägt die REST-Erneuerung fehl, weil das Refresh-Token
|
||||
gelöscht wurde, kann der Client nicht verlängern, und der Server schließt bei Ablauf mit
|
||||
`4001`. Die Wirkungsverzögerung ist damit auf die Restlaufzeit des Access-Tokens begrenzt,
|
||||
also höchstens 15 Minuten — dieselbe Schranke wie bei einer Verbindungs-TTL.
|
||||
|
||||
Verworfen: eine serverseitige Widerrufsliste. Sie machte Widerruf sofort wirksam, kostet aber
|
||||
geteilten Zustand über Instanzen hinweg plus eine Prüfung je Event — und gegen den Betreiber
|
||||
als Angreifer wirkt sie per Definition gar nicht, weil er die Liste kontrolliert.
|
||||
|
||||
### 3. Autorisierung: Registry ist maßgeblich, REST prüft je Anfrage
|
||||
|
||||
Die Zustellung folgt der Registry aus [*Fan-out-Architektur*](08-fan-out-architektur.md). Die
|
||||
**Sicherheitsgrenze liegt bei den REST-Endpunkten** — Manifest, Backfill und Historie prüfen
|
||||
Mitgliedschaft bei jeder Anfrage.
|
||||
|
||||
Das trägt, weil der Event nur `{conversation, seq}` enthält und **keinen Inhalt**. Eine
|
||||
Abweichung der Registry von der Datenbank leckt folglich nur Metadaten („Conversation X war
|
||||
um 14:03 aktiv"), niemals Inhalte — und Metadaten gegenüber dem Server hat die Map
|
||||
ausdrücklich als akzeptabel eingestuft.
|
||||
|
||||
Verworfen: eine Datenbankprüfung je Event (bei einem Channel mit fünfhundert Mitgliedern
|
||||
fünfhundert Abfragen für eine Nachricht, zusätzlich zum Backfill, der ohnehin prüft) und
|
||||
periodische Neuvalidierung (ein weiterer Mechanismus ohne nachgewiesenes Driftproblem —
|
||||
bleibt als billiges Sicherheitsnetz in der Hinterhand).
|
||||
|
||||
### 4. Heartbeat: der Client pingt alle 15 Sekunden
|
||||
|
||||
Protokoll-Ping, kein Application-Frame — im WebView wäre das nicht möglich, in Rust schon.
|
||||
Der Server antwortet (tungstenite automatisch) und führt `last_seen`; nach zwei ausbleibenden
|
||||
Intervallen räumt er die Registry auf. Der Client erkennt eine tote Verbindung am
|
||||
ausbleibenden Pong. **Eine Richtung bedient beide Bedürfnisse.**
|
||||
|
||||
Der Client treibt, weil er hinter NAT und Proxy sitzt und sein Intervall situativ anpassen
|
||||
kann — im Hintergrund oder bei schlechtem Funk seltener, ohne dass der Server davon wissen
|
||||
muss. Bei N verbundenen Servern kann er die Pings zeitlich verteilen statt sie zu bündeln.
|
||||
|
||||
**15 Sekunden**, um unter dem striktesten dokumentierten Proxy-Idle-Timeout der Recherche zu
|
||||
bleiben (Azure Application Gateway, 20 s). Konfigurierbar — die meisten Aufstellungen sind
|
||||
nginx mit 60 s. Die verbreiteten Cloudflare- und HAProxy-Werte ließen sich laut Recherche
|
||||
nicht belegen und sind nicht Grundlage dieser Wahl.
|
||||
|
||||
### 5. Verbindungslimit: acht je Nutzer, die älteste weicht
|
||||
|
||||
Bei Überschreitung wird die **älteste** Verbindung mit `4002` getrennt, nicht die neue
|
||||
abgewiesen. Das ist der wichtige Teil: ein Gerät mit hängengebliebener Verbindung könnte sich
|
||||
sonst selbst aussperren — und genau das passiert auf Mobile regelmäßig, wenn das
|
||||
Betriebssystem eine App abräumt, ohne dass der Socket sauber schließt.
|
||||
|
||||
Acht ist großzügig gegenüber dem legitimen Fall (Desktop, Handy, Tablet, Zweitrechner) und
|
||||
trotzdem eine harte Obergrenze. Gezählt wird in der `user_id`-Registry, die es seit
|
||||
Ticket 08 ohnehin gibt.
|
||||
|
||||
Ein Rate-Limit auf Handshakes wurde erwogen und **nicht** jetzt entschieden: es trifft ein
|
||||
anderes Missbrauchsmuster, ist aber ohne Betriebserfahrung nicht kalibrierbar — ein
|
||||
Reconnect-Sturm nach einem Server-Neustart sieht genauso aus wie ein Angriff. Bleibt in der
|
||||
Fog-Notiz zum Rate-Limiting.
|
||||
|
||||
### 6. Close-Codes und Reconnect-Politik
|
||||
|
||||
Drei Handlungsklassen im Client:
|
||||
|
||||
| Code | Bedeutung | Klasse |
|
||||
|---|---|---|
|
||||
| `1012` | Server-Neustart | **sofort neu verbinden**, mit Jitter |
|
||||
| `4001` | Token abgelaufen, keine gültige Re-Auth | Token erneuern, dann sofort neu verbinden; scheitert die Erneuerung → Anmeldung |
|
||||
| `4002` | durch neuere Verbindung verdrängt | **nicht** neu verbinden |
|
||||
| `4003` | Konto gesperrt oder gelöscht | **nicht** neu verbinden, zur Anmeldung |
|
||||
| `1001`, `1006`, alles Übrige | Netzfehler, unbekannt | mit Backoff neu verbinden |
|
||||
|
||||
`4002` ist der Grund, warum es diese Tabelle überhaupt gibt: ohne ein „nicht neu verbinden"
|
||||
verdrängt ein verdrängtes Gerät sofort das nächste, und bei mehr aktiven Geräten als erlaubt
|
||||
entsteht ein Kreisverkehr.
|
||||
|
||||
Der Nutzen reicht über den Reconnect hinaus: der Client muss je Server einen aussagekräftigen
|
||||
Zustand anzeigen, und „Anmeldung abgelaufen" von „Server nicht erreichbar" zu unterscheiden
|
||||
geht nur so.
|
||||
|
||||
**Backoff (abgeleitet):** Full Jitter nach dem in der Recherche belegten Muster —
|
||||
`warte = random(0, min(30 s, 0,5 s · 2^Versuch))`. Der Zufallsanteil ist nicht optional; ohne
|
||||
ihn kehren nach einem Neustart alle Clients gleichzeitig zurück.
|
||||
|
||||
### 7. Graceful Shutdown selbst bauen (abgeleitet)
|
||||
|
||||
Vierfach belegt in der Recherche: `axum::serve(...).with_graceful_shutdown(...)` beendet
|
||||
WebSocket-Verbindungen nicht und wartet nicht auf sie (axum-Issue #3003 offen seit
|
||||
21.10.2024, PR #3831 offen seit 11.07.2026). Bausteine:
|
||||
|
||||
- Ein `CancellationToken` je Verbindung, abgeleitet vom globalen Shutdown-Token.
|
||||
- Ein `TaskTracker`, um beim Herunterfahren auf die Verbindungstasks zu warten.
|
||||
- Beim Shutdown Close-Frame mit `1012` senden — `WebSocket::close` gibt es seit axum 0.8
|
||||
nicht mehr, der Frame geht explizit über `send`.
|
||||
- `broadcast::Receiver::recv` und `CancellationToken::cancelled` sind dokumentiert cancel-safe;
|
||||
für `SplitStream::next()` fehlt eine explizite Zusage, weshalb die axum-Beispiele
|
||||
durchweg in zwei Tasks aufteilen statt über den Socket-Stream zu selektieren.
|
||||
|
||||
Sobald PR #3831 gemerged ist, kann das eigene Tracking entfallen — bis dahin ist es zwingend.
|
||||
|
||||
### Nebenbefund
|
||||
|
||||
`axum = "0.8.9"` ist heute **ohne das `ws`-Feature** eingebunden; es fehlt also die
|
||||
Voraussetzung überhaupt. Und `Extension<Claims>` wird nirgends gelesen — die Middleware
|
||||
authentifiziert, aber kein Handler nutzt die Identität. Beides gehört in die Umsetzung.
|
||||
|
||||
### Zu entscheiden
|
||||
|
||||
- **Handshake-Auth.** Token im Query-String ist bei diesem Bedrohungsmodell heikel
|
||||
(Server-Logs, Proxy-Logs, Browser-History). Welcher Weg wird es? Falls die Verbindung
|
||||
im Rust-Layer lebt (siehe *Wo lebt die WS-Verbindung im Client*), ist ein
|
||||
`Authorization`-Header möglich — dann ist die Frage fast beantwortet.
|
||||
- **Token-Ablauf bei laufender Verbindung.** Verbindungs-TTL mit erzwungenem Reconnect,
|
||||
In-Band-Re-Auth, oder Revocation-Prüfung. Wichtig: eine Verbindung darf einen
|
||||
**Token-Widerruf nicht überleben** — bei „Nutzersicherheit als Erfolgsfaktor" ist eine
|
||||
Sitzung, die nach dem Logout weiterläuft, ein echter Fehler, kein Schönheitsfleck.
|
||||
- **Autorisierung.** Der Nutzer darf nur Events für Conversations sehen, in denen er
|
||||
Mitglied ist. Wird die Mitgliedschaft beim Verbindungsaufbau einmal geladen oder pro
|
||||
Event geprüft? Was passiert, wenn er während der Verbindung aus einer Conversation
|
||||
entfernt wird — oder hinzugefügt?
|
||||
- **Heartbeat.** Protokoll-Ping/Pong oder Application-Level, mit welchen Intervallen.
|
||||
Zielgruppe Firmen heißt: hinter Proxies, die Idle-Verbindungen abräumen.
|
||||
- **Reconnect-Parameter.** Backoff-Kurve und Jitter, konkret. Was passiert beim
|
||||
Server-Neustart, wenn alle Clients gleichzeitig zurückkommen?
|
||||
- **Verbindungslimits.** Wieviele Verbindungen pro Nutzer sind erlaubt? Multi-Device ist
|
||||
gefordert (Desktop + Mobile parallel), also mehr als eine — aber nicht unbegrenzt,
|
||||
sonst ist es ein Vektor auf einem öffentlichen Server.
|
||||
- **Sauberes Herunterfahren.** Wie werden offene Verbindungen beim Server-Shutdown
|
||||
beendet, sodass Clients einen geordneten Reconnect machen statt in Timeouts zu laufen?
|
||||
Reference in New Issue
Block a user