1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
//! XRPC Relay — a WebRTC signaling broker built on Axum with optional
//! ATProto JWT authentication and DID-based signature verification.
//!
//! The relay accepts WebSocket connections, optionally verifies ATProto JWT
//! bearer tokens, and routes WebRTC SDP offers/answers and ICE candidates
//! between peers using an in-memory connection map.
//!
//! # Authentication
//!
//! When [`RelayConfig::auth_required`] is `true`, every connecting client must
//! present a valid ATProto access JWT. The relay accepts the token from two
//! sources, checked in order:
//!
//! 1. `Authorization: Bearer <token>` header (native clients).
//! 2. `Sec-WebSocket-Protocol: access_token, <token>` header (WASM clients
//! using the subprotocol trick — the relay echoes the selected subprotocol
//! back per RFC 6455).
//!
//! Bearer tokens are intentionally **not** accepted via query string: query
//! parameters are routinely captured in plaintext by reverse proxy access logs,
//! load balancers, and intermediate firewalls, which would leak ATProto session
//! credentials into operator-side logs the user never consented to share.
//!
//! The relay resolves the issuer's DID document (via `plc.directory` for
//! `did:plc`, or HTTPS for `did:web` — domain-only DIDs use
//! `/.well-known/did.json`, path-based DIDs like `did:web:example.com:u:alice`
//! use `/{path}/did.json`), extracts the `#atproto` signing key (P-256/ES256
//! or secp256k1/ES256K), and cryptographically verifies the JWT signature.
//! Resolved keys are cached in memory with a 5-minute TTL.
//! The authenticated DID becomes the peer's session identity.
//!
//! When `auth_required` is `false`, authentication is opportunistic — clients
//! presenting a valid, signature-verified token are identified by their DID,
//! while clients that present no token fall back to random UUIDs. An explicitly
//! invalid token is rejected with HTTP 401 regardless of `auth_required`.
//! Tokens are only trusted when a DID resolver is configured.
//!
//! # Room Isolation
//!
//! The URL path used during WebSocket upgrade determines the peer's **room**.
//! For example, `wss://relay/game_A` and `wss://relay/game_B` are separate
//! rooms — peers only see `PeerJoined`/`PeerLeft` events and can only exchange
//! signals with other peers in the same room. Cross-room signals are dropped.
//! Connecting to `/` (or with no path) places the peer in a `"default"` room.
//! Room paths are percent-decoded so that `/my%20room` and `/my room` resolve
//! to the same room.
//!
//! Peer state is keyed by `(room, session_id)`, so the same authenticated
//! identity may legitimately hold concurrent connections in different rooms
//! (e.g. a lobby and a game room). Within a single room, a reconnect from the
//! same identity still replaces the prior entry and emits a `PeerLeft` to the
//! room so the WebRTC mesh state machine can recover cleanly.
//!
//! # Hardening
//!
//! - **HTTP request timeout** — A `tower-http` `TimeoutLayer` drops any HTTP
//! connection that has not completed the request (including header parsing and
//! WebSocket upgrade) within 10 seconds, mitigating Slowloris-style attacks
//! that trickle headers slowly to hold TCP connections without ever reaching
//! the WebSocket handler.
//! - **Connection limits** — [`RelayConfig::max_peers`] caps the number of
//! concurrent connections (default `512`). The limit is enforced via an atomic
//! counter that reserves a slot *before* async identity extraction, preventing
//! TOCTOU bypasses from concurrent handshakes. An RAII `ConnectionGuard`
//! ensures the counter is decremented even if the WebSocket upgrade callback
//! is never executed (e.g. TCP drops during the HTTP handshake). New
//! connections are rejected with HTTP 503 once the limit is reached.
//! - **Message size cap** — Incoming WebSocket messages are limited to 64 KiB.
//! SDP offers/answers and ICE candidates are typically a few KiB at most.
//! - **Control signal filtering** — Clients cannot forge `PeerJoined`/`PeerLeft`
//! control signals; only the relay may originate these.
//! - **SSRF protection** — `did:web` domain resolution validates against
//! private/loopback IPs and pins the resolved address to prevent DNS rebinding.
//! - **DID document size limit** — Responses are streamed with an incremental
//! 256 KiB cap, aborting before buffering oversized payloads.
//! - **Idle timeout** — WebSocket connections that receive no messages for 120
//! seconds are disconnected, preventing Slowloris-style attacks that hold
//! connection slots indefinitely.
//! - **Handshake timeout** — The authentication/identity extraction phase is
//! capped at 15 seconds, preventing connection slot exhaustion from DIDs that
//! tarpit the HTTP fetch.
//! - **WebSocket write timeout** — Every outbound write (signaling envelope or
//! server-side Ping frame) is wrapped in a 5-second timeout. Without this, an
//! attacker that opens a connection but never drains their TCP receive buffer
//! would cause `ws_tx.send` to block indefinitely; client-side Pings would
//! keep the idle-timeout from firing, permanently holding a connection slot.
//! - **Self-targeting rejection** — SDP offers/answers addressed to the sender's
//! own session ID are dropped, preventing pointless self-negotiation loops.
//! - **Invalid message disconnect** — Peers that send 10 cumulative invalid
//! messages (malformed JSON, binary frames, forged control signals) are
//! disconnected, preventing log exhaustion attacks.
//! - **Negative DID cache** — Failed DID resolutions are cached for 60 seconds,
//! preventing attackers from using the relay as a DDoS reflector by spamming
//! handshakes with the same DID pointing at a victim server.
//! - **DID request coalescing** — The key cache uses [`moka::future::Cache`]
//! with `try_get_with`, deduplicating concurrent lookups for the same DID.
//! This prevents the relay from amplifying connection bursts into outbound
//! HTTP floods against DID hosting servers.
//! - **Domain client cap** — Per-domain `reqwest::Client` instances (DNS-pinned
//! for SSRF protection) are capped at 100. Each client holds a connection
//! pool and background workers, so the low cap prevents resource exhaustion
//! from an attacker feeding many unique `did:web` domains.
//! - **Server-side WebSocket pings** — The relay sends Ping frames every 30
//! seconds. Browsers cannot initiate WebSocket pings (the API only supports
//! *responding* to pings), so without server-side pings, idle WASM clients
//! would be reaped by the idle timeout.
//! - **Backpressure** — When the per-peer relay channel (256 slots)
//! is full, signals are dropped and logged. Each sender tracks per-target
//! strike counters independently. If a sender accumulates 50 consecutive
//! channel-full strikes against the same target, the sender silently stops
//! delivering to that target for the remainder of the connection. Stalled
//! peers are reaped by the idle timeout rather than by sender-driven eviction,
//! preventing a malicious sender from kicking arbitrary targets by flooding
//! their channel. Successful sends reset the strike counter, so live peers
//! that are merely slow will not be affected. Closed channels (peer
//! disconnected but not yet cleaned up) are skipped per-message without
//! accumulating strikes, so a peer that reconnects can immediately receive
//! signals again.
//! - **Handshake slot budget** — At most `max_peers / 4` connections may be
//! in the authentication/DID-resolution phase simultaneously. This prevents
//! attackers from exhausting all connection slots by tarpitting the DID
//! fetch with slow-responding servers. When `max_peers == 0` (unlimited),
//! the budget falls back to a fixed cap so the tarpit protection is never
//! silently disabled by an unlimited-peers configuration.
//! - **Per-target burst limiting** — Each sender may route at most 64 messages
//! to the same target within a single rate-refill window. This prevents one
//! sender from filling a target's relay channel (256 slots) with garbage,
//! which would cause legitimate signalling messages from other peers to be
//! silently dropped (WebRTC negotiation sabotage). The limit is generous for
//! legitimate mesh setup (~1 SDP + ~10 ICE per target) but well below the
//! channel capacity, leaving room for other senders. Counters reset when the
//! per-sender token bucket refills to capacity.
//! - **Per-sender rate limiting** — Each peer is rate-limited via a token bucket
//! with a burst capacity of 500 messages and a steady-state refill of 20
//! tokens per second. The high burst accommodates WebRTC mesh initialization
//! (joining a room with N peers generates N SDP offers + multiple ICE
//! candidates each), while the low refill rate caps sustained throughput.
//! Messages are dropped when the token budget is exhausted; the peer
//! remains connected so that ICE/SDP retry logic can recover.
//! - **Per-domain DID fetch concurrency limit** — Each `did:web` domain is
//! limited to 10 concurrent in-flight fetches. Slots are released as soon as
//! each fetch completes (via RAII guard), so attacker requests that fail
//! quickly cannot permanently exhaust the budget for legitimate users.
//! - **Global `did:web` fetch concurrency limit** — Total concurrent `did:web`
//! fetches across all domains are capped at 50. This prevents subdomain
//! spraying attacks from exhausting the Tokio blocking thread pool with DNS
//! resolution calls. Like the per-domain limit, slots are freed on
//! completion, making the limit resistant to unauthenticated DoS.
//! - **Peer ID length validation** — The `peer_id` field in incoming
//! [`SignalEnvelope`] messages is capped at 512 bytes after deserialization.
//! DIDs and UUIDs are well under this limit; oversized values are rejected
//! as invalid messages to prevent per-target map bloat and log-output
//! amplification.
//! - **Unique target cap** — Each sender may address at most 256 unique targets
//! within a single per-target rate window. Legitimate peers target at most
//! the number of peers in their room; an attacker forging random target IDs
//! to bloat the per-target counter map is disconnected when the cap is
//! exceeded.
//! - **JWT audience validation** — When [`RelayConfig::service_did`] is set,
//! the relay validates the JWT `aud` claim against the configured value.
//! This prevents cross-service token replay attacks where a JWT issued for
//! one relay is presented to a different relay.
//!
//! # Usage
//!
//! ```rust,no_run
//! use bevy_symbios_multiuser::relay::{RelayConfig, run_relay};
//!
//! #[tokio::main]
//! async fn main() {
//! let config = RelayConfig {
//! bind_addr: "0.0.0.0:3536".to_string(),
//! auth_required: false,
//! max_peers: 512,
//! service_did: None,
//! };
//! run_relay(config).await.expect("relay crashed");
//! }
//! ```
pub
pub
use DashMap;
use Arc;
use AtomicUsize;
use mpsc;
// Re-export protocol types so existing `use relay::SignalEnvelope` still works.
pub use crate;
/// Configuration for the relay server.
/// A connected peer's sender handle paired with a unique connection ID.
///
/// The connection ID distinguishes multiple WebSocket connections from the
/// same user (e.g. reconnects), preventing stale cleanup from clobbering a
/// newer connection.
/// Shared server state holding the map of connected peers.
/// Start the relay signaling server.
///
/// Binds to the configured address and serves WebSocket connections.
/// This function runs until the server is shut down.
pub async