summaryrefslogtreecommitdiffstats
path: root/webd/src/session.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
committersalmonik <salmonik@tuta.io>2026-11-08 03:29:35 +0100
commit058432130628d00764663ecf819a9adc6793768d (patch)
treed3f49f111fbfaa48647cca2600adcf9017af2d30 /webd/src/session.rs
parentweb: serve latest.php through PHP and refuse stray PHP files (diff)
downloadzagan-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--webd/src/session.rs623
1 files changed, 623 insertions, 0 deletions
diff --git a/webd/src/session.rs b/webd/src/session.rs
new file mode 100644
index 0000000..8a20721
--- /dev/null
+++ b/webd/src/session.rs
@@ -0,0 +1,623 @@
1//! One gateway session: a browser socket, one upstream TLS connection and one
2//! `ClientCore`.
3//!
4//! The browser sends `Command` JSON and renders `View` JSON. All game state,
5//! protocol framing and balance arithmetic stay in the Rust core.
6
7use std::collections::HashMap;
8use std::net::TcpStream;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::{Arc, Mutex, mpsc};
11use std::thread;
12use std::time::{Duration, Instant};
13
14use rusqlite::Connection;
15use serde::{Deserialize, Serialize};
16use tungstenite::error::Error as WsError;
17use tungstenite::http::HeaderValue;
18use tungstenite::http::header::ORIGIN;
19use tungstenite::protocol::{WebSocket, WebSocketConfig};
20use tungstenite::{Message, accept_hdr_with_config};
21
22use zagan_bet::client::core::{ClientCore, Effect};
23use zagan_bet::client::database;
24use zagan_bet::client::transport::{self, TlsStream};
25use zagan_bet::client::view::{Command, View};
26use zagan_bet::protocol::Packet;
27use zagan_bet::version::VERSION;
28
29use crate::config::Config;
30
31const COOKIE_NAME: &str = "zagan_session";
32/// On HTTPS, use the `__Host-` prefix so a sibling origin cannot set a
33/// domain-scoped cookie and fixate the session.
34const HOST_COOKIE_NAME: &str = "__Host-zagan_session";
35const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
36const POLL: Duration = Duration::from_millis(10);
37
38pub enum SocketEvent {
39 Attach(WebSocket<TcpStream>),
40}
41
42/// Session id → generation and the thread that owns it.
43///
44/// The generation lets an ending thread remove its own entry without deleting
45/// a replacement session that reused the same cookie.
46pub type Registry = Arc<Mutex<HashMap<String, (u64, mpsc::Sender<SocketEvent>)>>>;
47
48static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
49
50pub fn new_registry() -> Registry {
51 Arc::new(Mutex::new(HashMap::new()))
52}
53
54/// Complete the WebSocket handshake, then attach this socket to an existing
55/// session or start one.
56pub fn accept(stream: TcpStream, config: Config, registry: Registry) -> Result<(), String> {
57 let mut session_id = None;
58 let origin = config.origin.clone();
59 let secure = config.cookie_secure;
60
61 let ws_config = WebSocketConfig {
62 // A command or view is a few KiB at most; keep a compromised page from
63 // making the gateway buffer tens of megabytes.
64 max_message_size: Some(64 * 1024),
65 max_frame_size: Some(64 * 1024),
66 ..WebSocketConfig::default()
67 };
68 let mut ws = accept_hdr_with_config(
69 stream,
70 |request: &tungstenite::handshake::server::Request,
71 mut response: tungstenite::handshake::server::Response| {
72 if let Some(header) = request.headers().get(ORIGIN) {
73 if header.to_str().ok() != Some(origin.as_str()) {
74 return Err(tungstenite::http::Response::builder()
75 .status(403)
76 .body(Some("origin not allowed".to_string()))
77 .expect("static response"));
78 }
79 }
80
81 let found = request
82 .headers()
83 .get("cookie")
84 .and_then(|value| value.to_str().ok())
85 .and_then(cookie_id);
86 let (id, fresh) = match found {
87 Some(id) => (id, false),
88 None => (random_id(), true),
89 };
90 if fresh {
91 let mut cookie = format!("{COOKIE_NAME}={id}; Path=/; HttpOnly; SameSite=Strict");
92 if secure {
93 cookie.push_str("; Secure");
94 }
95 if let Ok(value) = HeaderValue::from_str(&cookie) {
96 response.headers_mut().append("set-cookie", value);
97 }
98 }
99 session_id = Some(id);
100 Ok(response)
101 },
102 Some(ws_config),
103 )
104 .map_err(|error| format!("WebSocket handshake failed: {error}"))?;
105
106 let id = session_id.ok_or_else(|| "handshake produced no session id".to_string())?;
107 ws.get_ref()
108 .set_nonblocking(true)
109 .map_err(|error| format!("could not make the browser socket nonblocking: {error}"))?;
110
111 let (sender, receiver) = mpsc::channel();
112 let generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed);
113 {
114 let mut sessions = registry.lock().map_err(|_| "session registry poisoned")?;
115 if let Some((_, existing)) = sessions.get(&id).cloned() {
116 match existing.send(SocketEvent::Attach(ws)) {
117 Ok(()) => return Ok(()),
118 Err(mpsc::SendError(SocketEvent::Attach(returned))) => {
119 sessions.remove(&id);
120 ws = returned;
121 }
122 }
123 }
124 sessions.insert(id.clone(), (generation, sender.clone()));
125 }
126
127 thread::spawn(move || {
128 let session = match Session::new(id.clone(), config) {
129 Ok(session) => Some(session),
130 Err(error) => {
131 eprintln!("[webd {id}] could not start: {error}");
132 None
133 }
134 };
135 if let Some(mut session) = session {
136 session.attach(ws);
137 session.poll_loop(&receiver);
138 }
139 if let Ok(mut sessions) = registry.lock() {
140 let own = sessions
141 .get(&id)
142 .map(|(generation_now, _)| *generation_now == generation)
143 .unwrap_or(false);
144 if own {
145 sessions.remove(&id);
146 }
147 }
148 eprintln!("[webd {id}] session ended");
149 });
150
151 Ok(())
152}
153
154fn cookie_id(raw: &str) -> Option<String> {
155 for pair in raw.split(';') {
156 let (name, value) = pair.split_once('=')?;
157 if matches!(name.trim(), COOKIE_NAME | HOST_COOKIE_NAME) {
158 let value = value.trim();
159 if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
160 return Some(value.to_ascii_lowercase());
161 }
162 return None;
163 }
164 }
165 None
166}
167
168fn random_id() -> String {
169 use rand::RngCore;
170 let mut bytes = [0u8; 32];
171 rand::thread_rng().fill_bytes(&mut bytes);
172 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
173}
174
175#[derive(Deserialize)]
176#[serde(tag = "type", rename_all = "snake_case")]
177enum ClientMessage {
178 Command { command: Command },
179 Ping,
180}
181
182#[derive(Serialize)]
183#[serde(tag = "type", rename_all = "snake_case")]
184enum ServerMessage<'a> {
185 Hello {
186 version: &'a str,
187 grace_seconds: u64,
188 /// Signed-in user, so a refresh during a game can skip the login card.
189 user: Option<&'a str>,
190 /// Estimated game-server Unix time, milliseconds.
191 time: u64,
192 },
193 View {
194 view: &'a View,
195 },
196 Notice {
197 text: &'a str,
198 },
199 Error {
200 text: &'a str,
201 },
202 Replaced,
203 Pong,
204}
205
206struct Session {
207 id: String,
208 config: Config,
209 core: ClientCore,
210 /// In-memory trust store seeded with the pinned fingerprint. No player
211 /// credentials are ever stored here.
212 trust: Connection,
213 upstream: Option<TlsStream>,
214 ws: Option<WebSocket<TcpStream>>,
215 pending_id: Option<u64>,
216 pending_since: Option<Instant>,
217 grace_until: Option<Instant>,
218 drop_upstream: bool,
219}
220
221impl Session {
222 fn new(id: String, config: Config) -> Result<Self, String> {
223 let trust = Connection::open_in_memory()
224 .map_err(|error| format!("could not open the trust store: {error}"))?;
225 database::init_db(&trust);
226 database::save_fingerprint(&trust, &config.upstream, &config.fingerprint)
227 .map_err(|error| format!("could not pin the upstream key: {error}"))?;
228
229 Ok(Self {
230 id,
231 config,
232 core: ClientCore::new(),
233 trust,
234 upstream: None,
235 ws: None,
236 pending_id: None,
237 pending_since: None,
238 grace_until: None,
239 drop_upstream: false,
240 })
241 }
242
243 fn attach(&mut self, ws: WebSocket<TcpStream>) {
244 if let Some(mut old) = self.ws.take() {
245 send_json(&mut old, &ServerMessage::Replaced);
246 close_socket(&mut old);
247 }
248 eprintln!("[webd {}] browser attached", self.id);
249 self.ws = Some(ws);
250 self.grace_until = None;
251 let mut ws = self.ws.take().expect("just attached");
252 if let Err(text) = self.ensure_upstream() {
253 send_json(&mut ws, &ServerMessage::Error { text: &text });
254 }
255 send_json(
256 &mut ws,
257 &ServerMessage::Hello {
258 version: VERSION,
259 grace_seconds: self.config.grace.as_secs(),
260 user: self.core.user(),
261 time: self.core.server_now_ms(),
262 },
263 );
264 self.send_view(&mut ws);
265 self.ws = Some(ws);
266 }
267
268 fn poll_loop(&mut self, receiver: &mpsc::Receiver<SocketEvent>) {
269 loop {
270 match receiver.recv_timeout(POLL) {
271 Ok(SocketEvent::Attach(ws)) => self.attach(ws),
272 Err(mpsc::RecvTimeoutError::Timeout) => {}
273 Err(mpsc::RecvTimeoutError::Disconnected) => break,
274 }
275
276 self.pump_browser();
277 self.pump_upstream();
278 self.check_request_timeout();
279
280 if self.ws.is_some() {
281 self.grace_until = None;
282 } else if let Some(deadline) = self.grace_until {
283 if Instant::now() >= deadline {
284 break;
285 }
286 } else {
287 self.grace_until = Some(Instant::now() + self.config.grace);
288 }
289 }
290
291 // Dropping the TLS stream closes upstream; the server forfeits any
292 // live hand, exactly like a desktop client disconnecting.
293 self.upstream = None;
294 eprintln!("[webd {}] grace expired", self.id);
295 }
296
297 fn pump_browser(&mut self) {
298 let Some(mut ws) = self.ws.take() else {
299 return;
300 };
301 let mut keep = true;
302
303 loop {
304 match ws.read() {
305 Ok(Message::Text(text)) => {
306 if !self.handle_browser_text(&text, &mut ws) {
307 keep = false;
308 break;
309 }
310 }
311 Ok(Message::Close(_)) => {
312 keep = false;
313 break;
314 }
315 Ok(Message::Ping(payload)) => {
316 let _ = send_message(&mut ws, Message::Pong(payload));
317 }
318 Ok(Message::Binary(_)) | Ok(Message::Pong(_)) | Ok(Message::Frame(_)) => {}
319 Err(WsError::Io(error)) if error.kind() == std::io::ErrorKind::WouldBlock => break,
320 Err(WsError::ConnectionClosed) | Err(WsError::AlreadyClosed) => {
321 keep = false;
322 break;
323 }
324 Err(_) => {
325 keep = false;
326 break;
327 }
328 }
329 }
330
331 if keep {
332 self.ws = Some(ws);
333 } else {
334 self.grace_until = Some(Instant::now() + self.config.grace);
335 self.pending_id = None;
336 self.pending_since = None;
337 }
338 }
339
340 fn handle_browser_text(&mut self, text: &str, ws: &mut WebSocket<TcpStream>) -> bool {
341 let message = match serde_json::from_str::<ClientMessage>(text) {
342 Ok(message) => message,
343 Err(error) => {
344 send_json(
345 ws,
346 &ServerMessage::Error {
347 text: &format!("Malformed message: {error}"),
348 },
349 );
350 return true;
351 }
352 };
353
354 match message {
355 ClientMessage::Ping => send_json(ws, &ServerMessage::Pong),
356 ClientMessage::Command { command } => {
357 if let Err(text) = self.ensure_upstream() {
358 send_json(ws, &ServerMessage::Error { text: &text });
359 self.send_view(ws);
360 return true;
361 }
362 let effects = self.core.command(command);
363 self.apply_effects(effects, Some(ws));
364 }
365 }
366 true
367 }
368
369 fn ensure_upstream(&mut self) -> Result<(), String> {
370 if self.upstream.is_some() {
371 return Ok(());
372 }
373
374 let mut trust = |_prompt: &transport::TrustPrompt| false;
375 match transport::connect(&self.config.upstream, &self.trust, false, &mut trust) {
376 Ok(connected) => {
377 self.core.set_connected(true);
378 self.core.set_server_time(connected.server_time);
379 self.core.set_host_info(
380 Some(self.config.upstream.clone()),
381 Some(self.config.fingerprint.clone()),
382 );
383 self.upstream = Some(connected.stream);
384 eprintln!(
385 "[webd {}] upstream connected: {} ({}), ping {:.1}ms",
386 self.id,
387 self.config.upstream,
388 connected.server_version,
389 connected.elapsed.as_secs_f64() * 1000.0
390 );
391 Ok(())
392 }
393 Err(error) => {
394 self.core.reset_after_disconnect();
395 Err(format!("Upstream connection failed: {error}"))
396 }
397 }
398 }
399
400 fn pump_upstream(&mut self) {
401 let Some(mut upstream) = self.upstream.take() else {
402 return;
403 };
404 let mut ws = self.ws.take();
405 let mut disconnected = None;
406
407 loop {
408 match upstream.read_available_packet() {
409 Ok(Some(Packet::Event { body, .. })) => {
410 if let Some(effects) = self.core.handle_async_line(&body) {
411 self.apply_effects(effects, ws.as_mut());
412 }
413 }
414 Ok(Some(Packet::Reply { id, body })) => {
415 if self.pending_id == Some(id) {
416 self.pending_id = None;
417 self.pending_since = None;
418 }
419 let effects = self.core.handle_reply_id(id, &body);
420 self.apply_effects(effects, ws.as_mut());
421 }
422 Ok(None) => break,
423 Err(error) => {
424 disconnected = Some(error.to_string());
425 break;
426 }
427 }
428 }
429
430 if self.drop_upstream {
431 self.drop_upstream = false;
432 drop(upstream);
433 } else if disconnected.is_none() {
434 self.upstream = Some(upstream);
435 }
436
437 if let Some(message) = disconnected {
438 eprintln!("[webd {}] upstream lost: {message}", self.id);
439 self.core.reset_after_disconnect();
440 self.pending_id = None;
441 self.pending_since = None;
442 if let Some(ws) = ws.as_mut() {
443 send_json(
444 ws,
445 &ServerMessage::Error {
446 text: "Connection to the game server was lost",
447 },
448 );
449 self.send_view(ws);
450 }
451 }
452
453 self.ws = ws;
454 }
455
456 fn check_request_timeout(&mut self) {
457 let Some(sent) = self.pending_since else {
458 return;
459 };
460 if sent.elapsed() < REQUEST_TIMEOUT {
461 return;
462 }
463
464 let id = self.pending_id.take();
465 self.pending_since = None;
466 self.upstream = None;
467 let mut ws = self.ws.take();
468 if let Some(id) = id {
469 let effects = self.core.handle_reply_id(id, "ERROR Request timed out");
470 self.apply_effects(effects, ws.as_mut());
471 if let Some(ws) = ws.as_mut() {
472 send_json(
473 ws,
474 &ServerMessage::Notice {
475 text: "Request timed out. Disconnected.",
476 },
477 );
478 }
479 }
480 self.core.reset_after_disconnect();
481 if let Some(ws) = ws.as_mut() {
482 self.send_view(ws);
483 }
484 self.ws = ws;
485 }
486
487 fn apply_effects(&mut self, effects: Vec<Effect>, mut ws: Option<&mut WebSocket<TcpStream>>) {
488 for effect in effects {
489 match effect {
490 Effect::Print(lines) => {
491 // A multi-line print is a terminal table; the structured
492 // `View` carries that data to the browser.
493 if lines.len() == 1 {
494 if let (Some(socket), Some(line)) = (ws.as_deref_mut(), lines.first()) {
495 send_json(socket, &ServerMessage::Notice { text: line });
496 }
497 }
498 }
499 Effect::SendRequest { id, payload } => {
500 self.pending_id = Some(id);
501 self.pending_since = Some(Instant::now());
502 match self.ensure_upstream() {
503 Ok(()) => {
504 if let Some(upstream) = self.upstream.as_mut() {
505 if let Err(error) = upstream.write_request(id, None, &payload) {
506 let text = format!("Could not send request: {error}");
507 if let Some(socket) = ws.as_deref_mut() {
508 send_json(socket, &ServerMessage::Error { text: &text });
509 }
510 self.drop_upstream = true;
511 self.upstream = None;
512 }
513 }
514 }
515 Err(text) => {
516 self.pending_id = None;
517 self.pending_since = None;
518 if let Some(socket) = ws.as_deref_mut() {
519 send_json(socket, &ServerMessage::Error { text: &text });
520 }
521 }
522 }
523 }
524 Effect::Disconnect => {
525 self.upstream = None;
526 self.drop_upstream = true;
527 self.core.reset_after_disconnect();
528 }
529 Effect::Render(_) | Effect::ClearRegion | Effect::Quit => {}
530 }
531 }
532
533 if let Some(socket) = ws {
534 self.send_view(socket);
535 }
536 }
537
538 fn send_view(&self, ws: &mut WebSocket<TcpStream>) {
539 let Some(view) = self.core.view() else {
540 return;
541 };
542 send_json(ws, &ServerMessage::View { view: &view });
543 }
544}
545
546fn send_message(ws: &mut WebSocket<TcpStream>, message: Message) -> Result<(), WsError> {
547 // The read side is nonblocking; writes are small and should not fail
548 // half-way because a browser is briefly not draining its socket.
549 let _ = ws.get_ref().set_nonblocking(false);
550 let result = ws.send(message);
551 let _ = ws.get_ref().set_nonblocking(true);
552 result
553}
554
555fn send_json(ws: &mut WebSocket<TcpStream>, message: &ServerMessage<'_>) {
556 let Ok(text) = serde_json::to_string(message) else {
557 return;
558 };
559 let _ = send_message(ws, Message::Text(text));
560}
561
562fn close_socket(ws: &mut WebSocket<TcpStream>) {
563 let _ = ws.get_ref().set_nonblocking(false);
564 let _ = ws.close(None);
565 let _ = ws.get_ref().set_nonblocking(true);
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use zagan_bet::client::view::{LobbyView, View};
572
573 #[test]
574 fn reads_only_well_formed_session_cookies() {
575 let id = "a".repeat(64);
576 assert_eq!(
577 cookie_id(&format!("other=1; {COOKIE_NAME}={id}; theme=dark")),
578 Some(id)
579 );
580 assert_eq!(cookie_id(&format!("{COOKIE_NAME}=short")), None);
581 assert_eq!(cookie_id("other=1"), None);
582 assert_eq!(cookie_id(""), None);
583 }
584
585 #[test]
586 fn parses_a_browser_command_and_rejects_nonsense() {
587 let message: ClientMessage = serde_json::from_str(
588 r#"{"type":"command","command":{"Login":{"user":"alice","credential":"abc"}}}"#,
589 )
590 .expect("login command");
591 match message {
592 ClientMessage::Command { command } => {
593 assert_eq!(
594 command,
595 Command::Login {
596 user: "alice".to_string(),
597 credential: "abc".to_string()
598 }
599 );
600 }
601 _ => panic!("expected a command"),
602 }
603
604 assert!(serde_json::from_str::<ClientMessage>("{}").is_err());
605 assert!(serde_json::from_str::<ClientMessage>(r#"{"type":"nope"}"#).is_err());
606 }
607
608 #[test]
609 fn serializes_a_view_for_the_browser() {
610 let view = View::Lobby(LobbyView {
611 connected: true,
612 user: Some("alice".to_string()),
613 account: Some("1".to_string()),
614 balance: Some(1000),
615 games: vec![("slots".to_string(), "Slots".to_string())],
616 login_error: None,
617 });
618 let json = serde_json::to_string(&ServerMessage::View { view: &view }).unwrap();
619 assert!(json.contains(r#""type":"view""#));
620 assert!(json.contains(r#""screen":"lobby""#));
621 assert!(json.contains(r#""user":"alice""#));
622 }
623}