bamboo_server/handlers/agent/ws_v2/mod.rs
1//! v2-P1 unified WebSocket multiplex: `GET /v2/stream`.
2//!
3//! One WebSocket replaces the two v1 SSE streams (`GET /events/{id}` +
4//! `GET /stream`) plus a minimal `stop` control uplink, so a mobile client uses
5//! ONE connection instead of two SSE + scattered POSTs. The v1 SSE/REST
6//! endpoints stay UNCHANGED (dual-track per `docs/api-v2-transport.md` §8.1).
7//!
8//! ## Channels (server→client)
9//! - `feed` — the account `ChangeEvent` stream (reuses `plan_replay` + journal).
10//! - `agent.{session_id}` — a per-session `AgentEvent` stream (reuses the v1
11//! critical-event replay + the v1 `Coalescer` for token batching).
12//!
13//! ## Control (client→server, P1)
14//! - `stop` only — cancels a session (reuses the v1 stop discipline).
15//!
16//! ## Design
17//! Each subscribed channel runs its OWN forwarder task with its OWN broadcast
18//! receiver (per-channel lag independence; §10-Q3) AND its OWN bounded outbound
19//! `mpsc` queue. The driver holds a `StreamMap<channel, ReceiverStream>` and
20//! drains every per-channel queue with a fair (randomized-start) merge, so a burst
21//! on one channel can no longer head-of-line another at the socket (RFC §10-Q3 —
22//! see `forwarders.rs` for the honest fairness guarantee). A per-channel
23//! `JoinHandle` lets `unsubscribe`/teardown abort exactly that forwarder and drop
24//! its queue, leaving no orphaned broadcast reader.
25//!
26//! ## Encoding (v2-P3, #181)
27//! The wire encoding is negotiated ONCE at the upgrade from the offered
28//! `Sec-WebSocket-Protocol`: `bamboo.v2.msgpack` selects binary MessagePack,
29//! anything else (or nothing) stays JSON text (the default — desktop /
30//! debuggability). The SAME envelope schema is carried either way; only the
31//! serialization + WS frame type (Text vs Binary) differs. See `envelope::Encoding`.
32//!
33//! ## DEFERRED (later slices; see PR / §5.3)
34//! - `execute` / `approve` over control (handler refactors) — clients keep REST.
35//!
36//! ## Auth (v2-P2, #181 / #189)
37//! `/v2/stream` is on the public route whitelist, so the upgrade OPENS without
38//! a middleware credential — this is the only way a browser device-token client
39//! (which cannot set `Authorization`/`X-Device-Id` headers on a WS upgrade) can
40//! present its token, via the `hello` frame. The handler is therefore the
41//! AUTHORITATIVE gate:
42//!
43//! - `pre_authorized` is computed from the SAME allow-decision the middleware
44//! uses for every other route (`request_is_authorized`): a local bypass, a
45//! verified password cookie (cookies ARE sent on the upgrade), or a header
46//! device token. These connections stay frictionless — exactly as before.
47//! - While NOT authorized, the ONLY frame that does anything is `hello` carrying
48//! a VALID `device_id` + `token`. A `subscribe`/`unsubscribe`/`stop` received
49//! before authorization is IGNORED — no forwarder, no cancel, no channel data.
50//! - A token-less `hello` NEVER authorizes a connection that wasn't already
51//! pre-authorized.
52//! - An unauthenticated socket that never sends a valid `hello` within
53//! [`AUTH_DEADLINE`] is CLOSED, so it cannot linger.
54//! - The token is NEVER logged.
55
56mod envelope;
57mod forwarders;
58
59use std::collections::HashMap;
60use std::time::Duration;
61
62use actix_web::{web, HttpRequest, Responder};
63use actix_ws::Message;
64use futures::StreamExt;
65use tokio::sync::mpsc;
66use tokio_stream::wrappers::ReceiverStream;
67use tokio_stream::StreamMap;
68
69use serde::Deserialize;
70
71use self::envelope::{
72 decode_client_frame, sys_keepalive_envelope, Channel, ClientFrame, Encoding, OutFrame,
73 SUBPROTOCOL_JSON, SUBPROTOCOL_MSGPACK,
74};
75use self::forwarders::{spawn_agent_forwarder, spawn_feed_forwarder, OutboundTx};
76use crate::app_state::AppState;
77use crate::handlers::agent::events::MAX_BATCH_MS;
78use crate::handlers::agent::stop::cancel_session;
79
80/// Bound on EACH per-channel outbound mpsc (RFC §10-Q3). Caps how far ahead one
81/// channel's forwarder may run before the WS writer applies backpressure to that
82/// channel ALONE — a slow socket no longer lets a bursting channel starve the
83/// queue of another, because each channel owns its own bounded buffer and the
84/// driver drains them with a fair merge.
85const OUTBOUND_BUFFER: usize = 64;
86
87/// WS ping interval. Each tick sends BOTH the protocol-level ping (server-side
88/// write probe) and the app-level `sys` keepalive data frame (client-side
89/// liveness signal, #533).
90///
91/// 2s (15s → 5s in #543, then → 2s): the client watchdog cannot detect a dead
92/// socket faster than a few missed keepalives, and a user actively watching a
93/// running session should not stare at a dead screen for long — at 2s the
94/// watchdog resolves in ~6s. The frames are tiny (a ping + a ~50-byte text
95/// frame); the cost is negligible even for remote clients. The lotus watchdog
96/// ADAPTS its threshold to the observed cadence (3×, clamped), so old-server ×
97/// new-client pairings stay safe in both directions.
98const PING_INTERVAL: Duration = Duration::from_secs(2);
99
100/// Env var the live integration test sets to SHORTEN the ping interval so the
101/// `sys` keepalive cadence is asserted in milliseconds, not 15s. Read once per
102/// connection in [`drive`]. Production NEVER sets it.
103const PING_INTERVAL_OVERRIDE_ENV: &str = "BAMBOO_WS_PING_INTERVAL_MS";
104
105/// The effective ping interval: the [`PING_INTERVAL`] default unless
106/// [`PING_INTERVAL_OVERRIDE_ENV`] is set to a valid millisecond count (test-only).
107fn ping_interval() -> Duration {
108 std::env::var(PING_INTERVAL_OVERRIDE_ENV)
109 .ok()
110 .and_then(|v| v.parse::<u64>().ok())
111 .map(Duration::from_millis)
112 .unwrap_or(PING_INTERVAL)
113}
114
115/// How long an UNAUTHORIZED connection may stay open before it must present a
116/// valid `hello` device token. A connection that opens the (now public) upgrade
117/// without a header/cookie credential and never authenticates is CLOSED when
118/// this elapses, so an unauthenticated socket can never linger (#189). Once the
119/// connection is authorized this deadline is disarmed.
120const AUTH_DEADLINE: Duration = Duration::from_secs(10);
121
122/// Env var the live integration test sets to SHORTEN the auth deadline so the
123/// "closed when no hello arrives" path is asserted in milliseconds, not 10s. It
124/// is read once per connection in [`drive`]. Production NEVER sets it, so the
125/// 10s [`AUTH_DEADLINE`] default is unchanged.
126const AUTH_DEADLINE_OVERRIDE_ENV: &str = "BAMBOO_WS_AUTH_DEADLINE_MS";
127
128/// The effective unauthorized deadline: the [`AUTH_DEADLINE`] default unless
129/// [`AUTH_DEADLINE_OVERRIDE_ENV`] is set to a valid millisecond count (test-only).
130fn auth_deadline() -> Duration {
131 std::env::var(AUTH_DEADLINE_OVERRIDE_ENV)
132 .ok()
133 .and_then(|v| v.parse::<u64>().ok())
134 .map(Duration::from_millis)
135 .unwrap_or(AUTH_DEADLINE)
136}
137
138/// The verdict for one client frame while a connection is NOT yet authorized.
139///
140/// Pure decision over the parsed frame (no `AppState`), so the auth-gating
141/// contract is unit-testable without a live WS driver: while unauthorized the
142/// ONLY frame that can change anything is a `hello`, and only a `hello` carrying
143/// a credential is even a candidate to authorize.
144#[derive(Debug, PartialEq, Eq)]
145enum UnauthorizedAction {
146 /// A `hello` carrying `device_id` + `token` — verify it; valid → authorize,
147 /// invalid → close.
148 VerifyHello,
149 /// A token-less `hello` while unauthorized — a harmless no-op that does NOT
150 /// grant access (the deadline still governs).
151 TokenlessHelloNoop,
152 /// Any other frame (`subscribe`/`unsubscribe`/`stop`/unknown) while
153 /// unauthorized — IGNORE it (serve no channel data, cancel nothing).
154 Ignore,
155}
156
157impl UnauthorizedAction {
158 /// Classify a parsed frame for an unauthorized connection.
159 fn classify(frame: &ClientFrame) -> Self {
160 match frame {
161 ClientFrame::Hello {
162 device_id: Some(_),
163 token: Some(_),
164 } => UnauthorizedAction::VerifyHello,
165 ClientFrame::Hello { .. } => UnauthorizedAction::TokenlessHelloNoop,
166 _ => UnauthorizedAction::Ignore,
167 }
168 }
169}
170
171/// What the driver should do with a frame after the auth gate ran.
172#[derive(Debug, PartialEq, Eq)]
173enum GateOutcome {
174 /// The frame was fully handled by the gate (the connection is/was
175 /// unauthorized, or it just authorized): keep the socket open, do NOT fall
176 /// through to channel dispatch.
177 Handled,
178 /// An invalid credential was presented: CLOSE the connection.
179 Close,
180 /// The connection is authorized and this frame should proceed to the normal
181 /// channel dispatch (subscribe/unsubscribe/stop/hello-rebind).
182 Dispatch,
183}
184
185/// The AppState-aware auth gate, factored out of `handle_client_frame` so it is
186/// unit-testable WITHOUT a live WS `Session`. It owns the entire `!authorized`
187/// decision plus the credential verification, and flips `authorized` to `true`
188/// only on a VERIFIED `hello` device token.
189///
190/// Invariants (the security review hammers these):
191/// - While `!*authorized`, a `subscribe`/`unsubscribe`/`stop` returns
192/// [`GateOutcome::Handled`] (ignored — never `Dispatch`), so no channel is
193/// served and no session cancelled before authorization.
194/// - A token-less `hello` NEVER sets `*authorized` when it was `false`.
195/// - A credentialed `hello` with a VALID token sets `*authorized = true`.
196/// - A credentialed `hello` with an INVALID token returns [`GateOutcome::Close`].
197/// - The token is NEVER logged.
198async fn apply_auth_gate(
199 state: &web::Data<AppState>,
200 frame: &ClientFrame,
201 authorized: &mut bool,
202) -> GateOutcome {
203 if *authorized {
204 return GateOutcome::Dispatch;
205 }
206
207 match UnauthorizedAction::classify(frame) {
208 UnauthorizedAction::VerifyHello => {
209 let ClientFrame::Hello {
210 device_id: Some(device_id),
211 token: Some(token),
212 } = frame
213 else {
214 unreachable!("VerifyHello implies a credentialed Hello");
215 };
216 let config = state.config.read().await.clone();
217 if crate::handlers::settings::verify_device_token(&config, device_id, token) {
218 *authorized = true;
219 // Bind device id for logging. NEVER log the token.
220 tracing::debug!("ws_v2: hello verified for device {device_id}; authorized");
221 GateOutcome::Handled
222 } else {
223 tracing::warn!(
224 "ws_v2: hello rejected — invalid device credential for {device_id}; closing"
225 );
226 GateOutcome::Close
227 }
228 }
229 UnauthorizedAction::TokenlessHelloNoop => {
230 // A token-less hello does NOT authorize a non-pre-authorized
231 // connection. Keep the socket open; the deadline still governs.
232 tracing::debug!("ws_v2: token-less hello while unauthorized — not granting access");
233 GateOutcome::Handled
234 }
235 UnauthorizedAction::Ignore => {
236 // subscribe/unsubscribe/stop/unknown before auth: serve nothing,
237 // cancel nothing. Tolerate hello-after-subscribe ordering.
238 tracing::debug!("ws_v2: ignoring frame on unauthorized connection");
239 GateOutcome::Handled
240 }
241 }
242}
243
244/// Query parameters for the `GET /v2/stream` upgrade.
245#[derive(Debug, Default, Deserialize)]
246pub struct StreamQuery {
247 /// Token-coalescing window in milliseconds for `agent.{sid}` channels.
248 /// `0` (default) = no coalescing (desktop). Mobile passes e.g. `50`.
249 #[serde(default)]
250 pub batch_ms: u64,
251}
252
253/// Negotiate the wire [`Encoding`] from the upgrade's `Sec-WebSocket-Protocol`
254/// header (v2-P3, §5.3 / §7.2). Returns the chosen encoding AND the subprotocol
255/// token the server must ECHO on the upgrade response (per RFC 6455 the server
256/// echoes the single selected subprotocol).
257///
258/// - Offers including `bamboo.v2.msgpack` → `(Msgpack, Some("bamboo.v2.msgpack"))`.
259/// - Offers including only `bamboo.v2` → `(Json, Some("bamboo.v2"))`.
260/// - No (recognized) subprotocol offered → `(Json, None)` — today's behavior is
261/// preserved byte-for-byte for clients that offer nothing.
262///
263/// `bamboo.v2.msgpack` wins if BOTH are offered (the client opted into binary).
264fn negotiate_encoding(req: &HttpRequest) -> (Encoding, Option<&'static str>) {
265 let offered = req
266 .headers()
267 .get(actix_web::http::header::SEC_WEBSOCKET_PROTOCOL)
268 .and_then(|v| v.to_str().ok())
269 .unwrap_or("");
270 // The header is a comma-separated list of subprotocol tokens.
271 let mut has_msgpack = false;
272 let mut has_json = false;
273 for tok in offered.split(',') {
274 match tok.trim() {
275 SUBPROTOCOL_MSGPACK => has_msgpack = true,
276 SUBPROTOCOL_JSON => has_json = true,
277 _ => {}
278 }
279 }
280 if has_msgpack {
281 (Encoding::Msgpack, Some(SUBPROTOCOL_MSGPACK))
282 } else if has_json {
283 (Encoding::Json, Some(SUBPROTOCOL_JSON))
284 } else {
285 (Encoding::Json, None)
286 }
287}
288
289/// `GET /v2/stream` — upgrade to the unified WS multiplex.
290///
291/// The upgrade itself is PUBLIC (whitelisted in `is_public_access_route`), so a
292/// browser device-token client can open the socket and authenticate via `hello`
293/// (it cannot set headers on a WS upgrade). Auth is enforced in `drive`: a
294/// connection that the middleware WOULD have allowed is `pre_authorized` here
295/// (local bypass / verified password cookie / header device token), via the same
296/// `request_is_authorized` allow-decision the middleware uses; everything else
297/// must present a verified `hello` before any channel is served, on a deadline.
298///
299/// The wire [`Encoding`] is negotiated from `Sec-WebSocket-Protocol` (v2-P3):
300/// `bamboo.v2.msgpack` → binary MessagePack; otherwise JSON text (default). The
301/// selected subprotocol is ECHOED on the upgrade response per RFC 6455.
302pub async fn handler(
303 state: web::Data<AppState>,
304 query: web::Query<StreamQuery>,
305 req: HttpRequest,
306 body: web::Payload,
307) -> actix_web::Result<impl Responder> {
308 // Clamp the untrusted `batch_ms` the same way the v1 SSE handler does.
309 let batch_ms = query.batch_ms.min(MAX_BATCH_MS);
310
311 // Negotiate the wire encoding from the offered subprotocols (v2-P3).
312 let (encoding, selected_subprotocol) = negotiate_encoding(&req);
313
314 // Pre-authorize exactly the connections the middleware would have allowed on
315 // a gated route: local bypass, a verified password cookie (sent on the
316 // upgrade), or a header device token. This preserves every existing client
317 // with ZERO change; a remote browser device-token client is NOT pre-auth and
318 // must send a valid `hello`.
319 let pre_authorized = {
320 let config = state.config.read().await.clone();
321 crate::handlers::settings::request_is_authorized(&req, &config)
322 };
323
324 let (mut response, session, msg_stream) = actix_ws::handle(&req, body)?;
325
326 // Echo the selected subprotocol on the upgrade RESPONSE (RFC 6455). A client
327 // that offered no recognized subprotocol gets none, keeping the legacy
328 // handshake byte-for-byte unchanged.
329 if let Some(proto) = selected_subprotocol {
330 response.headers_mut().insert(
331 actix_web::http::header::SEC_WEBSOCKET_PROTOCOL,
332 actix_web::http::header::HeaderValue::from_static(proto),
333 );
334 }
335
336 actix_web::rt::spawn(drive(
337 state,
338 session,
339 msg_stream,
340 batch_ms,
341 pre_authorized,
342 encoding,
343 ));
344
345 Ok(response)
346}
347
348/// The per-connection driver: owns the WS `session` (write) + `msg_stream`
349/// (read), the shared outbound mpsc, the per-channel forwarder handles, and the
350/// keepalive ping timer.
351async fn drive(
352 state: web::Data<AppState>,
353 mut session: actix_ws::Session,
354 mut msg_stream: actix_ws::MessageStream,
355 batch_ms: u64,
356 pre_authorized: bool,
357 encoding: Encoding,
358) {
359 // Per-channel outbound (RFC §10-Q3): every subscribed channel owns its OWN
360 // bounded queue. `forwarders` keeps the task handle (for unsubscribe/teardown
361 // abort) and `queues` keeps the matching receiver in a `StreamMap` the driver
362 // drains with a fair (randomized-start) merge, so a burst on one channel cannot
363 // head-of-line another at the socket. The two maps are keyed by the SAME
364 // channel id and mutated together (see [`subscribe`] / unsubscribe / teardown).
365 let mut forwarders: HashMap<String, tokio::task::JoinHandle<()>> = HashMap::new();
366 let mut queues: StreamMap<String, ReceiverStream<OutFrame>> = StreamMap::new();
367 let mut ping = tokio::time::interval(ping_interval());
368 ping.tick().await; // skip the immediate tick
369
370 // Authorization state (#189). Seeded from the upgrade-time decision so local
371 // / cookie / header clients are authorized immediately; everything else must
372 // present a valid `hello` before any channel is served.
373 let mut authorized = pre_authorized;
374 // The unauthorized-deadline timer. Pinned + biased to fire while `!authorized`
375 // and disarmed once the connection authorizes, so an unauthenticated socket
376 // is closed but an authorized one runs indefinitely.
377 let auth_deadline = tokio::time::sleep(auth_deadline());
378 tokio::pin!(auth_deadline);
379
380 loop {
381 tokio::select! {
382 // While unauthorized, close the socket once the deadline elapses. The
383 // `!authorized` guard disarms this arm the moment the connection
384 // authorizes (a never-completing branch is simply never selected).
385 _ = &mut auth_deadline, if !authorized => {
386 tracing::debug!("ws_v2: closing unauthorized connection after auth deadline");
387 break;
388 }
389 // Drain the per-channel queues to the WS with a fair merge. The
390 // `StreamMap` randomizes its poll start each call, so no single channel's
391 // queue is favored — a bursting channel cannot starve another at the
392 // socket. `(channel, frame)` is yielded; only the frame goes on the
393 // wire. The frame is ALREADY encoded per the connection's `Encoding`
394 // (the forwarder did the encode), so the driver only picks the WS frame
395 // type: `session.text` for a JSON `Text`, `session.binary` for a
396 // MessagePack `Binary`. If the write fails the peer is gone — break and
397 // tear down.
398 Some((_ch, frame)) = queues.next() => {
399 let write = match frame {
400 OutFrame::Text(s) => session.text(s).await,
401 OutFrame::Binary(b) => session.binary(b).await,
402 };
403 if write.is_err() {
404 break;
405 }
406 }
407 // Keepalive. Liveness is write-driven (a dead peer surfaces as a
408 // failed `ping`/`text` write); we do NOT track Pong arrivals or run a
409 // pong timeout — same one-directional keepalive contract as the v1 SSE
410 // stream.
411 //
412 // TWO frames go out per tick (#533):
413 // - a protocol-level ping — the server-side write probe (unchanged);
414 // - an app-level `{ch:"sys", control:{type:"keepalive"}}` DATA frame —
415 // browsers never expose protocol pings to JS, so without a data
416 // frame a client on a half-open socket (sleep/wake, NAT idle
417 // eviction) has NO observable liveness signal and sits "open"
418 // forever after the server tears down. The sys frame is what the
419 // lotus watchdog keys on to force a reconnect. Old clients ignore
420 // the unknown `sys` channel, so this is backward-compatible.
421 _ = ping.tick() => {
422 if session.ping(b"").await.is_err() {
423 break;
424 }
425 // Only an AUTHORIZED connection gets the data frame: an
426 // unauthenticated socket is served nothing (same posture as
427 // channel data) and is closed by the auth deadline anyway.
428 if authorized {
429 if let Some(frame) = sys_keepalive_envelope().encode(encoding) {
430 let write = match frame {
431 OutFrame::Text(s) => session.text(s).await,
432 OutFrame::Binary(b) => session.binary(b).await,
433 };
434 if write.is_err() {
435 break;
436 }
437 }
438 }
439 }
440 // Client frames. In JSON mode the client sends TEXT frames; in msgpack
441 // mode it sends BINARY frames. A frame carrying the inbound bytes for the
442 // ACTIVE encoding is decoded + dispatched; a frame of the other kind is
443 // ignored (a Binary frame in JSON mode, a Text frame in msgpack mode),
444 // exactly as a malformed frame is — it never tears down the connection.
445 msg = msg_stream.next() => {
446 match msg {
447 // The inbound frame type that matches the active encoding.
448 Some(Ok(Message::Text(text))) if encoding == Encoding::Json => {
449 let keep_open = handle_client_bytes(
450 &state, &mut forwarders, &mut queues,
451 batch_ms, encoding, text.as_bytes(), &mut authorized,
452 )
453 .await;
454 if !keep_open {
455 break;
456 }
457 }
458 Some(Ok(Message::Binary(bytes))) if encoding == Encoding::Msgpack => {
459 let keep_open = handle_client_bytes(
460 &state, &mut forwarders, &mut queues,
461 batch_ms, encoding, &bytes, &mut authorized,
462 )
463 .await;
464 if !keep_open {
465 break;
466 }
467 }
468 Some(Ok(Message::Ping(bytes))) => {
469 if session.pong(&bytes).await.is_err() {
470 break;
471 }
472 }
473 // A frame of the WRONG kind for the active encoding (a Binary
474 // frame in JSON mode / a Text frame in msgpack mode), plus Pong /
475 // Continuation / Nop: ignore, never disconnect.
476 Some(Ok(Message::Text(_)))
477 | Some(Ok(Message::Binary(_)))
478 | Some(Ok(Message::Pong(_)))
479 | Some(Ok(Message::Continuation(_)))
480 | Some(Ok(Message::Nop)) => {}
481 Some(Ok(Message::Close(_))) | None => break,
482 Some(Err(e)) => {
483 tracing::debug!("ws_v2: message stream error: {e}");
484 break;
485 }
486 }
487 }
488 else => break,
489 }
490 }
491
492 // Clean teardown: abort every forwarder so no orphaned broadcast reader
493 // survives the connection, and drop every per-channel queue (clearing the
494 // `StreamMap` drops each receiver).
495 for (_ch, handle) in forwarders.drain() {
496 handle.abort();
497 }
498 queues.clear();
499 let _ = session.close(None).await;
500}
501
502/// Decode one inbound frame's bytes per the connection's [`Encoding`] (serde_json
503/// for `Json`, rmp-serde for `Msgpack`) and dispatch the resulting [`ClientFrame`].
504/// A malformed body logs and is ignored — it NEVER tears down the connection, in
505/// EITHER encoding (the msgpack decode error is treated exactly like a malformed
506/// JSON text frame).
507///
508/// This is the thin decode step in front of [`handle_client_frame`]; the dispatch
509/// and auth logic is encoding-agnostic (the SAME `ClientFrame` flows through both
510/// paths), so the JSON behavior is byte-for-byte unchanged.
511///
512/// Returns `false` to signal the driver to CLOSE the connection (a `hello` that
513/// presents an INVALID device credential); `true` to keep it open.
514async fn handle_client_bytes(
515 state: &web::Data<AppState>,
516 forwarders: &mut HashMap<String, tokio::task::JoinHandle<()>>,
517 queues: &mut StreamMap<String, ReceiverStream<OutFrame>>,
518 batch_ms: u64,
519 encoding: Encoding,
520 bytes: &[u8],
521 authorized: &mut bool,
522) -> bool {
523 let frame: ClientFrame = match decode_client_frame(encoding, bytes) {
524 Ok(f) => f,
525 Err(e) => {
526 tracing::debug!("ws_v2: ignoring malformed client frame: {e}");
527 return true;
528 }
529 };
530 handle_client_frame(
531 state, forwarders, queues, batch_ms, encoding, frame, authorized,
532 )
533 .await
534}
535
536/// Dispatch one decoded client frame. A malformed/unknown frame logs and is
537/// ignored — it never tears down the connection.
538///
539/// `authorized` is the per-connection auth state (#189). While it is `false` the
540/// ONLY frame that does anything is a `hello` carrying a VALID device token (it
541/// flips `authorized` to `true`); EVERY other frame — including a
542/// `subscribe`/`unsubscribe`/`stop` — is IGNORED, so a remote unauthenticated
543/// connection gets NO channel data and cancels nothing. The driver's deadline
544/// closes a socket that never authorizes.
545///
546/// Returns `false` to signal the driver to CLOSE the connection (a `hello` that
547/// presents an INVALID device credential); `true` to keep it open.
548async fn handle_client_frame(
549 state: &web::Data<AppState>,
550 forwarders: &mut HashMap<String, tokio::task::JoinHandle<()>>,
551 queues: &mut StreamMap<String, ReceiverStream<OutFrame>>,
552 batch_ms: u64,
553 encoding: Encoding,
554 frame: ClientFrame,
555 authorized: &mut bool,
556) -> bool {
557 // Auth gate (#189). Until the connection is authorized, no frame may serve a
558 // channel or cancel a session — the only frame that can change anything is a
559 // `hello` that carries a verifiable device credential.
560 match apply_auth_gate(state, &frame, authorized).await {
561 GateOutcome::Handled => return true,
562 GateOutcome::Close => return false,
563 GateOutcome::Dispatch => {}
564 }
565
566 // Authorized path: full dispatch.
567 match frame {
568 ClientFrame::Hello { device_id, token } => {
569 // Already authorized. A credentialed hello re-verifies as identity
570 // binding; an invalid one still closes. A token-less hello on an
571 // already-authorized connection is a harmless no-op.
572 match (device_id, token) {
573 (Some(device_id), Some(token)) => {
574 let config = state.config.read().await.clone();
575 if crate::handlers::settings::verify_device_token(&config, &device_id, &token) {
576 // NEVER log the token.
577 tracing::debug!("ws_v2: hello verified for device {device_id}");
578 } else {
579 tracing::warn!(
580 "ws_v2: hello rejected — invalid device credential for {device_id}; closing"
581 );
582 return false;
583 }
584 }
585 _ => {
586 tracing::debug!("ws_v2: token-less hello on authorized connection (no-op)");
587 }
588 }
589 }
590 ClientFrame::Subscribe { ch, since } => {
591 subscribe(state, forwarders, queues, batch_ms, encoding, &ch, since).await;
592 }
593 ClientFrame::Unsubscribe { ch } => {
594 if let Some(handle) = forwarders.remove(&ch) {
595 handle.abort();
596 // Drop this channel's queue too: removing it from the StreamMap
597 // drops the receiver so the (now-aborted) forwarder's sender is
598 // dead and no stale frame can leak onto the socket.
599 queues.remove(&ch);
600 tracing::debug!("ws_v2: unsubscribed {ch}");
601 }
602 }
603 ClientFrame::Stop { session_id } => {
604 let cancelled = cancel_session(state, &session_id).await;
605 tracing::debug!("ws_v2: stop {session_id} -> cancelled={cancelled}");
606 }
607 ClientFrame::Unknown => {
608 tracing::debug!("ws_v2: ignoring unknown client frame type");
609 }
610 }
611 true
612}
613
614/// Subscribe to a channel, replacing any existing forwarder for the same `ch`
615/// (a re-subscribe with a new cursor aborts the old one first, so there is never
616/// a duplicate reader on one channel).
617async fn subscribe(
618 state: &web::Data<AppState>,
619 forwarders: &mut HashMap<String, tokio::task::JoinHandle<()>>,
620 queues: &mut StreamMap<String, ReceiverStream<OutFrame>>,
621 batch_ms: u64,
622 encoding: Encoding,
623 ch: &str,
624 since: Option<u64>,
625) {
626 let Some(channel) = Channel::parse(ch) else {
627 tracing::debug!("ws_v2: ignoring subscribe to unknown channel {ch}");
628 return;
629 };
630
631 // Replace any prior forwarder for this exact channel id, dropping its queue
632 // (a re-subscribe with a new cursor must not leave the old queue behind).
633 if let Some(old) = forwarders.remove(ch) {
634 old.abort();
635 }
636 queues.remove(ch);
637
638 // This channel's OWN bounded outbound queue (RFC §10-Q3). The forwarder owns
639 // the sender; the driver drains the receiver via the fair `StreamMap` merge.
640 // Carries already-encoded [`OutFrame`]s (Text/Binary per the connection's
641 // `Encoding`).
642 let (out_tx, out_rx) = mpsc::channel::<OutFrame>(OUTBOUND_BUFFER);
643 let out_tx: OutboundTx = out_tx;
644
645 let handle = match channel {
646 Channel::Feed => {
647 // Subscribe FIRST so events written during journal replay are buffered
648 // in the ring and delivered in the live phase (no gap) — exactly the
649 // v1 SSE handoff discipline.
650 let receiver = state.account_sink.subscribe();
651 let latest_at_start = state.account_sink.latest_seq();
652 let events_dir = state.account_sink.events_dir().to_path_buf();
653 let since = since.unwrap_or(0);
654 spawn_feed_forwarder(
655 out_tx,
656 encoding,
657 receiver,
658 events_dir,
659 since,
660 latest_at_start,
661 )
662 }
663 Channel::Agent(sid) => {
664 // The session must exist; otherwise ignore (parity with the v1
665 // events handler's 404, but here we just skip the subscribe).
666 if state.session_store.get_index_entry(&sid).await.is_none() {
667 tracing::debug!("ws_v2: ignoring subscribe to unknown session {sid}");
668 return;
669 }
670
671 let sender = state.get_session_event_sender(&sid).await;
672 let receiver = sender.subscribe();
673 // Keep the notification relay running for this session (parity with
674 // the v1 events handler).
675 state.ensure_notification_relay(&sid, sender.clone());
676
677 // Snapshot the runner for critical-event + budget replay (mirrors
678 // `events/handler.rs:80-89`).
679 let runner_snapshot = {
680 let runners = state.agent_runners.read().await;
681 runners.get(&sid).cloned()
682 };
683 let budget_event_to_replay = runner_snapshot
684 .as_ref()
685 .and_then(|runner| runner.last_budget_event.clone());
686 let critical_events_to_replay: Vec<_> = runner_snapshot
687 .as_ref()
688 .map(|runner| runner.last_critical_events.clone())
689 .unwrap_or_default();
690
691 spawn_agent_forwarder(
692 state.clone(),
693 sid.clone(),
694 out_tx,
695 encoding,
696 ch.to_string(),
697 receiver,
698 budget_event_to_replay,
699 critical_events_to_replay,
700 batch_ms,
701 )
702 }
703 };
704
705 forwarders.insert(ch.to_string(), handle);
706 // Register this channel's queue receiver into the fair-merge drain. Keyed by
707 // the SAME channel id as `forwarders`, so unsubscribe/teardown drops both.
708 queues.insert(ch.to_string(), ReceiverStream::new(out_rx));
709 tracing::debug!("ws_v2: subscribed {ch} (since={since:?})");
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715 use crate::app_state::AppState;
716 use bamboo_config::{AccessControlConfig, DeviceCredential};
717 use tempfile::tempdir;
718
719 fn hello(device_id: Option<&str>, token: Option<&str>) -> ClientFrame {
720 ClientFrame::Hello {
721 device_id: device_id.map(str::to_string),
722 token: token.map(str::to_string),
723 }
724 }
725
726 // ── Subprotocol negotiation (v2-P3) ───────────────────────────────────────
727
728 fn negotiate_for(header: Option<&str>) -> (Encoding, Option<&'static str>) {
729 let mut req = actix_web::test::TestRequest::default();
730 if let Some(h) = header {
731 req = req.insert_header((actix_web::http::header::SEC_WEBSOCKET_PROTOCOL, h));
732 }
733 negotiate_encoding(&req.to_http_request())
734 }
735
736 #[test]
737 fn negotiate_encoding_branches() {
738 // No header / empty → JSON default, NO echo (unchanged for old clients).
739 assert_eq!(negotiate_for(None), (Encoding::Json, None));
740 assert_eq!(negotiate_for(Some("")), (Encoding::Json, None));
741 // Explicit JSON subprotocol → JSON, echo it.
742 assert_eq!(
743 negotiate_for(Some("bamboo.v2")),
744 (Encoding::Json, Some(SUBPROTOCOL_JSON))
745 );
746 // Msgpack offered → Msgpack, echo it.
747 assert_eq!(
748 negotiate_for(Some("bamboo.v2.msgpack")),
749 (Encoding::Msgpack, Some(SUBPROTOCOL_MSGPACK))
750 );
751 // Multi-offer: msgpack preferred regardless of order, and whitespace is trimmed.
752 assert_eq!(
753 negotiate_for(Some("bamboo.v2.msgpack, bamboo.v2")),
754 (Encoding::Msgpack, Some(SUBPROTOCOL_MSGPACK))
755 );
756 assert_eq!(
757 negotiate_for(Some(" bamboo.v2 , bamboo.v2.msgpack ")),
758 (Encoding::Msgpack, Some(SUBPROTOCOL_MSGPACK))
759 );
760 // Unknown-only offer → JSON, and NO bogus echo (echoing a non-offered
761 // subprotocol would violate RFC 6455).
762 assert_eq!(
763 negotiate_for(Some("some.other.proto")),
764 (Encoding::Json, None)
765 );
766 }
767
768 // ── Pure classification (no AppState) ─────────────────────────────────────
769
770 #[test]
771 fn classify_unauthorized_frame_actions() {
772 // A credentialed hello is the only authorize candidate.
773 assert_eq!(
774 UnauthorizedAction::classify(&hello(Some("d"), Some("t"))),
775 UnauthorizedAction::VerifyHello
776 );
777 // A token-less hello (any missing field) is a no-op, never an authorizer.
778 assert_eq!(
779 UnauthorizedAction::classify(&hello(None, None)),
780 UnauthorizedAction::TokenlessHelloNoop
781 );
782 assert_eq!(
783 UnauthorizedAction::classify(&hello(Some("d"), None)),
784 UnauthorizedAction::TokenlessHelloNoop
785 );
786 assert_eq!(
787 UnauthorizedAction::classify(&hello(None, Some("t"))),
788 UnauthorizedAction::TokenlessHelloNoop
789 );
790 // Every channel-touching / control frame is IGNORED while unauthorized.
791 assert_eq!(
792 UnauthorizedAction::classify(&ClientFrame::Subscribe {
793 ch: "feed".into(),
794 since: None
795 }),
796 UnauthorizedAction::Ignore
797 );
798 assert_eq!(
799 UnauthorizedAction::classify(&ClientFrame::Unsubscribe { ch: "feed".into() }),
800 UnauthorizedAction::Ignore
801 );
802 assert_eq!(
803 UnauthorizedAction::classify(&ClientFrame::Stop {
804 session_id: "s".into()
805 }),
806 UnauthorizedAction::Ignore
807 );
808 assert_eq!(
809 UnauthorizedAction::classify(&ClientFrame::Unknown),
810 UnauthorizedAction::Ignore
811 );
812 }
813
814 // ── AppState-aware gate (no live Session) ─────────────────────────────────
815
816 async fn app_state_with_device() -> (web::Data<AppState>, DeviceCredential, String) {
817 let dir = tempdir().unwrap();
818 let state = web::Data::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
819 let (cred, token) = crate::handlers::settings::issue_device_token("test-device");
820 {
821 let mut config = state.config.write().await;
822 config.access_control = Some(AccessControlConfig {
823 password_enabled: false,
824 password_hash: None,
825 password_salt: None,
826 updated_at: None,
827 devices: vec![cred.clone()],
828 });
829 }
830 (state, cred, token)
831 }
832
833 #[actix_web::test]
834 async fn subscribe_while_unauthorized_is_ignored_and_stays_unauthorized() {
835 let (state, _cred, _token) = app_state_with_device().await;
836 let mut authorized = false;
837 let frame = ClientFrame::Subscribe {
838 ch: "feed".into(),
839 since: None,
840 };
841 let outcome = apply_auth_gate(&state, &frame, &mut authorized).await;
842 // The driver keeps the socket open but does NOT dispatch (no forwarder).
843 assert_eq!(outcome, GateOutcome::Handled);
844 assert!(!authorized, "a subscribe must never authorize a connection");
845 }
846
847 #[actix_web::test]
848 async fn stop_while_unauthorized_is_ignored() {
849 let (state, _cred, _token) = app_state_with_device().await;
850 let mut authorized = false;
851 let frame = ClientFrame::Stop {
852 session_id: "sess".into(),
853 };
854 let outcome = apply_auth_gate(&state, &frame, &mut authorized).await;
855 assert_eq!(outcome, GateOutcome::Handled);
856 assert!(!authorized);
857 }
858
859 #[actix_web::test]
860 async fn valid_hello_authorizes() {
861 let (state, cred, token) = app_state_with_device().await;
862 let mut authorized = false;
863 let frame = hello(Some(&cred.device_id), Some(&token));
864 let outcome = apply_auth_gate(&state, &frame, &mut authorized).await;
865 assert_eq!(outcome, GateOutcome::Handled);
866 assert!(authorized, "a valid hello must authorize the connection");
867 }
868
869 #[actix_web::test]
870 async fn invalid_hello_closes_and_does_not_authorize() {
871 let (state, cred, _token) = app_state_with_device().await;
872 let mut authorized = false;
873 let frame = hello(Some(&cred.device_id), Some("bd1_wrongwrongwrong"));
874 let outcome = apply_auth_gate(&state, &frame, &mut authorized).await;
875 assert_eq!(outcome, GateOutcome::Close);
876 assert!(!authorized, "an invalid hello must never authorize");
877 }
878
879 #[actix_web::test]
880 async fn tokenless_hello_does_not_authorize_unauthorized_connection() {
881 let (state, _cred, _token) = app_state_with_device().await;
882 let mut authorized = false;
883 let frame = hello(None, None);
884 let outcome = apply_auth_gate(&state, &frame, &mut authorized).await;
885 assert_eq!(outcome, GateOutcome::Handled);
886 assert!(
887 !authorized,
888 "a token-less hello must NEVER authorize a non-pre-authorized connection"
889 );
890 }
891
892 #[actix_web::test]
893 async fn pre_authorized_connection_dispatches_subscribe() {
894 let (state, _cred, _token) = app_state_with_device().await;
895 // Pre-authorized (local / cookie / header equivalent): the gate passes
896 // every frame straight through to channel dispatch.
897 let mut authorized = true;
898 let frame = ClientFrame::Subscribe {
899 ch: "feed".into(),
900 since: None,
901 };
902 let outcome = apply_auth_gate(&state, &frame, &mut authorized).await;
903 assert_eq!(outcome, GateOutcome::Dispatch);
904 assert!(authorized);
905 }
906}