diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-08 03:29:35 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-08 03:29:35 +0100 |
| commit | 058432130628d00764663ecf819a9adc6793768d (patch) | |
| tree | d3f49f111fbfaa48647cca2600adcf9017af2d30 /src/protocol.rs | |
| parent | web: serve latest.php through PHP and refuse stray PHP files (diff) | |
| download | zagan-bet-058432130628d00764663ecf819a9adc6793768d.tar.gz zagan-bet-058432130628d00764663ecf819a9adc6793768d.zip | |
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.
Diffstat (limited to '')
| -rw-r--r-- | src/protocol.rs | 226 |
1 files changed, 198 insertions, 28 deletions
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 @@ | |||
| 1 | //! Newline-delimited protocol helpers shared by the client and server. | 1 | //! Protocol v5: bounded frames and the request/reply/event envelope. |
| 2 | //! | 2 | //! |
| 3 | //! TLS reads can return more than one line at a time. Keeping the leftover | 3 | //! Every packet is `[u32 length][UTF-8 payload]`. The length is big-endian and |
| 4 | //! bytes is essential: dropping them silently loses the next message. | 4 | //! capped, so a peer cannot make the reader allocate without bound. Inside a |
| 5 | //! frame the payload is text: | ||
| 6 | //! | ||
| 7 | //! ```text | ||
| 8 | //! REQ <id> [idem=<token>] <command> | ||
| 9 | //! RES <id> <body> | ||
| 10 | //! EVT <seq> <body> | ||
| 11 | //! ``` | ||
| 12 | //! | ||
| 13 | //! The command and body grammar is the same text protocol the games already | ||
| 14 | //! use (`HELLO`, `PLAY_START`, `GAME_RESULT`, ...). The envelope only adds | ||
| 15 | //! correlation, event numbering and a hard size limit. | ||
| 16 | |||
| 17 | use std::fmt; | ||
| 18 | use std::io::{self, Read, Write}; | ||
| 19 | |||
| 20 | /// Largest payload a peer may send in one frame. A game message is a few | ||
| 21 | /// hundred bytes; 64 KiB is generous and still bounds the reader. | ||
| 22 | pub const MAX_FRAME_LEN: usize = 64 * 1024; | ||
| 23 | |||
| 24 | /// How many request ids a connection remembers for idempotent retries. | ||
| 25 | pub const REPLAY_CACHE_LEN: usize = 256; | ||
| 26 | |||
| 27 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 28 | pub enum FrameError { | ||
| 29 | /// The declared payload length exceeds [`MAX_FRAME_LEN`]. | ||
| 30 | TooLarge(usize), | ||
| 31 | /// The payload was not valid UTF-8. | ||
| 32 | InvalidUtf8, | ||
| 33 | } | ||
| 34 | |||
| 35 | impl fmt::Display for FrameError { | ||
| 36 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| 37 | match self { | ||
| 38 | FrameError::TooLarge(length) => { | ||
| 39 | write!( | ||
| 40 | f, | ||
| 41 | "frame of {length} bytes exceeds the {MAX_FRAME_LEN}-byte limit" | ||
| 42 | ) | ||
| 43 | } | ||
| 44 | FrameError::InvalidUtf8 => write!(f, "frame payload is not valid UTF-8"), | ||
| 45 | } | ||
| 46 | } | ||
| 47 | } | ||
| 5 | 48 | ||
| 6 | use std::io::{self, Read}; | 49 | impl std::error::Error for FrameError {} |
| 7 | 50 | ||
| 51 | /// A bounded frame decoder. Feed it bytes from a socket, take whole frames. | ||
| 8 | #[derive(Debug, Default)] | 52 | #[derive(Debug, Default)] |
| 9 | pub struct LineBuffer { | 53 | pub struct FrameBuffer { |
| 10 | buffer: Vec<u8>, | 54 | buffer: Vec<u8>, |
| 11 | } | 55 | } |
| 12 | 56 | ||
| 13 | impl LineBuffer { | 57 | impl FrameBuffer { |
| 14 | pub fn new() -> Self { | 58 | pub fn new() -> Self { |
| 15 | Self::default() | 59 | Self::default() |
| 16 | } | 60 | } |
| @@ -19,46 +63,172 @@ impl LineBuffer { | |||
| 19 | self.buffer.extend_from_slice(bytes); | 63 | self.buffer.extend_from_slice(bytes); |
| 20 | } | 64 | } |
| 21 | 65 | ||
| 22 | /// Return the next complete line, including a line that ended at EOF. | 66 | /// Return the next complete frame, if the buffer holds one. |
| 23 | pub fn next_line(&mut self) -> Option<String> { | 67 | /// |
| 24 | let end = self.buffer.iter().position(|byte| *byte == b'\n')?; | 68 | /// A declared length above the limit is reported immediately instead of |
| 25 | let mut line: Vec<u8> = self.buffer.drain(..=end).collect(); | 69 | /// waiting for bytes that will never be accepted; the caller should close |
| 26 | line.pop(); // '\n' | 70 | /// the connection. |
| 27 | if line.last() == Some(&b'\r') { | 71 | pub fn next_frame(&mut self) -> Option<Result<String, FrameError>> { |
| 28 | line.pop(); | 72 | if self.buffer.len() < 4 { |
| 73 | return None; | ||
| 29 | } | 74 | } |
| 30 | Some(String::from_utf8_lossy(&line).into_owned()) | ||
| 31 | } | ||
| 32 | 75 | ||
| 33 | /// Return the unterminated remainder at EOF, if any. | 76 | let length = u32::from_be_bytes([ |
| 34 | pub fn take_rest(&mut self) -> Option<String> { | 77 | self.buffer[0], |
| 35 | if self.buffer.is_empty() { | 78 | self.buffer[1], |
| 79 | self.buffer[2], | ||
| 80 | self.buffer[3], | ||
| 81 | ]) as usize; | ||
| 82 | |||
| 83 | if length > MAX_FRAME_LEN { | ||
| 84 | self.buffer.clear(); | ||
| 85 | return Some(Err(FrameError::TooLarge(length))); | ||
| 86 | } | ||
| 87 | if self.buffer.len() < 4 + length { | ||
| 36 | return None; | 88 | return None; |
| 37 | } | 89 | } |
| 38 | let mut rest = std::mem::take(&mut self.buffer); | 90 | |
| 39 | if rest.last() == Some(&b'\r') { | 91 | let payload: Vec<u8> = self.buffer[4..4 + length].to_vec(); |
| 40 | rest.pop(); | 92 | self.buffer.drain(..4 + length); |
| 93 | match String::from_utf8(payload) { | ||
| 94 | Ok(text) => Some(Ok(text)), | ||
| 95 | Err(_) => Some(Err(FrameError::InvalidUtf8)), | ||
| 41 | } | 96 | } |
| 42 | Some(String::from_utf8_lossy(&rest).into_owned()) | ||
| 43 | } | 97 | } |
| 44 | 98 | ||
| 45 | /// Read one line from a blocking reader, preserving any extra lines for | 99 | /// Read one frame from a blocking reader, preserving leftover bytes. |
| 46 | /// the next call. Returns `None` at EOF with nothing buffered. | 100 | /// `None` means EOF with no complete frame buffered. |
| 47 | pub fn read_line<R: Read>(&mut self, reader: &mut R) -> io::Result<Option<String>> { | 101 | pub fn read_frame<R: Read>(&mut self, reader: &mut R) -> io::Result<Option<String>> { |
| 48 | loop { | 102 | loop { |
| 49 | if let Some(line) = self.next_line() { | 103 | match self.next_frame() { |
| 50 | return Ok(Some(line)); | 104 | Some(Ok(frame)) => return Ok(Some(frame)), |
| 105 | Some(Err(error)) => return Err(io::Error::new(io::ErrorKind::InvalidData, error)), | ||
| 106 | None => {} | ||
| 51 | } | 107 | } |
| 52 | 108 | ||
| 53 | let mut chunk = [0u8; 4096]; | 109 | let mut chunk = [0u8; 4096]; |
| 54 | match reader.read(&mut chunk) { | 110 | match reader.read(&mut chunk) { |
| 55 | Ok(0) => return Ok(self.take_rest()), | 111 | Ok(0) => return Ok(None), |
| 56 | Ok(n) => self.push(&chunk[..n]), | 112 | Ok(n) => self.push(&chunk[..n]), |
| 57 | Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, | 113 | Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, |
| 58 | Err(error) => return Err(error), | 114 | Err(error) => return Err(error), |
| 59 | } | 115 | } |
| 60 | } | 116 | } |
| 61 | } | 117 | } |
| 118 | |||
| 119 | /// True when a partial frame is buffered (used by tests and diagnostics). | ||
| 120 | pub fn is_empty(&self) -> bool { | ||
| 121 | self.buffer.is_empty() | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | /// Encode one payload as a length-prefixed frame. | ||
| 126 | pub fn encode_frame(payload: &str) -> Vec<u8> { | ||
| 127 | let length = payload.len() as u32; | ||
| 128 | let mut bytes = Vec::with_capacity(4 + payload.len()); | ||
| 129 | bytes.extend_from_slice(&length.to_be_bytes()); | ||
| 130 | bytes.extend_from_slice(payload.as_bytes()); | ||
| 131 | bytes | ||
| 132 | } | ||
| 133 | |||
| 134 | /// Write one payload as a length-prefixed frame. | ||
| 135 | pub fn write_frame<W: Write>(writer: &mut W, payload: &str) -> io::Result<()> { | ||
| 136 | writer.write_all(&encode_frame(payload))?; | ||
| 137 | writer.flush() | ||
| 138 | } | ||
| 139 | |||
| 140 | /// One client request. `id` is the correlation key; `idem` is optional | ||
| 141 | /// metadata for logs and a future cross-connection cache. | ||
| 142 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 143 | pub struct Request { | ||
| 144 | pub id: u64, | ||
| 145 | pub idem: Option<String>, | ||
| 146 | pub payload: String, | ||
| 147 | } | ||
| 148 | |||
| 149 | /// A frame received by a client. | ||
| 150 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 151 | pub enum Packet { | ||
| 152 | Reply { id: u64, body: String }, | ||
| 153 | Event { seq: u64, body: String }, | ||
| 154 | } | ||
| 155 | |||
| 156 | pub fn request_line(id: u64, idem: Option<&str>, payload: &str) -> String { | ||
| 157 | match idem { | ||
| 158 | Some(idem) if !idem.is_empty() => format!("REQ {id} idem={idem} {payload}"), | ||
| 159 | _ => format!("REQ {id} {payload}"), | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 163 | pub fn reply_line(id: u64, body: &str) -> String { | ||
| 164 | format!("RES {id} {body}") | ||
| 165 | } | ||
| 166 | |||
| 167 | pub fn event_line(seq: u64, body: &str) -> String { | ||
| 168 | format!("EVT {seq} {body}") | ||
| 169 | } | ||
| 170 | |||
| 171 | /// Parse a `REQ` payload. Errors are safe to return to the peer. | ||
| 172 | pub fn parse_request(frame: &str) -> Result<Request, String> { | ||
| 173 | let frame = frame.trim_end(); | ||
| 174 | let rest = frame | ||
| 175 | .strip_prefix("REQ ") | ||
| 176 | .ok_or_else(|| "Malformed request: expected REQ".to_string())?; | ||
| 177 | |||
| 178 | let (id_token, mut rest) = split_token(rest); | ||
| 179 | let id: u64 = id_token | ||
| 180 | .parse() | ||
| 181 | .map_err(|_| "Malformed request id".to_string())?; | ||
| 182 | |||
| 183 | let mut idem = None; | ||
| 184 | let (next, after) = split_token(rest); | ||
| 185 | if let Some(value) = next.strip_prefix("idem=") { | ||
| 186 | if value.is_empty() { | ||
| 187 | return Err("Malformed idempotency key".to_string()); | ||
| 188 | } | ||
| 189 | idem = Some(value.to_string()); | ||
| 190 | rest = after; | ||
| 191 | } | ||
| 192 | |||
| 193 | Ok(Request { | ||
| 194 | id, | ||
| 195 | idem, | ||
| 196 | payload: rest.to_string(), | ||
| 197 | }) | ||
| 198 | } | ||
| 199 | |||
| 200 | /// Parse a `RES` or `EVT` payload on the client side. | ||
| 201 | pub fn parse_packet(frame: &str) -> Result<Packet, String> { | ||
| 202 | let frame = frame.trim_end(); | ||
| 203 | if let Some(rest) = frame.strip_prefix("RES ") { | ||
| 204 | let (id_token, body) = split_token(rest); | ||
| 205 | let id: u64 = id_token | ||
| 206 | .parse() | ||
| 207 | .map_err(|_| "Malformed reply id".to_string())?; | ||
| 208 | return Ok(Packet::Reply { | ||
| 209 | id, | ||
| 210 | body: body.to_string(), | ||
| 211 | }); | ||
| 212 | } | ||
| 213 | if let Some(rest) = frame.strip_prefix("EVT ") { | ||
| 214 | let (seq_token, body) = split_token(rest); | ||
| 215 | let seq: u64 = seq_token | ||
| 216 | .parse() | ||
| 217 | .map_err(|_| "Malformed event sequence".to_string())?; | ||
| 218 | return Ok(Packet::Event { | ||
| 219 | seq, | ||
| 220 | body: body.to_string(), | ||
| 221 | }); | ||
| 222 | } | ||
| 223 | Err(format!("Unexpected packet: {frame}")) | ||
| 224 | } | ||
| 225 | |||
| 226 | fn split_token(rest: &str) -> (&str, &str) { | ||
| 227 | let rest = rest.trim_start(); | ||
| 228 | match rest.find(' ') { | ||
| 229 | Some(index) => (&rest[..index], rest[index + 1..].trim_start()), | ||
| 230 | None => (rest, ""), | ||
| 231 | } | ||
| 62 | } | 232 | } |
| 63 | 233 | ||
| 64 | /// A stable, user-facing message for a failed request. | 234 | /// A stable, user-facing message for a failed request. |
