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.
12 KiB
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
(entblockt am 14.08.2026), Volltext in
../research/03-ws-auth-reconnect.md. Dieses Ticket
entscheidet.
Vier Befunde, die die Optionen bereits verschoben haben
- 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
Authorizationaktiv,request.urinicht. Sec-WebSocket-Protocolist 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.- Die Browser-API verschluckt alle Fehlerdetails (WHATWG-Vorgabe): im WebView ist
„Token abgelaufen" nicht von „Server down" unterscheidbar — immer
CloseEvent.code === 1006.tungstenite::Error::Httpträgt dagegen die vollständige Response. Wenn die Verbindung im Rust-Layer lebt, istAuthorization: Bearermöglich und die Fehlerdiagnose brauchbar; beides zusammen macht die Entscheidung in Wo lebt die WS-Verbindung im Client fast zur Vorentscheidung für dieses Ticket. - 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: 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. 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
CancellationTokenje Verbindung, abgeleitet vom globalen Shutdown-Token. - Ein
TaskTracker, um beim Herunterfahren auf die Verbindungstasks zu warten. - Beim Shutdown Close-Frame mit
1012senden —WebSocket::closegibt es seit axum 0.8 nicht mehr, der Frame geht explizit übersend. broadcast::Receiver::recvundCancellationToken::cancelledsind dokumentiert cancel-safe; fürSplitStream::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?