From 2fe2e8cdbc3bb520994ff0944188f2fa657c546a Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 17:49:08 +0100 Subject: v4.0.0: require HELLO MAJOR.MINOR lockstep before auth --- Cargo.toml | 2 +- docs/GAMES.md | 14 ++++++ docs/TESTING.md | 12 +++++ src/bin/client/connection.rs | 24 ++++++++- src/lib.rs | 1 + src/server/core.rs | 35 +++++++++++++ src/version.rs | 117 +++++++++++++++++++++++++++++++++++++++++++ tests/server_core.rs | 100 ++++++++++++++++++++++++++++++++++++ tests/version.rs | 71 ++++++++++++++++++++++++++ 9 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 src/version.rs create mode 100644 tests/version.rs diff --git a/Cargo.toml b/Cargo.toml index 0a5ab12..7a16747 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "3.3.0" +version = "4.0.0" edition = "2024" license = "GPL-3.0-or-later" diff --git a/docs/GAMES.md b/docs/GAMES.md index bd372b1..6f6d197 100644 --- a/docs/GAMES.md +++ b/docs/GAMES.md @@ -182,6 +182,20 @@ database. ## Wire protocol +The first line after the TLS handshake is always the version handshake: + +``` +HELLO +HELLO latest= url= +``` + +Client and server must share MAJOR.MINOR; only the patch component may differ. +Until the handshake succeeds the server answers every other message with +`ERROR HELLO required`, and the client sends no credentials. A mismatch answers +`ERROR Version mismatch: ...` and the client disconnects. `latest` and `url` +drive the "newer version available" notice. The server reads `DOWNLOAD_URL` +from its environment, falling back to the public site. + Slots keeps its original exchange: ``` diff --git a/docs/TESTING.md b/docs/TESTING.md index 1a1d369..23e3a19 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -180,6 +180,18 @@ Use an 80-column terminal because several output lines are width-sensitive. one row with `winnings = 0`, `details.result = "abandoned"` and `details.reason = "server_restart"`. +## Manual version handshake check + +1. Connect a client and server from the same release: the client connects and + the server log shows the `HELLO` exchange. +2. Connect a 4.x client to a 3.x server (or the reverse): the client must print + `Version mismatch: client ..., server ...` and never reach the login prompt. +3. With a deliberately skew-built server (PATCH differs) the connection is + accepted and, when the server's version is newer, the client prints + `A newer version is available`. +4. Before the handshake, `openssl s_client -quiet -connect host:port` followed + by `PING` must answer `ERROR HELLO required`. + ## Manual identity / credential checklist 1. Connect through one address, `register` and `login`. `hosts` shows the diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs index 7516836..6bc9b09 100644 --- a/src/bin/client/connection.rs +++ b/src/bin/client/connection.rs @@ -8,6 +8,7 @@ use std::thread; use std::time::{Duration, Instant}; use zagan_bet::client::database; use zagan_bet::client::ui::first_contact_lines; +use zagan_bet::version::{is_newer, parse_hello, VERSION}; static UNSAFE_MODE: Mutex = Mutex::new(false); @@ -69,16 +70,35 @@ pub fn connect( let mut tls = tls; tls.set_read_timeout(Some(Duration::from_secs(10)))?; + // Version lockstep: the first line after TLS is the handshake, and no + // credentials are sent until the server accepts it. let start = Instant::now(); - tls.write_line("PING")?; + tls.write_line(&format!("HELLO {VERSION}"))?; let mut response = String::new(); - tls.read_line(&mut response)?; + if tls.read_line(&mut response)? == 0 { + return Err("server closed the connection during HELLO".into()); + } let elapsed = start.elapsed(); + let response = response.trim(); + + if let Some(message) = response.strip_prefix("ERROR") { + return Err(message.trim().to_string().into()); + } + let Some(hello) = parse_hello(response) else { + return Err(format!("unexpected HELLO reply: {response}").into()); + }; + println!( "\n\rConnected to {} with ping delay: {:.1}ms (TLS)", addr, elapsed.as_secs_f64() * 1000.0 ); + if is_newer(&hello.latest, VERSION) { + println!( + "A newer version is available: {} ({})", + hello.latest, hello.url + ); + } // Credentials are stored per server identity, so any address presenting // this certificate key can log in with the same stored account. diff --git a/src/lib.rs b/src/lib.rs index 51afafc..f1de5d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,3 +14,4 @@ pub mod roulette; pub mod server; pub mod slots; pub mod tofu; +pub mod version; diff --git a/src/server/core.rs b/src/server/core.rs index 89264f3..3cd6469 100644 --- a/src/server/core.rs +++ b/src/server/core.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use crate::blackjack::BlackjackState; use crate::crash::CrashState; use crate::server::database; +use crate::version::{download_url, hello_line, Version, VERSION}; use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock}; use crate::server::games::registry::GameRegistry; @@ -15,6 +16,7 @@ use crate::server::games::traits::{GameError, GamePhase, GameState}; #[derive(Debug, Default)] pub struct ConnectionState { user: Option, + greeted: bool, } /// Settle reservations left behind by a previous process. @@ -32,6 +34,11 @@ impl ConnectionState { pub fn user(&self) -> Option<&str> { self.user.as_deref() } + + /// True once the client has completed the version handshake. + pub fn greeted(&self) -> bool { + self.greeted + } } pub struct ServerCore { @@ -91,7 +98,14 @@ impl ServerCore { pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String { let parts: Vec<&str> = msg.splitn(5, ' ').collect(); + + // Nothing but the handshake is accepted on a fresh connection. + if parts[0] != "HELLO" && !state.greeted { + return "ERROR HELLO required".to_string(); + } + match parts[0] { + "HELLO" => self.handle_hello(state, &parts), "GAME_LIST" => { let mut games = self.registry.list_games(); games.sort(); @@ -193,6 +207,27 @@ impl ServerCore { } } + fn handle_hello(&mut self, state: &mut ConnectionState, parts: &[&str]) -> String { + if parts.len() != 2 { + return "ERROR Invalid HELLO format. Usage: HELLO ".to_string(); + } + + let (Some(client), Some(server)) = (Version::parse(parts[1]), Version::parse(VERSION)) else { + return "ERROR Invalid version".to_string(); + }; + + if !client.compatible_with(server) { + return format!( + "ERROR Version mismatch: client {}, server {VERSION}. Get {VERSION} at {}", + parts[1], + download_url() + ); + } + + state.greeted = true; + hello_line(VERSION, VERSION, &download_url()) + } + fn handle_register(&mut self, username: &str, credential: &str) -> String { if database::get_hashed_password(&self.conn, username).is_some() { return "ERROR User already exists".to_string(); diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..c1dcee5 --- /dev/null +++ b/src/version.rs @@ -0,0 +1,117 @@ +//! Version compatibility and the `HELLO` handshake. +//! +//! Client and server must share MAJOR.MINOR; only the patch component may +//! differ. The first line after TLS is `HELLO `, and nothing else is +//! accepted until the server has answered it. + +use std::env; + +/// Version this build reports. The client and the server share it. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Where players download new releases when the server advertises one and the +/// operator has not set `DOWNLOAD_URL`. +pub const DEFAULT_DOWNLOAD_URL: &str = "https://zagan.2137697.xyz/"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Version { + pub major: u64, + pub minor: u64, + pub patch: u64, +} + +impl Version { + /// Parse `MAJOR.MINOR.PATCH`, tolerating a leading `v` and a pre-release or + /// build suffix (`4.0.0-rc1`, `4.0.0+build`). + pub fn parse(raw: &str) -> Option { + let raw = raw.trim(); + let raw = raw.strip_prefix('v').unwrap_or(raw); + + // Drop a pre-release or build suffix, but not an empty one. + let (core, suffix) = match raw.find(['-', '+']) { + Some(index) => (&raw[..index], Some(&raw[index + 1..])), + None => (raw, None), + }; + if suffix == Some("") { + return None; + } + + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + + Some(Self { + major, + minor, + patch, + }) + } + + pub fn compatible_with(self, other: Self) -> bool { + self.major == other.major && self.minor == other.minor + } +} + +/// True when both versions parse and share MAJOR.MINOR. +pub fn compatible(client: &str, server: &str) -> bool { + match (Version::parse(client), Version::parse(server)) { + (Some(client), Some(server)) => client.compatible_with(server), + _ => false, + } +} + +/// True when `latest` is strictly newer than `current`. Unparseable versions +/// are never "newer", so a broken reply cannot nag the player. +pub fn is_newer(latest: &str, current: &str) -> bool { + match (Version::parse(latest), Version::parse(current)) { + (Some(latest), Some(current)) => latest > current, + _ => false, + } +} + +/// The server line that accepts a handshake. +pub fn hello_line(version: &str, latest: &str, url: &str) -> String { + format!("HELLO {version} latest={latest} url={url}") +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HelloReply { + pub version: String, + pub latest: String, + pub url: String, +} + +/// Parse `HELLO latest= url=`. +pub fn parse_hello(line: &str) -> Option { + let rest = line.trim().strip_prefix("HELLO ")?; + let mut tokens = rest.split_whitespace(); + let version = tokens.next()?.to_string(); + + let mut latest = None; + let mut url = None; + for token in tokens { + if let Some(value) = token.strip_prefix("latest=") { + latest = Some(value.to_string()); + } else if let Some(value) = token.strip_prefix("url=") { + url = Some(value.to_string()); + } + } + + Some(HelloReply { + version, + latest: latest?, + url: url?, + }) +} + +/// Download page advertised by the server. `DOWNLOAD_URL` wins when set. +pub fn download_url() -> String { + env::var("DOWNLOAD_URL") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_DOWNLOAD_URL.to_string()) +} diff --git a/tests/server_core.rs b/tests/server_core.rs index 6d6cffd..e2600e4 100644 --- a/tests/server_core.rs +++ b/tests/server_core.rs @@ -100,7 +100,16 @@ fn crash_state(response: &str) -> CrashState { serde_json::from_str(raw).expect("valid crash state") } +fn hello(core: &mut ServerCore, state: &mut ConnectionState) { + let response = core.handle_message( + state, + &format!("HELLO {}", zagan_bet::version::VERSION), + ); + assert!(response.starts_with("HELLO "), "handshake failed: {response}"); +} + fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { + hello(core, state); assert_eq!( core.handle_message(state, &format!("REGISTER {user} credential")), format!("REGISTERED {user}") @@ -108,6 +117,7 @@ fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { } fn login(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { + hello(core, state); assert_eq!( core.handle_message(state, &format!("AUTH {user} credential")), format!("OK {user}") @@ -155,6 +165,7 @@ fn legacy_sha256_rows_are_upgraded_on_login() { database::add_user(&conn, "legacy", "deadbeef").unwrap(); let mut state = ConnectionState::default(); + hello(&mut core, &mut state); assert_eq!( core.handle_message(&mut state, "AUTH legacy deadbeef"), "OK legacy" @@ -165,6 +176,7 @@ fn legacy_sha256_rows_are_upgraded_on_login() { assert!(bcrypt::verify("deadbeef", &stored).unwrap()); let mut other = ConnectionState::default(); + hello(&mut core, &mut other); assert_eq!( core.handle_message(&mut other, "AUTH legacy wrong"), "ERROR Invalid credentials" @@ -876,6 +888,7 @@ fn an_insufficient_bet_leaves_no_reservation() { fn history_requires_login_and_valid_counts() { let (mut core, _path) = setup("history-guards", WIN3); let mut state = ConnectionState::default(); + hello(&mut core, &mut state); assert_eq!( core.handle_message(&mut state, "HISTORY"), @@ -928,3 +941,90 @@ fn history_lists_recent_plays_newest_first() { // Newest first: the timestamps must not increase across the list. assert!(records[0].at >= records[1].at); } + +#[test] +fn a_fresh_connection_must_greet_before_anything_else() { + let (mut core, _path) = setup("hello-gate", WIN3); + let mut state = ConnectionState::default(); + + assert_eq!( + core.handle_message(&mut state, "PING"), + "ERROR HELLO required" + ); + assert_eq!( + core.handle_message(&mut state, "AUTH alice x"), + "ERROR HELLO required" + ); + assert_eq!( + core.handle_message(&mut state, "PLAY_START slots 10"), + "ERROR HELLO required" + ); + assert!(!state.greeted()); +} + +#[test] +fn hello_reports_the_version_and_download_url() { + let (mut core, _path) = setup("hello-ok", WIN3); + let mut state = ConnectionState::default(); + + let response = core.handle_message( + &mut state, + &format!("HELLO {}", zagan_bet::version::VERSION), + ); + let hello = zagan_bet::version::parse_hello(&response).expect("hello reply"); + + assert_eq!(hello.version, zagan_bet::version::VERSION); + assert_eq!(hello.latest, zagan_bet::version::VERSION); + assert!(hello.url.starts_with("https://"), "url: {}", hello.url); + assert!(state.greeted()); + + // The gate is open now. + assert_eq!(core.handle_message(&mut state, "PING"), "PONG"); +} + +#[test] +fn hello_refuses_a_different_major_or_minor() { + let (mut core, _path) = setup("hello-mismatch", WIN3); + let server = zagan_bet::version::Version::parse(zagan_bet::version::VERSION).unwrap(); + let versions = [ + format!("{}.{}.{}", server.major, server.minor + 1, server.patch), + format!("{}.{}.{}", server.major + 1, server.minor, server.patch), + ]; + + for version in versions { + let mut state = ConnectionState::default(); + let response = core.handle_message(&mut state, &format!("HELLO {version}")); + + assert!( + response.starts_with("ERROR Version mismatch"), + "response: {response}" + ); + assert!(!state.greeted(), "a refused handshake must not open the gate"); + assert_eq!( + core.handle_message(&mut state, "PING"), + "ERROR HELLO required" + ); + } +} + +#[test] +fn hello_accepts_a_patch_difference_and_rejects_bad_input() { + let (mut core, _path) = setup("hello-patch", WIN3); + let server = zagan_bet::version::Version::parse(zagan_bet::version::VERSION).unwrap(); + let patch = format!("{}.{}.{}", server.major, server.minor, server.patch + 1); + + let mut state = ConnectionState::default(); + assert!(core + .handle_message(&mut state, &format!("HELLO {patch}")) + .starts_with("HELLO ")); + + let mut other = ConnectionState::default(); + assert_eq!( + core.handle_message(&mut other, "HELLO"), + "ERROR Invalid HELLO format. Usage: HELLO " + ); + assert_eq!( + core.handle_message(&mut other, "HELLO nope"), + "ERROR Invalid version" + ); +} diff --git a/tests/version.rs b/tests/version.rs new file mode 100644 index 0000000..ef58601 --- /dev/null +++ b/tests/version.rs @@ -0,0 +1,71 @@ +use zagan_bet::version::{ + compatible, hello_line, is_newer, parse_hello, Version, VERSION, +}; + +#[test] +fn versions_parse_and_reject_nonsense() { + assert_eq!( + Version::parse("4.0.0"), + Some(Version { + major: 4, + minor: 0, + patch: 0 + }) + ); + assert_eq!( + Version::parse("v4.1.2"), + Some(Version { + major: 4, + minor: 1, + patch: 2 + }) + ); + assert_eq!( + Version::parse("4.0.0-rc1").map(|v| (v.major, v.minor, v.patch)), + Some((4, 0, 0)) + ); + assert_eq!( + Version::parse("4.0.0+build7").map(|v| v.patch), + Some(0) + ); + + for bad in ["", "4", "4.0", "4.0.0.1", "x.0.0", "4.x.0", "4.0.0-"] { + assert!(Version::parse(bad).is_none(), "{bad:?} should not parse"); + } + + assert!(Version::parse(VERSION).is_some(), "the build version must parse"); +} + +#[test] +fn compatibility_is_major_and_minor_only() { + assert!(compatible("4.0.0", "4.0.9")); + assert!(compatible("4.2.7", "4.2.1")); + assert!(!compatible("4.0.0", "4.1.0")); + assert!(!compatible("4.0.0", "5.0.0")); + assert!(!compatible("nope", "4.0.0")); +} + +#[test] +fn newer_versions_are_ordered_by_major_minor_patch() { + assert!(is_newer("4.0.1", "4.0.0")); + assert!(is_newer("4.1.0", "4.0.9")); + assert!(is_newer("5.0.0", "4.9.9")); + assert!(!is_newer("4.0.0", "4.0.0")); + assert!(!is_newer("4.0.0", "4.0.1")); + assert!(!is_newer("nonsense", "4.0.0")); +} + +#[test] +fn the_hello_reply_round_trips() { + let line = hello_line("4.0.0", "4.1.2", "https://zagan.example/"); + let reply = parse_hello(&line).expect("parse a generated reply"); + + assert_eq!(reply.version, "4.0.0"); + assert_eq!(reply.latest, "4.1.2"); + assert_eq!(reply.url, "https://zagan.example/"); + + assert!(parse_hello("HELLO 4.0.0 url=https://x/").is_none(), "latest is required"); + assert!(parse_hello("HELLO 4.0.0 latest=4.0.0").is_none(), "url is required"); + assert!(parse_hello("PONG").is_none()); + assert!(parse_hello("HELLO").is_none()); +} -- cgit v1.2.3