summaryrefslogtreecommitdiffstats
path: root/webd
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--webd/Cargo.toml13
-rw-r--r--webd/README.md118
-rw-r--r--webd/src/config.rs95
-rw-r--r--webd/src/main.rs61
-rw-r--r--webd/src/session.rs623
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]
2name = "zagan-bet-webd"
3version = "5.0.0"
4edition = "2024"
5license = "GPL-3.0-or-later"
6
7[dependencies]
8zagan-bet = { path = "..", default-features = false }
9tungstenite = "0.21"
10serde = { version = "1.0", features = ["derive"] }
11serde_json = "1.0"
12rand = "0.8"
13rusqlite = { 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
3The browser cannot open the game's TCP/TLS stream, and the rules must not be
4reimplemented in JavaScript. `webd` is a server-side frontend: one blocking
5thread per browser session, one `ClientCore`, and one ordinary client TLS
6connection to the game server. nginx terminates TLS and proxies `/ws` here.
7
8It is a workspace member like `gui/`. Its dependencies never enter the TUI,
9GUI or server build graph.
10
11## Build
12
13```sh
14cargo 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
28The fingerprint is the same one the desktop client shows:
29
30```sh
31openssl x509 -in cert.pem -pubkey -noout \
32 | openssl pkey -pubin -outform DER \
33 | sha256sum
34```
35
36There is no trust-on-first-use and no unsafe mode. A mismatch refuses to
37connect and logs both fingerprints. Run the gateway on the same host as the
38game server (or pin the remote key and accept the network hop).
39
40## systemd
41
42Install `zagan-bet-webd` under `/usr/local/bin/`, then:
43
44```ini
45# /etc/systemd/system/zagan-bet-webd.service
46[Unit]
47Description=zagan-bet browser gateway
48After=network-online.target
49Wants=network-online.target
50
51[Service]
52Type=simple
53User=zagan
54Group=zagan
55EnvironmentFile=/etc/zagan-bet-webd.env
56ExecStart=/usr/local/bin/zagan-bet-webd
57Restart=on-failure
58RestartSec=2
59NoNewPrivileges=true
60PrivateTmp=true
61ProtectSystem=strict
62ProtectHome=true
63
64[Install]
65WantedBy=multi-user.target
66```
67
68```sh
69sudo install -m 0755 target/release/zagan-bet-webd /usr/local/bin/
70sudo install -d -m 0755 /etc
71sudo tee /etc/zagan-bet-webd.env >/dev/null <<'ENV'
72ZAGAN_UPSTREAM=127.0.0.1:11525
73ZAGAN_UPSTREAM_FINGERPRINT=<64 hex>
74ZAGAN_WEB_ORIGIN=https://zagan.2137697.xyz
75ZAGAN_WEB_BIND=127.0.0.1:8890
76ENV
77sudo systemctl daemon-reload
78sudo systemctl enable --now zagan-bet-webd
79```
80
81nginx needs:
82
83```nginx
84location = /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
6use std::env;
7use std::time::Duration;
8
9pub const DEFAULT_BIND: &str = "127.0.0.1:8890";
10pub const DEFAULT_UPSTREAM: &str = "127.0.0.1:11525";
11pub const DEFAULT_GRACE_SECONDS: u64 = 60;
12pub const DEFAULT_ORIGIN: &str = "https://zagan.2137697.xyz";
13
14#[derive(Debug, Clone)]
15pub 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
30impl 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
76fn 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
84fn 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
8mod config;
9mod session;
10
11use std::net::TcpListener;
12use std::sync::Arc;
13use std::thread;
14
15use config::Config;
16
17fn 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(&registry);
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
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}