From 058432130628d00764663ecf819a9adc6793768d Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 8 Nov 2026 03:29:35 +0100 Subject: v5.0.0: protocol v5 and the browser frontend Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point. Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials. Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph. The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO. --- src/protocol.rs | 226 +++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 198 insertions(+), 28 deletions(-) (limited to 'src/protocol.rs') diff --git a/src/protocol.rs b/src/protocol.rs index 11f54a1..e838b08 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,16 +1,60 @@ -//! Newline-delimited protocol helpers shared by the client and server. +//! Protocol v5: bounded frames and the request/reply/event envelope. //! -//! TLS reads can return more than one line at a time. Keeping the leftover -//! bytes is essential: dropping them silently loses the next message. +//! Every packet is `[u32 length][UTF-8 payload]`. The length is big-endian and +//! capped, so a peer cannot make the reader allocate without bound. Inside a +//! frame the payload is text: +//! +//! ```text +//! REQ [idem=] +//! RES +//! EVT +//! ``` +//! +//! The command and body grammar is the same text protocol the games already +//! use (`HELLO`, `PLAY_START`, `GAME_RESULT`, ...). The envelope only adds +//! correlation, event numbering and a hard size limit. + +use std::fmt; +use std::io::{self, Read, Write}; + +/// Largest payload a peer may send in one frame. A game message is a few +/// hundred bytes; 64 KiB is generous and still bounds the reader. +pub const MAX_FRAME_LEN: usize = 64 * 1024; + +/// How many request ids a connection remembers for idempotent retries. +pub const REPLAY_CACHE_LEN: usize = 256; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrameError { + /// The declared payload length exceeds [`MAX_FRAME_LEN`]. + TooLarge(usize), + /// The payload was not valid UTF-8. + InvalidUtf8, +} + +impl fmt::Display for FrameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FrameError::TooLarge(length) => { + write!( + f, + "frame of {length} bytes exceeds the {MAX_FRAME_LEN}-byte limit" + ) + } + FrameError::InvalidUtf8 => write!(f, "frame payload is not valid UTF-8"), + } + } +} -use std::io::{self, Read}; +impl std::error::Error for FrameError {} +/// A bounded frame decoder. Feed it bytes from a socket, take whole frames. #[derive(Debug, Default)] -pub struct LineBuffer { +pub struct FrameBuffer { buffer: Vec, } -impl LineBuffer { +impl FrameBuffer { pub fn new() -> Self { Self::default() } @@ -19,46 +63,172 @@ impl LineBuffer { self.buffer.extend_from_slice(bytes); } - /// Return the next complete line, including a line that ended at EOF. - pub fn next_line(&mut self) -> Option { - let end = self.buffer.iter().position(|byte| *byte == b'\n')?; - let mut line: Vec = self.buffer.drain(..=end).collect(); - line.pop(); // '\n' - if line.last() == Some(&b'\r') { - line.pop(); + /// Return the next complete frame, if the buffer holds one. + /// + /// A declared length above the limit is reported immediately instead of + /// waiting for bytes that will never be accepted; the caller should close + /// the connection. + pub fn next_frame(&mut self) -> Option> { + if self.buffer.len() < 4 { + return None; } - Some(String::from_utf8_lossy(&line).into_owned()) - } - /// Return the unterminated remainder at EOF, if any. - pub fn take_rest(&mut self) -> Option { - if self.buffer.is_empty() { + let length = u32::from_be_bytes([ + self.buffer[0], + self.buffer[1], + self.buffer[2], + self.buffer[3], + ]) as usize; + + if length > MAX_FRAME_LEN { + self.buffer.clear(); + return Some(Err(FrameError::TooLarge(length))); + } + if self.buffer.len() < 4 + length { return None; } - let mut rest = std::mem::take(&mut self.buffer); - if rest.last() == Some(&b'\r') { - rest.pop(); + + let payload: Vec = self.buffer[4..4 + length].to_vec(); + self.buffer.drain(..4 + length); + match String::from_utf8(payload) { + Ok(text) => Some(Ok(text)), + Err(_) => Some(Err(FrameError::InvalidUtf8)), } - Some(String::from_utf8_lossy(&rest).into_owned()) } - /// Read one line from a blocking reader, preserving any extra lines for - /// the next call. Returns `None` at EOF with nothing buffered. - pub fn read_line(&mut self, reader: &mut R) -> io::Result> { + /// Read one frame from a blocking reader, preserving leftover bytes. + /// `None` means EOF with no complete frame buffered. + pub fn read_frame(&mut self, reader: &mut R) -> io::Result> { loop { - if let Some(line) = self.next_line() { - return Ok(Some(line)); + match self.next_frame() { + Some(Ok(frame)) => return Ok(Some(frame)), + Some(Err(error)) => return Err(io::Error::new(io::ErrorKind::InvalidData, error)), + None => {} } let mut chunk = [0u8; 4096]; match reader.read(&mut chunk) { - Ok(0) => return Ok(self.take_rest()), + Ok(0) => return Ok(None), Ok(n) => self.push(&chunk[..n]), Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, Err(error) => return Err(error), } } } + + /// True when a partial frame is buffered (used by tests and diagnostics). + pub fn is_empty(&self) -> bool { + self.buffer.is_empty() + } +} + +/// Encode one payload as a length-prefixed frame. +pub fn encode_frame(payload: &str) -> Vec { + let length = payload.len() as u32; + let mut bytes = Vec::with_capacity(4 + payload.len()); + bytes.extend_from_slice(&length.to_be_bytes()); + bytes.extend_from_slice(payload.as_bytes()); + bytes +} + +/// Write one payload as a length-prefixed frame. +pub fn write_frame(writer: &mut W, payload: &str) -> io::Result<()> { + writer.write_all(&encode_frame(payload))?; + writer.flush() +} + +/// One client request. `id` is the correlation key; `idem` is optional +/// metadata for logs and a future cross-connection cache. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Request { + pub id: u64, + pub idem: Option, + pub payload: String, +} + +/// A frame received by a client. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Packet { + Reply { id: u64, body: String }, + Event { seq: u64, body: String }, +} + +pub fn request_line(id: u64, idem: Option<&str>, payload: &str) -> String { + match idem { + Some(idem) if !idem.is_empty() => format!("REQ {id} idem={idem} {payload}"), + _ => format!("REQ {id} {payload}"), + } +} + +pub fn reply_line(id: u64, body: &str) -> String { + format!("RES {id} {body}") +} + +pub fn event_line(seq: u64, body: &str) -> String { + format!("EVT {seq} {body}") +} + +/// Parse a `REQ` payload. Errors are safe to return to the peer. +pub fn parse_request(frame: &str) -> Result { + let frame = frame.trim_end(); + let rest = frame + .strip_prefix("REQ ") + .ok_or_else(|| "Malformed request: expected REQ".to_string())?; + + let (id_token, mut rest) = split_token(rest); + let id: u64 = id_token + .parse() + .map_err(|_| "Malformed request id".to_string())?; + + let mut idem = None; + let (next, after) = split_token(rest); + if let Some(value) = next.strip_prefix("idem=") { + if value.is_empty() { + return Err("Malformed idempotency key".to_string()); + } + idem = Some(value.to_string()); + rest = after; + } + + Ok(Request { + id, + idem, + payload: rest.to_string(), + }) +} + +/// Parse a `RES` or `EVT` payload on the client side. +pub fn parse_packet(frame: &str) -> Result { + let frame = frame.trim_end(); + if let Some(rest) = frame.strip_prefix("RES ") { + let (id_token, body) = split_token(rest); + let id: u64 = id_token + .parse() + .map_err(|_| "Malformed reply id".to_string())?; + return Ok(Packet::Reply { + id, + body: body.to_string(), + }); + } + if let Some(rest) = frame.strip_prefix("EVT ") { + let (seq_token, body) = split_token(rest); + let seq: u64 = seq_token + .parse() + .map_err(|_| "Malformed event sequence".to_string())?; + return Ok(Packet::Event { + seq, + body: body.to_string(), + }); + } + Err(format!("Unexpected packet: {frame}")) +} + +fn split_token(rest: &str) -> (&str, &str) { + let rest = rest.trim_start(); + match rest.find(' ') { + Some(index) => (&rest[..index], rest[index + 1..].trim_start()), + None => (rest, ""), + } } /// A stable, user-facing message for a failed request. -- cgit v1.2.3