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}