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 --- src/bin/client/connection.rs | 24 ++++++++- src/lib.rs | 1 + src/server/core.rs | 35 +++++++++++++ src/version.rs | 117 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 src/version.rs (limited to 'src') 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()) +} -- cgit v1.2.3