dwara_core/dataplane/websocket.rs
1//! WebSocket policy (DW-039 + DP-05 #253): the knobs that turn the
2//! generic 101 tunnel into a managed one for browser-style WebSocket
3//! traffic — origin allowlisting at the handshake, post-upgrade
4//! frame-rate policing, per-frame size limits, idle-timeout reaping,
5//! and RFC 6455 protocol-error enforcement on the tunnel.
6//!
7//! # Zero new dependencies, by construction
8//!
9//! The module hand-rolls exactly as much of RFC 6455 as policing
10//! needs and not one byte more. The frame scanner reads HEADERS only
11//! (2..=14 bytes: opcode + mask bit + 7/16/64-bit lengths) — it never
12//! unmasks, never inspects payload bytes. The origin check is a string
13//! comparison against config values. The close frames the policer
14//! sends (opcode 8, status 1008/1009/1002) are four fixed bytes each.
15//! Everything else — the handshake itself, the upgraded byte pipe —
16//! stays the generic tunnel's job (`proxy::tunnel` / `proxy::idle_tunnel`);
17//! a non-WebSocket upgrade (any other `Upgrade` token) never enters
18//! this module.
19//!
20//! # The origin gate
21//!
22//! Applied in the proxy action when the request offers a `websocket`
23//! upgrade: a non-empty `routes[].websocket.origins` list admits ONLY
24//! exact (case-sensitive) matches. A MISSING `Origin` header is
25//! denied — browsers always send one on a WebSocket handshake, so an
26//! originless handshake is a non-browser client the operator did not
27//! name; fail closed. The check denies BEFORE any upstream contact
28//! (no dial, no breaker observation) with the gateway's standard
29//! envelope, code `websocket_origin_denied`.
30//!
31//! # The frame-rate policer
32//!
33//! [`WsPoliceIo`] wraps the UPGRADED CLIENT side of the tunnel. Every
34//! byte the client sends passes through transparently while a scanner
35//! tracks frame boundaries and counts DATA frames (text `0x1`,
36//! binary `0x2`, continuation `0x0`; ping/pong/close are free — they
37//! are the protocol's own housekeeping, and a ping flood is bounded
38//! by the connection itself). The allowance is a token bucket:
39//! `rate` tokens per second, capacity `rate` (a one-second burst). A
40//! data frame with no token left trips the policer: a close frame
41//! with status 1008 (policy violation) is queued for the CLIENT
42//! direction (written ahead of any other pending bytes), the client
43//! read side returns EOF (the tunnel propagates shutdown), and the
44//! connection ends. Policing is one-directional by design: it
45//! protects UPSTREAMS from abusive clients.
46//!
47//! Fragmented messages count per frame — a client fragmenting every
48//! message gets policed harder, not softer; the conservative
49//! direction is the safe one. Extended (16/64-bit) lengths are data
50//! frames by construction: RFC 6455 caps CONTROL frames at 125
51//! payload bytes, so only data frames ever carry them.
52//!
53//! # DP-05 (#253): frame size, protocol errors, and idle timeout
54//!
55//! The frame scanner was extended to enforce three additional
56//! policies, all closing the connection with the appropriate code:
57//!
58//! - **`max_frame_size_bytes`**: a DATA frame whose payload length
59//! exceeds the configured cap is closed with status 1009 (message
60//! too big). The check runs at the header scan — both the 7-bit
61//! short length and the 16/64-bit extended length — before the
62//! payload is consumed.
63//! - **Reserved opcodes**: RFC 6455 reserves 0x3-0x7 (data) and
64//! 0xB-0xF (control). A frame using a reserved opcode is closed
65//! with status 1002 (protocol error).
66//! - **Control-frame length**: control frames with extended lengths
67//! (len7 >= 126) violate RFC 6455 §5.5 (control payloads must be
68//! <= 125 bytes) and are closed with status 1002.
69//!
70//! The idle timeout (`idle_timeout_s`) is enforced at the tunnel
71//! level by `proxy::idle_tunnel`, which wraps the bidirectional copy
72//! in a resettable `tokio::time::sleep`. Each successful read or
73//! write in either direction resets the timer; if it fires, both
74//! sides are shut down.
75
76use std::io;
77use std::pin::Pin;
78use std::sync::atomic::{AtomicU64, Ordering};
79use std::sync::Arc;
80use std::task::{Context, Poll};
81
82use hyper::HeaderMap;
83use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
84
85use crate::config::RouteWebsocket;
86
87/// Close frame for status 1008 (policy violation): FIN + opcode 8,
88/// unmasked (server-to-client frames are never masked), 2-byte
89/// status, empty reason. Four fixed bytes, sent as-is.
90const CLOSE_POLICY_VIOLATION: [u8; 4] = [0x88, 0x02, 0x03, 0xe8];
91
92/// Close frame for status 1009 (message too big): FIN + opcode 8,
93/// unmasked, 2-byte status 0x03F1, empty reason.
94const CLOSE_MESSAGE_TOO_BIG: [u8; 4] = [0x88, 0x02, 0x03, 0xf1];
95
96/// Close frame for status 1002 (protocol error): FIN + opcode 8,
97/// unmasked, 2-byte status 0x03EA, empty reason. Used for reserved
98/// opcodes and malformed control-frame lengths (DP-05, #253).
99const CLOSE_PROTOCOL_ERROR: [u8; 4] = [0x88, 0x02, 0x03, 0xea];
100
101/// Whether a request offering upgrades asks for WebSocket (the
102/// `Upgrade` header is a comma list; the token is case-insensitive
103/// per RFC 7230).
104pub fn offers_websocket(headers: &HeaderMap) -> bool {
105 headers
106 .get(hyper::header::UPGRADE)
107 .and_then(|v| v.to_str().ok())
108 .is_some_and(|v| {
109 v.split(',')
110 .any(|t| t.trim().eq_ignore_ascii_case("websocket"))
111 })
112}
113
114/// The origin gate: `true` when the handshake may proceed. A
115/// non-empty allowlist admits only exact matches; a missing `Origin`
116/// is denied under a non-empty allowlist (fail closed).
117pub fn origin_allowed(headers: &HeaderMap, ws: &RouteWebsocket) -> bool {
118 if ws.origins.is_empty() {
119 return true;
120 }
121 headers
122 .get(hyper::header::ORIGIN)
123 .and_then(|v| v.to_str().ok())
124 .is_some_and(|origin| ws.origins.iter().any(|allowed| allowed == origin))
125}
126
127/// The handshake gate's outcome for one upgrade request.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum Handshake {
130 /// Proceed with the transparent upgrade path.
131 Allowed,
132 /// A non-empty allowlist did not name this origin (or the request
133 /// carried none): deny with 403 before any upstream contact.
134 OriginDenied,
135}
136
137/// One policy decision at the handshake (DW-039): the proxy calls
138/// this before forwarding an upgrade request.
139pub fn handshake_verdict(headers: &HeaderMap, ws: &RouteWebsocket) -> Handshake {
140 if !ws.origins.is_empty() && !origin_allowed(headers, ws) {
141 return Handshake::OriginDenied;
142 }
143 Handshake::Allowed
144}
145
146/// What part of the current frame the passed bytes belong to.
147#[derive(Debug)]
148enum Scan {
149 /// Reading the fixed 2-byte frame header.
150 Head { buf: [u8; 2], have: usize },
151 /// Reading an extended length field (2 or 8 bytes; always a data
152 /// frame — control frames cannot exceed 125 payload bytes). The
153 /// mask length rides along: a masked frame's 4 mask bytes belong
154 /// to the payload span, so the skip stays in sync.
155 Extended {
156 buf: [u8; 8],
157 need: usize,
158 have: usize,
159 mask_len: u64,
160 },
161 /// Inside a payload with `remaining` bytes left (mask bytes
162 /// included: 4 when the frame is masked, which client frames
163 /// always are — the mask length is folded in here so the scanner
164 /// never has to remember it).
165 Payload { remaining: u64, data_frame: bool },
166}
167
168/// The kind of protocol violation the frame scanner detected (DP-05,
169/// #253). Stored in the shared violation flag so the tunnel spawner
170/// records the right metric and close code.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
172pub enum FrameViolation {
173 #[default]
174 None,
175 /// Data frame exceeded `max_frame_size_bytes` — close 1009.
176 SizeExceeded,
177 /// Reserved opcode (0x3-0x7 data, 0xB-0xF control) or a control
178 /// frame with an extended length (RFC 6455 caps control payloads
179 /// at 125 bytes) — close 1002.
180 ProtocolError,
181}
182
183/// Shared-flag discriminants stored in the `AtomicU64` witness.
184/// Distinct from the `FrameViolation` enum so rate-closed (which is
185/// not a frame-scanner violation) gets its own code without colliding.
186const FLAG_NONE: u64 = 0;
187const FLAG_RATE_CLOSED: u64 = 1;
188const FLAG_SIZE_CLOSED: u64 = 2;
189const FLAG_PROTOCOL_CLOSED: u64 = 3;
190
191/// Decode the shared violation flag into the metric label the tunnel
192/// spawner records (DP-05, #253). Returns an empty string when no
193/// violation occurred.
194pub fn flag_to_metric(flag: u64) -> &'static str {
195 match flag {
196 FLAG_RATE_CLOSED => "rate_closed",
197 FLAG_SIZE_CLOSED => "size_closed",
198 FLAG_PROTOCOL_CLOSED => "protocol_closed",
199 _ => "",
200 }
201}
202
203impl FrameViolation {
204 /// The close frame to send for this violation kind.
205 fn close_frame(self) -> &'static [u8] {
206 match self {
207 FrameViolation::SizeExceeded => &CLOSE_MESSAGE_TOO_BIG,
208 FrameViolation::ProtocolError => &CLOSE_PROTOCOL_ERROR,
209 FrameViolation::None => &[],
210 }
211 }
212
213 /// The shared-flag discriminant for the tunnel spawner's witness.
214 fn flag(self) -> u64 {
215 match self {
216 FrameViolation::None => FLAG_NONE,
217 FrameViolation::SizeExceeded => FLAG_SIZE_CLOSED,
218 FrameViolation::ProtocolError => FLAG_PROTOCOL_CLOSED,
219 }
220 }
221}
222
223/// The frame-boundary scanner: fed the exact byte stream the tunnel
224/// forwards client-to-upstream, counts data frames. Bytes are
225/// ANALYZED, never modified — the tunnel stays byte-exact.
226#[derive(Debug)]
227pub struct FrameCounter {
228 scan: Scan,
229 frames: u64,
230 /// Optional per-frame payload-size cap (DP-05, #253). A frame
231 /// whose payload length exceeds this trips `violation`.
232 max_frame_size: Option<u64>,
233 /// The first violation detected during scanning (size or protocol).
234 /// Once set, the scanner stops updating — the policer will close.
235 violation: FrameViolation,
236}
237
238impl FrameCounter {
239 pub fn new() -> Self {
240 FrameCounter {
241 scan: Scan::Head {
242 buf: [0, 0],
243 have: 0,
244 },
245 frames: 0,
246 max_frame_size: None,
247 violation: FrameViolation::None,
248 }
249 }
250
251 /// Configure a maximum payload size per data frame (DP-05, #253).
252 pub fn with_max_frame_size(mut self, max: u64) -> Self {
253 self.max_frame_size = Some(max);
254 self
255 }
256
257 pub fn data_frames(&self) -> u64 {
258 self.frames
259 }
260
261 /// The first violation detected, or `None` if the stream is clean.
262 pub fn violation(&self) -> FrameViolation {
263 self.violation
264 }
265
266 /// Feed the next chunk of the client-to-upstream byte stream
267 /// (chunk boundaries are irrelevant — the scanner spans them).
268 /// Public so the DW-039 unit tests can pin the spanning contract
269 /// directly; not part of the stable surface.
270 pub fn feed(&mut self, bytes: &[u8]) {
271 if self.violation != FrameViolation::None {
272 return;
273 }
274 let mut rest = bytes;
275 while !rest.is_empty() {
276 match &mut self.scan {
277 Scan::Head { buf, have } => {
278 let take = (2 - *have).min(rest.len());
279 buf[*have..*have + take].copy_from_slice(&rest[..take]);
280 *have += take;
281 rest = &rest[take..];
282 if *have < 2 {
283 continue;
284 }
285 let [b0, b1] = *buf;
286 let opcode = b0 & 0x0f;
287 // DP-05 (#253): reject reserved opcodes. RFC 6455
288 // reserves 0x3-0x7 (data) and 0xB-0xF (control).
289 // Valid: 0x0 continuation, 0x1 text, 0x2 binary,
290 // 0x8 close, 0x9 ping, 0xA pong.
291 if matches!(opcode, 0x3..=0x7 | 0xB..=0xF) {
292 self.violation = FrameViolation::ProtocolError;
293 return;
294 }
295 let data_frame = matches!(opcode, 0x0..=0x2);
296 let control = matches!(opcode, 0x8..=0xA);
297 let masked = b1 & 0x80 != 0;
298 let len7 = (b1 & 0x7f) as u64;
299 let mask_len: u64 = if masked { 4 } else { 0 };
300 // DP-05 (#253): control frames with extended
301 // lengths are a protocol error (RFC 6455 §5.5
302 // caps control payloads at 125 bytes).
303 if control && len7 >= 126 {
304 self.violation = FrameViolation::ProtocolError;
305 return;
306 }
307 // DP-05 (#253): check per-frame size cap.
308 if let Some(max) = self.max_frame_size {
309 if len7 <= 125 && len7 > max {
310 self.violation = FrameViolation::SizeExceeded;
311 return;
312 }
313 }
314 match len7 {
315 0..=125 => {
316 let total = len7 + mask_len;
317 if total == 0 {
318 if data_frame {
319 self.frames += 1;
320 }
321 self.scan = Scan::Head {
322 buf: [0, 0],
323 have: 0,
324 };
325 } else {
326 self.scan = Scan::Payload {
327 remaining: total,
328 data_frame,
329 };
330 }
331 }
332 126 => {
333 self.scan = Scan::Extended {
334 buf: [0; 8],
335 need: 2,
336 have: 0,
337 mask_len,
338 };
339 }
340 _ => {
341 self.scan = Scan::Extended {
342 buf: [0; 8],
343 need: 8,
344 have: 0,
345 mask_len,
346 };
347 }
348 }
349 }
350 Scan::Extended {
351 buf,
352 need,
353 have,
354 mask_len,
355 } => {
356 let take = (*need - *have).min(rest.len());
357 buf[*have..*have + take].copy_from_slice(&rest[..take]);
358 *have += take;
359 rest = &rest[take..];
360 if *have < *need {
361 continue;
362 }
363 let len = if *need == 2 {
364 u16::from_be_bytes([buf[0], buf[1]]) as u64
365 } else {
366 u64::from_be_bytes([
367 buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7],
368 ])
369 };
370 // DP-05 (#253): check per-frame size cap on the
371 // extended length (already known to be a data frame
372 // — control frames with extended lengths were
373 // rejected at the Head scan).
374 if let Some(max) = self.max_frame_size {
375 if len > max {
376 self.violation = FrameViolation::SizeExceeded;
377 return;
378 }
379 }
380 // Extended lengths are data frames by construction
381 // (control frames are capped at 125 bytes).
382 self.scan = Scan::Payload {
383 remaining: len + *mask_len,
384 data_frame: true,
385 };
386 }
387 Scan::Payload {
388 remaining,
389 data_frame,
390 } => {
391 let consumed = (*remaining).min(rest.len() as u64);
392 *remaining -= consumed;
393 rest = &rest[consumed as usize..];
394 if *remaining == 0 {
395 if *data_frame {
396 self.frames += 1;
397 }
398 self.scan = Scan::Head {
399 buf: [0, 0],
400 have: 0,
401 };
402 }
403 }
404 }
405 }
406 }
407}
408
409impl Default for FrameCounter {
410 fn default() -> Self {
411 Self::new()
412 }
413}
414
415/// Data frames counted for a byte stream fed in one chunk (tests and
416/// diagnostics).
417pub fn count_data_frames(bytes: &[u8]) -> u64 {
418 let mut c = FrameCounter::new();
419 c.feed(bytes);
420 c.data_frames()
421}
422
423/// A token bucket: `rate` tokens per second, capacity `rate` (a
424/// one-second burst). Refilled on access — used from exactly one
425/// task (the tunnel), so no locking.
426#[derive(Debug)]
427struct Bucket {
428 rate: f64,
429 tokens: f64,
430 last: std::time::Instant,
431}
432
433impl Bucket {
434 fn new(rate: u64) -> Self {
435 Bucket {
436 rate: rate as f64,
437 tokens: rate as f64,
438 last: std::time::Instant::now(),
439 }
440 }
441
442 /// Refill to the current instant and try to take one token.
443 fn take(&mut self) -> bool {
444 let now = std::time::Instant::now();
445 let elapsed = now.duration_since(self.last).as_secs_f64();
446 self.last = now;
447 self.tokens = (self.tokens + elapsed * self.rate).min(self.rate);
448 if self.tokens >= 1.0 {
449 self.tokens -= 1.0;
450 true
451 } else {
452 false
453 }
454 }
455}
456
457/// The policing wrapper around the UPGRADED CLIENT side of the tunnel
458/// (DW-039, DP-05 #253). Reads pass through byte-exact while the frame
459/// scanner counts; on the first unfunded data frame a 1008 close frame
460/// is queued for the write path (ahead of any other bytes) and the read
461/// side returns EOF — the tunnel then shuts both directions down.
462/// Bytes are never modified in either direction.
463///
464/// DP-05 (#253) extends policing beyond rate to per-frame size limits
465/// (close 1009) and reserved-opcode / malformed-control-length
466/// enforcement (close 1002). The shared `violated` flag carries the
467/// violation kind so the tunnel spawner records the right metric.
468pub struct WsPoliceIo<S> {
469 inner: S,
470 counter: FrameCounter,
471 bucket: Option<Bucket>,
472 /// Data frames already charged against the bucket.
473 charged: u64,
474 /// Set once a violation has been detected and the close frame
475 /// queued.
476 closing: bool,
477 close_out: Vec<u8>,
478 /// Shared with the tunnel spawner so the metric survives the
479 /// tunnel consuming the wrapper by value. Stores a `FrameViolation`
480 /// discriminant as a `u64` (0 = no violation).
481 violated: Arc<AtomicU64>,
482}
483
484impl<S> WsPoliceIo<S> {
485 /// Wrap the upgraded client IO with post-upgrade WebSocket
486 /// policies (DW-039 + DP-05 #253). `max_frames_per_sec` enables
487 /// rate policing (data frames, sustained, one-second burst);
488 /// `max_frame_size_bytes` enables per-frame size policing. Either
489 /// may be `None` to skip that policy. The violation flag is shared
490 /// with the caller so it can record the right metric after the
491 /// tunnel ends.
492 pub fn with_flag(
493 inner: S,
494 max_frames_per_sec: Option<u64>,
495 max_frame_size_bytes: Option<u64>,
496 violated: Arc<AtomicU64>,
497 ) -> Self {
498 let counter = match max_frame_size_bytes {
499 Some(max) => FrameCounter::new().with_max_frame_size(max),
500 None => FrameCounter::new(),
501 };
502 WsPoliceIo {
503 inner,
504 counter,
505 bucket: max_frames_per_sec.map(|r| Bucket::new(r.max(1))),
506 charged: 0,
507 closing: false,
508 close_out: Vec::new(),
509 violated,
510 }
511 }
512
513 /// Whether policing has tripped.
514 pub fn violated(&self) -> bool {
515 self.violated.load(Ordering::Relaxed) != 0
516 }
517}
518
519impl<S: AsyncRead + AsyncWrite + Unpin> AsyncRead for WsPoliceIo<S> {
520 fn poll_read(
521 mut self: Pin<&mut Self>,
522 cx: &mut Context<'_>,
523 buf: &mut ReadBuf<'_>,
524 ) -> Poll<io::Result<()>> {
525 let this = &mut *self;
526 if this.closing {
527 // The verdict is in: no more client bytes are forwarded
528 // (an empty ReadBuf IS the EOF signal).
529 return Poll::Ready(Ok(()));
530 }
531 match Pin::new(&mut this.inner).poll_read(cx, buf) {
532 Poll::Ready(Ok(())) => {
533 this.counter.feed(buf.filled());
534 // DP-05 (#253): check for frame-level violations
535 // (size, reserved opcode, malformed control) first —
536 // these are hard protocol errors, not rate limits.
537 let v = this.counter.violation();
538 if v != FrameViolation::None {
539 this.closing = true;
540 this.close_out.extend_from_slice(v.close_frame());
541 this.violated.store(v.flag(), Ordering::Relaxed);
542 return Poll::Ready(Ok(()));
543 }
544 // Charge one token per NEWLY counted data frame; the
545 // first frame without funding trips the policer. The
546 // bytes of THIS poll still pass (they were read before
547 // the verdict); the next read is EOF.
548 if let Some(bucket) = this.bucket.as_mut() {
549 while this.charged < this.counter.data_frames() {
550 this.charged += 1;
551 if !bucket.take() {
552 this.closing = true;
553 this.close_out.extend_from_slice(&CLOSE_POLICY_VIOLATION);
554 this.violated.store(FLAG_RATE_CLOSED, Ordering::Relaxed);
555 break;
556 }
557 }
558 }
559 Poll::Ready(Ok(()))
560 }
561 other => other,
562 }
563 }
564}
565
566impl<S: AsyncWrite + Unpin> AsyncWrite for WsPoliceIo<S> {
567 fn poll_write(
568 mut self: Pin<&mut Self>,
569 cx: &mut Context<'_>,
570 buf: &[u8],
571 ) -> Poll<io::Result<usize>> {
572 let this = &mut *self;
573 // The close frame goes first — the client must see the policy
574 // verdict even mid-burst of upstream bytes.
575 while !this.close_out.is_empty() {
576 match Pin::new(&mut this.inner).poll_write(cx, &this.close_out) {
577 Poll::Ready(Ok(n)) => {
578 this.close_out.drain(..n);
579 }
580 Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
581 Poll::Pending => return Poll::Pending,
582 }
583 }
584 Pin::new(&mut this.inner).poll_write(cx, buf)
585 }
586
587 fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
588 Pin::new(&mut self.inner).poll_flush(cx)
589 }
590
591 fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
592 Pin::new(&mut self.inner).poll_shutdown(cx)
593 }
594}