diff options
Diffstat (limited to 'webd')
| -rw-r--r-- | webd/Cargo.toml | 13 | ||||
| -rw-r--r-- | webd/README.md | 118 | ||||
| -rw-r--r-- | webd/src/config.rs | 95 | ||||
| -rw-r--r-- | webd/src/main.rs | 61 | ||||
| -rw-r--r-- | webd/src/session.rs | 623 |
5 files changed, 910 insertions, 0 deletions
diff --git a/webd/Cargo.toml b/webd/Cargo.toml new file mode 100644 index 0000000..8698404 --- /dev/null +++ b/webd/Cargo.toml | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | [package] | ||
| 2 | name = "zagan-bet-webd" | ||
| 3 | version = "5.0.0" | ||
| 4 | edition = "2024" | ||
| 5 | license = "GPL-3.0-or-later" | ||
| 6 | |||
| 7 | [dependencies] | ||
| 8 | zagan-bet = { path = "..", default-features = false } | ||
| 9 | tungstenite = "0.21" | ||
| 10 | serde = { version = "1.0", features = ["derive"] } | ||
| 11 | serde_json = "1.0" | ||
| 12 | rand = "0.8" | ||
| 13 | rusqlite = { version = "0.29", features = ["bundled"] } | ||
diff --git a/webd/README.md b/webd/README.md new file mode 100644 index 0000000..ac95fdb --- /dev/null +++ b/webd/README.md | |||
| @@ -0,0 +1,118 @@ | |||
| 1 | # `webd` — browser gateway | ||
| 2 | |||
| 3 | The browser cannot open the game's TCP/TLS stream, and the rules must not be | ||
| 4 | reimplemented in JavaScript. `webd` is a server-side frontend: one blocking | ||
| 5 | thread per browser session, one `ClientCore`, and one ordinary client TLS | ||
| 6 | connection to the game server. nginx terminates TLS and proxies `/ws` here. | ||
| 7 | |||
| 8 | It is a workspace member like `gui/`. Its dependencies never enter the TUI, | ||
| 9 | GUI or server build graph. | ||
| 10 | |||
| 11 | ## Build | ||
| 12 | |||
| 13 | ```sh | ||
| 14 | cargo build -p zagan-bet-webd --release | ||
| 15 | ``` | ||
| 16 | |||
| 17 | ## Configuration | ||
| 18 | |||
| 19 | | Variable | Default | Meaning | | ||
| 20 | | --- | --- | --- | | ||
| 21 | | `ZAGAN_WEB_BIND` | `127.0.0.1:8890` | Loopback address nginx proxies to | | ||
| 22 | | `ZAGAN_WEB_ORIGIN` | `https://zagan.2137697.xyz` | Exact `Origin` allowed | | ||
| 23 | | `ZAGAN_UPSTREAM` | `127.0.0.1:11525` | Game server `host:port` | | ||
| 24 | | `ZAGAN_UPSTREAM_FINGERPRINT` | **required** | SPKI SHA-256, 64 lowercase hex | | ||
| 25 | | `ZAGAN_GRACE_SECONDS` | `60` | Session survives this long without a socket | | ||
| 26 | | `ZAGAN_WEB_COOKIE_SECURE` | on for `https://` origin | `Secure` on the cookie | | ||
| 27 | |||
| 28 | The fingerprint is the same one the desktop client shows: | ||
| 29 | |||
| 30 | ```sh | ||
| 31 | openssl x509 -in cert.pem -pubkey -noout \ | ||
| 32 | | openssl pkey -pubin -outform DER \ | ||
| 33 | | sha256sum | ||
| 34 | ``` | ||
| 35 | |||
| 36 | There is no trust-on-first-use and no unsafe mode. A mismatch refuses to | ||
| 37 | connect and logs both fingerprints. Run the gateway on the same host as the | ||
| 38 | game server (or pin the remote key and accept the network hop). | ||
| 39 | |||
| 40 | ## systemd | ||
| 41 | |||
| 42 | Install `zagan-bet-webd` under `/usr/local/bin/`, then: | ||
| 43 | |||
| 44 | ```ini | ||
| 45 | # /etc/systemd/system/zagan-bet-webd.service | ||
| 46 | [Unit] | ||
| 47 | Description=zagan-bet browser gateway | ||
| 48 | After=network-online.target | ||
| 49 | Wants=network-online.target | ||
| 50 | |||
| 51 | [Service] | ||
| 52 | Type=simple | ||
| 53 | User=zagan | ||
| 54 | Group=zagan | ||
| 55 | EnvironmentFile=/etc/zagan-bet-webd.env | ||
| 56 | ExecStart=/usr/local/bin/zagan-bet-webd | ||
| 57 | Restart=on-failure | ||
| 58 | RestartSec=2 | ||
| 59 | NoNewPrivileges=true | ||
| 60 | PrivateTmp=true | ||
| 61 | ProtectSystem=strict | ||
| 62 | ProtectHome=true | ||
| 63 | |||
| 64 | [Install] | ||
| 65 | WantedBy=multi-user.target | ||
| 66 | ``` | ||
| 67 | |||
| 68 | ```sh | ||
| 69 | sudo install -m 0755 target/release/zagan-bet-webd /usr/local/bin/ | ||
| 70 | sudo install -d -m 0755 /etc | ||
| 71 | sudo tee /etc/zagan-bet-webd.env >/dev/null <<'ENV' | ||
| 72 | ZAGAN_UPSTREAM=127.0.0.1:11525 | ||
| 73 | ZAGAN_UPSTREAM_FINGERPRINT=<64 hex> | ||
| 74 | ZAGAN_WEB_ORIGIN=https://zagan.2137697.xyz | ||
| 75 | ZAGAN_WEB_BIND=127.0.0.1:8890 | ||
| 76 | ENV | ||
| 77 | sudo systemctl daemon-reload | ||
| 78 | sudo systemctl enable --now zagan-bet-webd | ||
| 79 | ``` | ||
| 80 | |||
| 81 | nginx needs: | ||
| 82 | |||
| 83 | ```nginx | ||
| 84 | location = /ws { | ||
| 85 | proxy_pass http://127.0.0.1:8890; | ||
| 86 | proxy_http_version 1.1; | ||
| 87 | proxy_set_header Upgrade $http_upgrade; | ||
| 88 | proxy_set_header Connection "upgrade"; | ||
| 89 | proxy_set_header Host $host; | ||
| 90 | proxy_read_timeout 3600s; | ||
| 91 | proxy_send_timeout 3600s; | ||
| 92 | } | ||
| 93 | ``` | ||
| 94 | |||
| 95 | ## Behaviour | ||
| 96 | |||
| 97 | - The WebSocket handshake sets an HttpOnly, SameSite=Strict session cookie. | ||
| 98 | There is no credential storage in the browser or gateway. | ||
| 99 | - A browser close starts the grace window. Upstream stays connected, crash | ||
| 100 | ticks are still applied to the core, and a reconnect with the same cookie | ||
| 101 | gets the current `View`. Expiry closes upstream; the server forfeits a live | ||
| 102 | hand, exactly like a desktop disconnect. | ||
| 103 | - A second socket with the same cookie takes over: the old tab receives | ||
| 104 | `{"type":"replaced"}` and is closed. | ||
| 105 | - A gateway restart loses live sessions, so their rounds are forfeited. There | ||
| 106 | is no protocol-level resume. | ||
| 107 | - The browser sends `{"type":"command","command":...}` and receives | ||
| 108 | `view`/`notice`/`error`/`replaced`/`hello`/`pong` JSON. It never sees the | ||
| 109 | game wire or a certificate. | ||
| 110 | |||
| 111 | ## Security notes | ||
| 112 | |||
| 113 | - Bind loopback. Do not expose the gateway directly. | ||
| 114 | - Keep `ZAGAN_WEB_ORIGIN` exact. The cookie is SameSite=Strict, and the | ||
| 115 | `Origin` check is a second lock on cross-site WebSocket attempts. | ||
| 116 | - The gateway is play-money only in v1. No payments, KYC or real-money copy. | ||
| 117 | - `ZAGAN_UPSTREAM_FINGERPRINT` is the browser's credential path: a wrong or | ||
| 118 | missing pin must fail closed. | ||
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 @@ | |||
| 1 | //! Gateway configuration from the environment. | ||
| 2 | //! | ||
| 3 | //! The upstream key is mandatory. There is no trust-on-first-use and no unsafe | ||
| 4 | //! mode: the browser's credentials ride on this connection. | ||
| 5 | |||
| 6 | use std::env; | ||
| 7 | use std::time::Duration; | ||
| 8 | |||
| 9 | pub const DEFAULT_BIND: &str = "127.0.0.1:8890"; | ||
| 10 | pub const DEFAULT_UPSTREAM: &str = "127.0.0.1:11525"; | ||
| 11 | pub const DEFAULT_GRACE_SECONDS: u64 = 60; | ||
| 12 | pub const DEFAULT_ORIGIN: &str = "https://zagan.2137697.xyz"; | ||
| 13 | |||
| 14 | #[derive(Debug, Clone)] | ||
| 15 | pub struct Config { | ||
| 16 | /// Loopback address nginx proxies `/ws` to. | ||
| 17 | pub bind: String, | ||
| 18 | /// Exact browser origin allowed on the WebSocket handshake. | ||
| 19 | pub origin: String, | ||
| 20 | /// Game server address, `host:port`. | ||
| 21 | pub upstream: String, | ||
| 22 | /// Lowercase hex SHA-256 of the upstream certificate's SPKI. | ||
| 23 | pub fingerprint: String, | ||
| 24 | /// How long a session survives without a browser socket. | ||
| 25 | pub grace: Duration, | ||
| 26 | /// `Secure` on the session cookie. Off only for local http testing. | ||
| 27 | pub cookie_secure: bool, | ||
| 28 | } | ||
| 29 | |||
| 30 | impl Config { | ||
| 31 | pub fn from_env() -> Result<Self, String> { | ||
| 32 | let bind = value("ZAGAN_WEB_BIND", DEFAULT_BIND); | ||
| 33 | let origin = value("ZAGAN_WEB_ORIGIN", DEFAULT_ORIGIN); | ||
| 34 | if !origin.starts_with("http://") && !origin.starts_with("https://") { | ||
| 35 | return Err("ZAGAN_WEB_ORIGIN must start with http:// or https://".to_string()); | ||
| 36 | } | ||
| 37 | let origin = origin.trim_end_matches('/').to_string(); | ||
| 38 | |||
| 39 | let upstream = value("ZAGAN_UPSTREAM", DEFAULT_UPSTREAM); | ||
| 40 | let fingerprint = required("ZAGAN_UPSTREAM_FINGERPRINT")?; | ||
| 41 | let fingerprint = fingerprint.trim().to_ascii_lowercase(); | ||
| 42 | if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { | ||
| 43 | return Err( | ||
| 44 | "ZAGAN_UPSTREAM_FINGERPRINT must be the 64-character SHA-256 of the upstream SPKI" | ||
| 45 | .to_string(), | ||
| 46 | ); | ||
| 47 | } | ||
| 48 | |||
| 49 | let grace_seconds = match env::var("ZAGAN_GRACE_SECONDS") { | ||
| 50 | Ok(raw) => raw | ||
| 51 | .trim() | ||
| 52 | .parse::<u64>() | ||
| 53 | .map_err(|_| "ZAGAN_GRACE_SECONDS must be a whole number of seconds".to_string())?, | ||
| 54 | Err(_) => DEFAULT_GRACE_SECONDS, | ||
| 55 | }; | ||
| 56 | |||
| 57 | let cookie_secure = match env::var("ZAGAN_WEB_COOKIE_SECURE") { | ||
| 58 | Ok(raw) => matches!( | ||
| 59 | raw.trim().to_ascii_lowercase().as_str(), | ||
| 60 | "1" | "true" | "yes" | ||
| 61 | ), | ||
| 62 | Err(_) => origin.starts_with("https://"), | ||
| 63 | }; | ||
| 64 | |||
| 65 | Ok(Self { | ||
| 66 | bind, | ||
| 67 | origin, | ||
| 68 | upstream, | ||
| 69 | fingerprint, | ||
| 70 | grace: Duration::from_secs(grace_seconds), | ||
| 71 | cookie_secure, | ||
| 72 | }) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | fn value(name: &str, default: &str) -> String { | ||
| 77 | env::var(name) | ||
| 78 | .ok() | ||
| 79 | .map(|value| value.trim().to_string()) | ||
| 80 | .filter(|value| !value.is_empty()) | ||
| 81 | .unwrap_or_else(|| default.to_string()) | ||
| 82 | } | ||
| 83 | |||
| 84 | fn required(name: &str) -> Result<String, String> { | ||
| 85 | env::var(name) | ||
| 86 | .ok() | ||
| 87 | .map(|value| value.trim().to_string()) | ||
| 88 | .filter(|value| !value.is_empty()) | ||
| 89 | .ok_or_else(|| { | ||
| 90 | format!( | ||
| 91 | "{name} is required: the gateway pins the upstream key. \ | ||
| 92 | Use the SPKI fingerprint the desktop client shows." | ||
| 93 | ) | ||
| 94 | }) | ||
| 95 | } | ||
diff --git a/webd/src/main.rs b/webd/src/main.rs new file mode 100644 index 0000000..c2cf0ff --- /dev/null +++ b/webd/src/main.rs | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | //! Browser gateway for zagan-bet. | ||
| 2 | //! | ||
| 3 | //! nginx terminates TLS, serves the static app and proxies `/ws` here. This | ||
| 4 | //! process owns one upstream TLS connection and one `ClientCore` per browser | ||
| 5 | //! session, so the browser never speaks the game wire protocol and never sees | ||
| 6 | //! a certificate. | ||
| 7 | |||
| 8 | mod config; | ||
| 9 | mod session; | ||
| 10 | |||
| 11 | use std::net::TcpListener; | ||
| 12 | use std::sync::Arc; | ||
| 13 | use std::thread; | ||
| 14 | |||
| 15 | use config::Config; | ||
| 16 | |||
| 17 | fn main() { | ||
| 18 | let config = match Config::from_env() { | ||
| 19 | Ok(config) => config, | ||
| 20 | Err(error) => { | ||
| 21 | eprintln!("webd: {error}"); | ||
| 22 | std::process::exit(2); | ||
| 23 | } | ||
| 24 | }; | ||
| 25 | |||
| 26 | let listener = match TcpListener::bind(&config.bind) { | ||
| 27 | Ok(listener) => listener, | ||
| 28 | Err(error) => { | ||
| 29 | eprintln!("webd: could not bind {}: {error}", config.bind); | ||
| 30 | std::process::exit(1); | ||
| 31 | } | ||
| 32 | }; | ||
| 33 | |||
| 34 | println!( | ||
| 35 | "webd listening on ws://{} for origin {}; upstream {} pinned to {}; grace {}s", | ||
| 36 | config.bind, | ||
| 37 | config.origin, | ||
| 38 | config.upstream, | ||
| 39 | config.fingerprint, | ||
| 40 | config.grace.as_secs() | ||
| 41 | ); | ||
| 42 | |||
| 43 | let registry = session::new_registry(); | ||
| 44 | for stream in listener.incoming() { | ||
| 45 | let stream = match stream { | ||
| 46 | Ok(stream) => stream, | ||
| 47 | Err(error) => { | ||
| 48 | eprintln!("webd: accept failed: {error}"); | ||
| 49 | continue; | ||
| 50 | } | ||
| 51 | }; | ||
| 52 | let _ = stream.set_nodelay(true); | ||
| 53 | let config = config.clone(); | ||
| 54 | let registry = Arc::clone(®istry); | ||
| 55 | thread::spawn(move || { | ||
| 56 | if let Err(error) = session::accept(stream, config, registry) { | ||
| 57 | eprintln!("webd: {error}"); | ||
| 58 | } | ||
| 59 | }); | ||
| 60 | } | ||
| 61 | } | ||
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 | |||
| 7 | use std::collections::HashMap; | ||
| 8 | use std::net::TcpStream; | ||
| 9 | use std::sync::atomic::{AtomicU64, Ordering}; | ||
| 10 | use std::sync::{Arc, Mutex, mpsc}; | ||
| 11 | use std::thread; | ||
| 12 | use std::time::{Duration, Instant}; | ||
| 13 | |||
| 14 | use rusqlite::Connection; | ||
| 15 | use serde::{Deserialize, Serialize}; | ||
| 16 | use tungstenite::error::Error as WsError; | ||
| 17 | use tungstenite::http::HeaderValue; | ||
| 18 | use tungstenite::http::header::ORIGIN; | ||
| 19 | use tungstenite::protocol::{WebSocket, WebSocketConfig}; | ||
| 20 | use tungstenite::{Message, accept_hdr_with_config}; | ||
| 21 | |||
| 22 | use zagan_bet::client::core::{ClientCore, Effect}; | ||
| 23 | use zagan_bet::client::database; | ||
| 24 | use zagan_bet::client::transport::{self, TlsStream}; | ||
| 25 | use zagan_bet::client::view::{Command, View}; | ||
| 26 | use zagan_bet::protocol::Packet; | ||
| 27 | use zagan_bet::version::VERSION; | ||
| 28 | |||
| 29 | use crate::config::Config; | ||
| 30 | |||
| 31 | const 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. | ||
| 34 | const HOST_COOKIE_NAME: &str = "__Host-zagan_session"; | ||
| 35 | const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); | ||
| 36 | const POLL: Duration = Duration::from_millis(10); | ||
| 37 | |||
| 38 | pub 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. | ||
| 46 | pub type Registry = Arc<Mutex<HashMap<String, (u64, mpsc::Sender<SocketEvent>)>>>; | ||
| 47 | |||
| 48 | static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1); | ||
| 49 | |||
| 50 | pub 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. | ||
| 56 | pub 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 | |||
| 154 | fn 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 | |||
| 168 | fn 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")] | ||
| 177 | enum ClientMessage { | ||
| 178 | Command { command: Command }, | ||
| 179 | Ping, | ||
| 180 | } | ||
| 181 | |||
| 182 | #[derive(Serialize)] | ||
| 183 | #[serde(tag = "type", rename_all = "snake_case")] | ||
| 184 | enum 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 | |||
| 206 | struct 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 | |||
| 221 | impl 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 | |||
| 546 | fn 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 | |||
| 555 | fn 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 | |||
| 562 | fn 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)] | ||
| 569 | mod 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 | } | ||
