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/bin/client/connection.rs | 6 +-- src/bin/client/repl.rs | 95 +++++++++++++++++++++++--------------------- src/bin/server/handler.rs | 47 +++++++++++++++------- src/bin/server/tls.rs | 27 ++++++------- 4 files changed, 97 insertions(+), 78 deletions(-) (limited to 'src/bin') diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs index f88f62b..e808d90 100644 --- a/src/bin/client/connection.rs +++ b/src/bin/client/connection.rs @@ -8,7 +8,7 @@ use parking_lot::Mutex; use std::error::Error; use zagan_bet::client::transport::{self, TrustPrompt}; use zagan_bet::client::ui::first_contact_lines; -use zagan_bet::version::{is_newer, VERSION}; +use zagan_bet::version::{VERSION, is_newer}; pub use zagan_bet::client::transport::TlsStream; @@ -21,7 +21,7 @@ pub fn set_unsafe_mode(unsafe_mode: bool) { pub fn connect( addr: &str, conn: &rusqlite::Connection, -) -> Result<(TlsStream, Option), Box> { +) -> Result<(TlsStream, Option, u64), Box> { let unsafe_mode = *UNSAFE_MODE.lock(); // The terminal has no dialog: print the first-contact notice and trust. @@ -53,5 +53,5 @@ pub fn connect( println!("Authentication failed: {error}"); } - Ok((connected.stream, connected.user)) + Ok((connected.stream, connected.user, connected.server_time)) } diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index cfdcdd5..deb5743 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs @@ -14,15 +14,15 @@ use crossterm::{ use sha2::{Digest, Sha256}; use zagan_bet::client::core::{ClientCore, Effect}; -use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; -use zagan_bet::client::shell::{self, Shell}; -use zagan_bet::client::view::{RoulettePhase, View}; use zagan_bet::client::database; +use zagan_bet::client::input::{Editor, GameInput, Input, Key, game_input}; +use zagan_bet::client::shell::{self, Shell}; use zagan_bet::client::ui::{ - display_width, format_pong, help_lines, host_lines, input_cursor_column, - lines_to_move_up, terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, + MIN_COLUMNS, MIN_ROWS, display_width, format_pong, help_lines, host_lines, input_cursor_column, + lines_to_move_up, terminal_size_warning, visual_line_count, }; -use zagan_bet::protocol::io_failure_message; +use zagan_bet::client::view::{RoulettePhase, View}; +use zagan_bet::protocol::{Packet, io_failure_message}; use zagan_bet::roulette::{WHEEL, color as roulette_color}; use crate::connection::TlsStream; @@ -78,9 +78,10 @@ impl ClientState { pub fn connect_to(&mut self, addr: &str) { self.addr = Some(addr.to_string()); match crate::connection::connect(addr, &self.conn) { - Ok((stream, authed_user)) => { + Ok((stream, authed_user, server_time)) => { self.stream = Some(stream); self.core.set_connected(true); + self.core.set_server_time(server_time); self.core.set_logged_in(authed_user); let fingerprint = database::get_fingerprint(&self.conn, addr); self.core.set_host_info(Some(addr.to_string()), fingerprint); @@ -126,9 +127,7 @@ impl ClientState { key, ) { GameInput::Quit => vec![Effect::Quit], - GameInput::Command(command) => { - self.core.command(command) - } + GameInput::Command(command) => self.core.command(command), GameInput::None => Vec::new(), } } else { @@ -189,10 +188,10 @@ impl ClientState { let Some(mut stream) = self.stream.take() else { return false; }; - let line = match stream.read_available_line() { - Ok(line) => { + let packet = match stream.read_available_packet() { + Ok(packet) => { self.stream = Some(stream); - line + packet } Err(_) => { self.core.set_connected(false); @@ -201,13 +200,15 @@ impl ClientState { } }; - let Some(line) = line else { + let Some(packet) = packet else { return false; }; - let effects = match self.core.handle_async_line(&line) { - Some(effects) => effects, - None => self.core.handle_reply(&line), + let effects = match packet { + Packet::Event { body, .. } => { + self.core.handle_async_line(&body).unwrap_or_default() + } + Packet::Reply { id, body } => self.core.handle_reply_id(id, &body), }; if self.apply_effects(effects) { return true; @@ -259,13 +260,13 @@ impl ClientState { self.draw_region(&lines); } Effect::ClearRegion => self.clear_region(), - Effect::SendLine(line) => { - let is_ping = line == "PING"; + Effect::SendRequest { id, payload } => { + let is_ping = payload == "PING"; let started = Instant::now(); - let reply = self.send_and_read(&line); + let reply = self.send_and_read(id, &payload); let elapsed = started.elapsed(); - let mut more = self.core.handle_reply(&reply); - if is_ping && reply.trim() == "PONG" { + let mut more = self.core.handle_reply_id(id, &reply); + if is_ping && reply.trim().starts_with("PONG") { for effect in &mut more { if let Effect::Print(lines) = effect { for text in lines { @@ -488,31 +489,30 @@ impl ClientState { self.prompt_text = text.to_string(); } - fn send_and_read(&mut self, line: &str) -> String { + fn send_and_read(&mut self, id: u64, line: &str) -> String { let Some(mut stream) = self.stream.take() else { return "ERROR Not connected".to_string(); }; // A crash event can be pushed while a request is in flight. Route those - // lines as they arrive and keep waiting for the actual reply. + // events as they arrive and keep waiting for the reply with this id. let mut pushed: Vec = Vec::new(); let result = (|| -> std::io::Result { - stream.write_line(line)?; + stream.write_request(id, None, line)?; loop { - let mut response = String::new(); - let read = stream.read_line(&mut response)?; - if read == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "connection closed", - )); - } - let response = response.trim_end().to_string(); - if self.core.is_async(&response) { - pushed.push(response); - continue; + match stream.read_packet()? { + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "connection closed", + )); + } + Some(Packet::Event { body, .. }) => pushed.push(body), + Some(Packet::Reply { id: reply_id, body }) if reply_id == id => { + return Ok(body); + } + Some(Packet::Reply { .. }) => continue, } - return Ok(response); } })(); @@ -614,7 +614,11 @@ impl ClientState { short_fingerprint(&identity), host ), - Ok(false) => println!("No saved login for server {} ({}).", short_fingerprint(&identity), host), + Ok(false) => println!( + "No saved login for server {} ({}).", + short_fingerprint(&identity), + host + ), Err(error) => println!("Failed to forget the saved login: {}", error), } } @@ -625,10 +629,9 @@ impl ClientState { return; }; match database::forget_host(&self.conn, &target) { - Ok(outcome) if outcome.credentials_removed => println!( - "Cleared trust and stored credentials for {}.", - target - ), + Ok(outcome) if outcome.credentials_removed => { + println!("Cleared trust and stored credentials for {}.", target) + } Ok(outcome) => { println!("Cleared trust for {}.", target); if !outcome.siblings.is_empty() { @@ -665,7 +668,8 @@ impl ClientState { let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); let message = format!("AUTH {} {}", username, hashed); - let response = self.send_and_read(&message); + let id = self.core.allocate_request_id(); + let response = self.send_and_read(id, &message); let response = response.trim(); if response.starts_with("OK") { @@ -709,7 +713,8 @@ impl ClientState { let hashed = format!("{:x}", Sha256::digest(password.as_bytes())); let message = format!("REGISTER {} {}", username, hashed); - let response = self.send_and_read(&message); + let id = self.core.allocate_request_id(); + let response = self.send_and_read(id, &message); let response = response.trim(); if response == format!("REGISTERED {}", username) { diff --git a/src/bin/server/handler.rs b/src/bin/server/handler.rs index dffae69..e6b623f 100644 --- a/src/bin/server/handler.rs +++ b/src/bin/server/handler.rs @@ -1,6 +1,7 @@ use std::time::Duration; use crate::tls::TlsStream; +use zagan_bet::protocol::{event_line, parse_request, reply_line}; use zagan_bet::server::core::{ConnectionState, ServerCore}; /// How long the connection waits for a request before advancing timed games. @@ -30,24 +31,35 @@ pub fn handle_client_tls(mut stream: TlsStream) { } let mut state = ConnectionState::default(); + let mut event_seq = 0u64; println!("[TLS Client connected] {client_addr}"); 'connection: loop { - let mut line = String::new(); - match stream.read_line(&mut line) { + let mut frame = String::new(); + match stream.read_frame(&mut frame) { Ok(0) => break, Ok(_) => { - let line = line.trim(); - if !line.is_empty() { - let user = state.user().unwrap_or("guest").to_string(); - println!("[{user} @ {client_addr}] Received: {line}"); - let response = core.handle_message(&mut state, line); - if let Err(error) = stream.write_line(&response) { - eprintln!("Write error: {error}"); - break; + let user = state.user().unwrap_or("guest").to_string(); + println!("[{user} @ {client_addr}] Received: {frame}"); + match parse_request(&frame) { + Ok(request) => { + let response = core.handle_request(&mut state, &request); + let reply = reply_line(request.id, &response); + if let Err(error) = stream.write_frame(&reply) { + eprintln!("Write error: {error}"); + break; + } + let user = state.user().unwrap_or("guest"); + println!("[{user} @ {client_addr}] Sent: {reply}"); + } + Err(message) => { + let reply = reply_line(0, &format!("ERROR {message}")); + if let Err(error) = stream.write_frame(&reply) { + eprintln!("Write error: {error}"); + break; + } + eprintln!("Malformed request from {client_addr}: {reply}"); } - let user = state.user().unwrap_or("guest"); - println!("[{user} @ {client_addr}] Sent: {response}"); } } Err(error) @@ -65,12 +77,17 @@ pub fn handle_client_tls(mut stream: TlsStream) { } for push in core.poll(&state) { - if let Err(error) = stream.write_line(&push) { + event_seq = event_seq.wrapping_add(1); + let event = event_line(event_seq, &push); + if let Err(error) = stream.write_frame(&event) { eprintln!("Write error: {error}"); break 'connection; } - let user = state.user().unwrap_or("guest"); - println!("[{user} @ {client_addr}] Pushed: {push}"); + // Ticks are 10 Hz per live round; logging them floods the disk. + if !push.starts_with("crash.tick ") { + let user = state.user().unwrap_or("guest"); + println!("[{user} @ {client_addr}] Pushed: {event}"); + } } } diff --git a/src/bin/server/tls.rs b/src/bin/server/tls.rs index 420b5b4..eb94637 100644 --- a/src/bin/server/tls.rs +++ b/src/bin/server/tls.rs @@ -1,7 +1,7 @@ use std::io::{Read, Write}; use std::net::TcpStream; -use zagan_bet::protocol::LineBuffer; +use zagan_bet::protocol::{FrameBuffer, write_frame}; #[derive(Clone)] pub struct TlsServer { @@ -44,34 +44,31 @@ impl TlsServer { pub struct TlsStream { stream: native_tls::TlsStream, - lines: LineBuffer, + frames: FrameBuffer, } impl TlsStream { pub fn new(stream: native_tls::TlsStream) -> Self { Self { stream, - lines: LineBuffer::new(), + frames: FrameBuffer::new(), } } - pub fn read_line(&mut self, s: &mut String) -> std::io::Result { - match self.lines.read_line(&mut self.stream)? { - Some(line) => { - s.push_str(&line); - s.push('\n'); - Ok(line.len() + 1) + /// Read one protocol frame. `Ok(0)` is EOF with no partial frame. + pub fn read_frame(&mut self, out: &mut String) -> std::io::Result { + match self.frames.read_frame(&mut self.stream)? { + Some(frame) => { + let length = frame.len(); + out.push_str(&frame); + Ok(length) } None => Ok(0), } } - pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { - use std::io::Write; - self.stream.write_all(msg.as_bytes())?; - self.stream.write_all(b"\n")?; - self.stream.flush()?; - Ok(()) + pub fn write_frame(&mut self, msg: &str) -> std::io::Result<()> { + write_frame(&mut self.stream, msg) } pub fn peer_addr(&self) -> std::io::Result { -- cgit v1.2.3