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. --- webd/src/config.rs | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 webd/src/config.rs (limited to 'webd/src/config.rs') diff --git a/webd/src/config.rs b/webd/src/config.rs new file mode 100644 index 0000000..d3ada8f --- /dev/null +++ b/webd/src/config.rs @@ -0,0 +1,95 @@ +//! Gateway configuration from the environment. +//! +//! The upstream key is mandatory. There is no trust-on-first-use and no unsafe +//! mode: the browser's credentials ride on this connection. + +use std::env; +use std::time::Duration; + +pub const DEFAULT_BIND: &str = "127.0.0.1:8890"; +pub const DEFAULT_UPSTREAM: &str = "127.0.0.1:11525"; +pub const DEFAULT_GRACE_SECONDS: u64 = 60; +pub const DEFAULT_ORIGIN: &str = "https://zagan.2137697.xyz"; + +#[derive(Debug, Clone)] +pub struct Config { + /// Loopback address nginx proxies `/ws` to. + pub bind: String, + /// Exact browser origin allowed on the WebSocket handshake. + pub origin: String, + /// Game server address, `host:port`. + pub upstream: String, + /// Lowercase hex SHA-256 of the upstream certificate's SPKI. + pub fingerprint: String, + /// How long a session survives without a browser socket. + pub grace: Duration, + /// `Secure` on the session cookie. Off only for local http testing. + pub cookie_secure: bool, +} + +impl Config { + pub fn from_env() -> Result { + let bind = value("ZAGAN_WEB_BIND", DEFAULT_BIND); + let origin = value("ZAGAN_WEB_ORIGIN", DEFAULT_ORIGIN); + if !origin.starts_with("http://") && !origin.starts_with("https://") { + return Err("ZAGAN_WEB_ORIGIN must start with http:// or https://".to_string()); + } + let origin = origin.trim_end_matches('/').to_string(); + + let upstream = value("ZAGAN_UPSTREAM", DEFAULT_UPSTREAM); + let fingerprint = required("ZAGAN_UPSTREAM_FINGERPRINT")?; + let fingerprint = fingerprint.trim().to_ascii_lowercase(); + if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err( + "ZAGAN_UPSTREAM_FINGERPRINT must be the 64-character SHA-256 of the upstream SPKI" + .to_string(), + ); + } + + let grace_seconds = match env::var("ZAGAN_GRACE_SECONDS") { + Ok(raw) => raw + .trim() + .parse::() + .map_err(|_| "ZAGAN_GRACE_SECONDS must be a whole number of seconds".to_string())?, + Err(_) => DEFAULT_GRACE_SECONDS, + }; + + let cookie_secure = match env::var("ZAGAN_WEB_COOKIE_SECURE") { + Ok(raw) => matches!( + raw.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ), + Err(_) => origin.starts_with("https://"), + }; + + Ok(Self { + bind, + origin, + upstream, + fingerprint, + grace: Duration::from_secs(grace_seconds), + cookie_secure, + }) + } +} + +fn value(name: &str, default: &str) -> String { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| default.to_string()) +} + +fn required(name: &str) -> Result { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + format!( + "{name} is required: the gateway pins the upstream key. \ + Use the SPKI fingerprint the desktop client shows." + ) + }) +} -- cgit v1.2.3