summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 17:49:08 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 17:49:08 +0100
commit2fe2e8cdbc3bb520994ff0944188f2fa657c546a (patch)
tree10f29b4dd62c2b8127d20f0bffd4325b3ca0bfd2
parentdocs: add the phased GUI plan (diff)
downloadzagan-bet-2fe2e8cdbc3bb520994ff0944188f2fa657c546a.tar.gz
zagan-bet-2fe2e8cdbc3bb520994ff0944188f2fa657c546a.zip
v4.0.0: require HELLO MAJOR.MINOR lockstep before auth
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GAMES.md14
-rw-r--r--docs/TESTING.md12
-rw-r--r--src/bin/client/connection.rs24
-rw-r--r--src/lib.rs1
-rw-r--r--src/server/core.rs35
-rw-r--r--src/version.rs117
-rw-r--r--tests/server_core.rs100
-rw-r--r--tests/version.rs71
9 files changed, 373 insertions, 3 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 0a5ab12..7a16747 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "3.3.0" 3version = "4.0.0"
4edition = "2024" 4edition = "2024"
5license = "GPL-3.0-or-later" 5license = "GPL-3.0-or-later"
6 6
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.
182 182
183## Wire protocol 183## Wire protocol
184 184
185The first line after the TLS handshake is always the version handshake:
186
187```
188HELLO <version>
189HELLO <version> latest=<version> url=<download-url>
190```
191
192Client and server must share MAJOR.MINOR; only the patch component may differ.
193Until the handshake succeeds the server answers every other message with
194`ERROR HELLO required`, and the client sends no credentials. A mismatch answers
195`ERROR Version mismatch: ...` and the client disconnects. `latest` and `url`
196drive the "newer version available" notice. The server reads `DOWNLOAD_URL`
197from its environment, falling back to the public site.
198
185Slots keeps its original exchange: 199Slots keeps its original exchange:
186 200
187``` 201```
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.
180 one row with `winnings = 0`, `details.result = "abandoned"` and 180 one row with `winnings = 0`, `details.result = "abandoned"` and
181 `details.reason = "server_restart"`. 181 `details.reason = "server_restart"`.
182 182
183## Manual version handshake check
184
1851. Connect a client and server from the same release: the client connects and
186 the server log shows the `HELLO` exchange.
1872. Connect a 4.x client to a 3.x server (or the reverse): the client must print
188 `Version mismatch: client ..., server ...` and never reach the login prompt.
1893. With a deliberately skew-built server (PATCH differs) the connection is
190 accepted and, when the server's version is newer, the client prints
191 `A newer version is available`.
1924. Before the handshake, `openssl s_client -quiet -connect host:port` followed
193 by `PING` must answer `ERROR HELLO required`.
194
183## Manual identity / credential checklist 195## Manual identity / credential checklist
184 196
1851. Connect through one address, `register` and `login`. `hosts` shows the 1971. 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;
8use std::time::{Duration, Instant}; 8use std::time::{Duration, Instant};
9use zagan_bet::client::database; 9use zagan_bet::client::database;
10use zagan_bet::client::ui::first_contact_lines; 10use zagan_bet::client::ui::first_contact_lines;
11use zagan_bet::version::{is_newer, parse_hello, VERSION};
11 12
12static UNSAFE_MODE: Mutex<bool> = Mutex::new(false); 13static UNSAFE_MODE: Mutex<bool> = Mutex::new(false);
13 14
@@ -69,16 +70,35 @@ pub fn connect(
69 let mut tls = tls; 70 let mut tls = tls;
70 tls.set_read_timeout(Some(Duration::from_secs(10)))?; 71 tls.set_read_timeout(Some(Duration::from_secs(10)))?;
71 72
73 // Version lockstep: the first line after TLS is the handshake, and no
74 // credentials are sent until the server accepts it.
72 let start = Instant::now(); 75 let start = Instant::now();
73 tls.write_line("PING")?; 76 tls.write_line(&format!("HELLO {VERSION}"))?;
74 let mut response = String::new(); 77 let mut response = String::new();
75 tls.read_line(&mut response)?; 78 if tls.read_line(&mut response)? == 0 {
79 return Err("server closed the connection during HELLO".into());
80 }
76 let elapsed = start.elapsed(); 81 let elapsed = start.elapsed();
82 let response = response.trim();
83
84 if let Some(message) = response.strip_prefix("ERROR") {
85 return Err(message.trim().to_string().into());
86 }
87 let Some(hello) = parse_hello(response) else {
88 return Err(format!("unexpected HELLO reply: {response}").into());
89 };
90
77 println!( 91 println!(
78 "\n\rConnected to {} with ping delay: {:.1}ms (TLS)", 92 "\n\rConnected to {} with ping delay: {:.1}ms (TLS)",
79 addr, 93 addr,
80 elapsed.as_secs_f64() * 1000.0 94 elapsed.as_secs_f64() * 1000.0
81 ); 95 );
96 if is_newer(&hello.latest, VERSION) {
97 println!(
98 "A newer version is available: {} ({})",
99 hello.latest, hello.url
100 );
101 }
82 102
83 // Credentials are stored per server identity, so any address presenting 103 // Credentials are stored per server identity, so any address presenting
84 // this certificate key can log in with the same stored account. 104 // 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;
14pub mod server; 14pub mod server;
15pub mod slots; 15pub mod slots;
16pub mod tofu; 16pub mod tofu;
17pub 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;
6use crate::blackjack::BlackjackState; 6use crate::blackjack::BlackjackState;
7use crate::crash::CrashState; 7use crate::crash::CrashState;
8use crate::server::database; 8use crate::server::database;
9use crate::version::{download_url, hello_line, Version, VERSION};
9use crate::server::games::blackjack::{BlackjackGame, BlackjackHand}; 10use crate::server::games::blackjack::{BlackjackGame, BlackjackHand};
10use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock}; 11use crate::server::games::crash::{Clock, CrashGame, CrashRound, RealClock};
11use crate::server::games::registry::GameRegistry; 12use crate::server::games::registry::GameRegistry;
@@ -15,6 +16,7 @@ use crate::server::games::traits::{GameError, GamePhase, GameState};
15#[derive(Debug, Default)] 16#[derive(Debug, Default)]
16pub struct ConnectionState { 17pub struct ConnectionState {
17 user: Option<String>, 18 user: Option<String>,
19 greeted: bool,
18} 20}
19 21
20/// Settle reservations left behind by a previous process. 22/// Settle reservations left behind by a previous process.
@@ -32,6 +34,11 @@ impl ConnectionState {
32 pub fn user(&self) -> Option<&str> { 34 pub fn user(&self) -> Option<&str> {
33 self.user.as_deref() 35 self.user.as_deref()
34 } 36 }
37
38 /// True once the client has completed the version handshake.
39 pub fn greeted(&self) -> bool {
40 self.greeted
41 }
35} 42}
36 43
37pub struct ServerCore { 44pub struct ServerCore {
@@ -91,7 +98,14 @@ impl ServerCore {
91 98
92 pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String { 99 pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String {
93 let parts: Vec<&str> = msg.splitn(5, ' ').collect(); 100 let parts: Vec<&str> = msg.splitn(5, ' ').collect();
101
102 // Nothing but the handshake is accepted on a fresh connection.
103 if parts[0] != "HELLO" && !state.greeted {
104 return "ERROR HELLO required".to_string();
105 }
106
94 match parts[0] { 107 match parts[0] {
108 "HELLO" => self.handle_hello(state, &parts),
95 "GAME_LIST" => { 109 "GAME_LIST" => {
96 let mut games = self.registry.list_games(); 110 let mut games = self.registry.list_games();
97 games.sort(); 111 games.sort();
@@ -193,6 +207,27 @@ impl ServerCore {
193 } 207 }
194 } 208 }
195 209
210 fn handle_hello(&mut self, state: &mut ConnectionState, parts: &[&str]) -> String {
211 if parts.len() != 2 {
212 return "ERROR Invalid HELLO format. Usage: HELLO <version>".to_string();
213 }
214
215 let (Some(client), Some(server)) = (Version::parse(parts[1]), Version::parse(VERSION)) else {
216 return "ERROR Invalid version".to_string();
217 };
218
219 if !client.compatible_with(server) {
220 return format!(
221 "ERROR Version mismatch: client {}, server {VERSION}. Get {VERSION} at {}",
222 parts[1],
223 download_url()
224 );
225 }
226
227 state.greeted = true;
228 hello_line(VERSION, VERSION, &download_url())
229 }
230
196 fn handle_register(&mut self, username: &str, credential: &str) -> String { 231 fn handle_register(&mut self, username: &str, credential: &str) -> String {
197 if database::get_hashed_password(&self.conn, username).is_some() { 232 if database::get_hashed_password(&self.conn, username).is_some() {
198 return "ERROR User already exists".to_string(); 233 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 @@
1//! Version compatibility and the `HELLO` handshake.
2//!
3//! Client and server must share MAJOR.MINOR; only the patch component may
4//! differ. The first line after TLS is `HELLO <version>`, and nothing else is
5//! accepted until the server has answered it.
6
7use std::env;
8
9/// Version this build reports. The client and the server share it.
10pub const VERSION: &str = env!("CARGO_PKG_VERSION");
11
12/// Where players download new releases when the server advertises one and the
13/// operator has not set `DOWNLOAD_URL`.
14pub const DEFAULT_DOWNLOAD_URL: &str = "https://zagan.2137697.xyz/";
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
17pub struct Version {
18 pub major: u64,
19 pub minor: u64,
20 pub patch: u64,
21}
22
23impl Version {
24 /// Parse `MAJOR.MINOR.PATCH`, tolerating a leading `v` and a pre-release or
25 /// build suffix (`4.0.0-rc1`, `4.0.0+build`).
26 pub fn parse(raw: &str) -> Option<Self> {
27 let raw = raw.trim();
28 let raw = raw.strip_prefix('v').unwrap_or(raw);
29
30 // Drop a pre-release or build suffix, but not an empty one.
31 let (core, suffix) = match raw.find(['-', '+']) {
32 Some(index) => (&raw[..index], Some(&raw[index + 1..])),
33 None => (raw, None),
34 };
35 if suffix == Some("") {
36 return None;
37 }
38
39 let mut parts = core.split('.');
40 let major = parts.next()?.parse().ok()?;
41 let minor = parts.next()?.parse().ok()?;
42 let patch = parts.next()?.parse().ok()?;
43 if parts.next().is_some() {
44 return None;
45 }
46
47 Some(Self {
48 major,
49 minor,
50 patch,
51 })
52 }
53
54 pub fn compatible_with(self, other: Self) -> bool {
55 self.major == other.major && self.minor == other.minor
56 }
57}
58
59/// True when both versions parse and share MAJOR.MINOR.
60pub fn compatible(client: &str, server: &str) -> bool {
61 match (Version::parse(client), Version::parse(server)) {
62 (Some(client), Some(server)) => client.compatible_with(server),
63 _ => false,
64 }
65}
66
67/// True when `latest` is strictly newer than `current`. Unparseable versions
68/// are never "newer", so a broken reply cannot nag the player.
69pub fn is_newer(latest: &str, current: &str) -> bool {
70 match (Version::parse(latest), Version::parse(current)) {
71 (Some(latest), Some(current)) => latest > current,
72 _ => false,
73 }
74}
75
76/// The server line that accepts a handshake.
77pub fn hello_line(version: &str, latest: &str, url: &str) -> String {
78 format!("HELLO {version} latest={latest} url={url}")
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct HelloReply {
83 pub version: String,
84 pub latest: String,
85 pub url: String,
86}
87
88/// Parse `HELLO <version> latest=<version> url=<url>`.
89pub fn parse_hello(line: &str) -> Option<HelloReply> {
90 let rest = line.trim().strip_prefix("HELLO ")?;
91 let mut tokens = rest.split_whitespace();
92 let version = tokens.next()?.to_string();
93
94 let mut latest = None;
95 let mut url = None;
96 for token in tokens {
97 if let Some(value) = token.strip_prefix("latest=") {
98 latest = Some(value.to_string());
99 } else if let Some(value) = token.strip_prefix("url=") {
100 url = Some(value.to_string());
101 }
102 }
103
104 Some(HelloReply {
105 version,
106 latest: latest?,
107 url: url?,
108 })
109}
110
111/// Download page advertised by the server. `DOWNLOAD_URL` wins when set.
112pub fn download_url() -> String {
113 env::var("DOWNLOAD_URL")
114 .ok()
115 .filter(|value| !value.trim().is_empty())
116 .unwrap_or_else(|| DEFAULT_DOWNLOAD_URL.to_string())
117}
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 {
100 serde_json::from_str(raw).expect("valid crash state") 100 serde_json::from_str(raw).expect("valid crash state")
101} 101}
102 102
103fn hello(core: &mut ServerCore, state: &mut ConnectionState) {
104 let response = core.handle_message(
105 state,
106 &format!("HELLO {}", zagan_bet::version::VERSION),
107 );
108 assert!(response.starts_with("HELLO "), "handshake failed: {response}");
109}
110
103fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { 111fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
112 hello(core, state);
104 assert_eq!( 113 assert_eq!(
105 core.handle_message(state, &format!("REGISTER {user} credential")), 114 core.handle_message(state, &format!("REGISTER {user} credential")),
106 format!("REGISTERED {user}") 115 format!("REGISTERED {user}")
@@ -108,6 +117,7 @@ fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
108} 117}
109 118
110fn login(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { 119fn login(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
120 hello(core, state);
111 assert_eq!( 121 assert_eq!(
112 core.handle_message(state, &format!("AUTH {user} credential")), 122 core.handle_message(state, &format!("AUTH {user} credential")),
113 format!("OK {user}") 123 format!("OK {user}")
@@ -155,6 +165,7 @@ fn legacy_sha256_rows_are_upgraded_on_login() {
155 database::add_user(&conn, "legacy", "deadbeef").unwrap(); 165 database::add_user(&conn, "legacy", "deadbeef").unwrap();
156 166
157 let mut state = ConnectionState::default(); 167 let mut state = ConnectionState::default();
168 hello(&mut core, &mut state);
158 assert_eq!( 169 assert_eq!(
159 core.handle_message(&mut state, "AUTH legacy deadbeef"), 170 core.handle_message(&mut state, "AUTH legacy deadbeef"),
160 "OK legacy" 171 "OK legacy"
@@ -165,6 +176,7 @@ fn legacy_sha256_rows_are_upgraded_on_login() {
165 assert!(bcrypt::verify("deadbeef", &stored).unwrap()); 176 assert!(bcrypt::verify("deadbeef", &stored).unwrap());
166 177
167 let mut other = ConnectionState::default(); 178 let mut other = ConnectionState::default();
179 hello(&mut core, &mut other);
168 assert_eq!( 180 assert_eq!(
169 core.handle_message(&mut other, "AUTH legacy wrong"), 181 core.handle_message(&mut other, "AUTH legacy wrong"),
170 "ERROR Invalid credentials" 182 "ERROR Invalid credentials"
@@ -876,6 +888,7 @@ fn an_insufficient_bet_leaves_no_reservation() {
876fn history_requires_login_and_valid_counts() { 888fn history_requires_login_and_valid_counts() {
877 let (mut core, _path) = setup("history-guards", WIN3); 889 let (mut core, _path) = setup("history-guards", WIN3);
878 let mut state = ConnectionState::default(); 890 let mut state = ConnectionState::default();
891 hello(&mut core, &mut state);
879 892
880 assert_eq!( 893 assert_eq!(
881 core.handle_message(&mut state, "HISTORY"), 894 core.handle_message(&mut state, "HISTORY"),
@@ -928,3 +941,90 @@ fn history_lists_recent_plays_newest_first() {
928 // Newest first: the timestamps must not increase across the list. 941 // Newest first: the timestamps must not increase across the list.
929 assert!(records[0].at >= records[1].at); 942 assert!(records[0].at >= records[1].at);
930} 943}
944
945#[test]
946fn a_fresh_connection_must_greet_before_anything_else() {
947 let (mut core, _path) = setup("hello-gate", WIN3);
948 let mut state = ConnectionState::default();
949
950 assert_eq!(
951 core.handle_message(&mut state, "PING"),
952 "ERROR HELLO required"
953 );
954 assert_eq!(
955 core.handle_message(&mut state, "AUTH alice x"),
956 "ERROR HELLO required"
957 );
958 assert_eq!(
959 core.handle_message(&mut state, "PLAY_START slots 10"),
960 "ERROR HELLO required"
961 );
962 assert!(!state.greeted());
963}
964
965#[test]
966fn hello_reports_the_version_and_download_url() {
967 let (mut core, _path) = setup("hello-ok", WIN3);
968 let mut state = ConnectionState::default();
969
970 let response = core.handle_message(
971 &mut state,
972 &format!("HELLO {}", zagan_bet::version::VERSION),
973 );
974 let hello = zagan_bet::version::parse_hello(&response).expect("hello reply");
975
976 assert_eq!(hello.version, zagan_bet::version::VERSION);
977 assert_eq!(hello.latest, zagan_bet::version::VERSION);
978 assert!(hello.url.starts_with("https://"), "url: {}", hello.url);
979 assert!(state.greeted());
980
981 // The gate is open now.
982 assert_eq!(core.handle_message(&mut state, "PING"), "PONG");
983}
984
985#[test]
986fn hello_refuses_a_different_major_or_minor() {
987 let (mut core, _path) = setup("hello-mismatch", WIN3);
988 let server = zagan_bet::version::Version::parse(zagan_bet::version::VERSION).unwrap();
989 let versions = [
990 format!("{}.{}.{}", server.major, server.minor + 1, server.patch),
991 format!("{}.{}.{}", server.major + 1, server.minor, server.patch),
992 ];
993
994 for version in versions {
995 let mut state = ConnectionState::default();
996 let response = core.handle_message(&mut state, &format!("HELLO {version}"));
997
998 assert!(
999 response.starts_with("ERROR Version mismatch"),
1000 "response: {response}"
1001 );
1002 assert!(!state.greeted(), "a refused handshake must not open the gate");
1003 assert_eq!(
1004 core.handle_message(&mut state, "PING"),
1005 "ERROR HELLO required"
1006 );
1007 }
1008}
1009
1010#[test]
1011fn hello_accepts_a_patch_difference_and_rejects_bad_input() {
1012 let (mut core, _path) = setup("hello-patch", WIN3);
1013 let server = zagan_bet::version::Version::parse(zagan_bet::version::VERSION).unwrap();
1014 let patch = format!("{}.{}.{}", server.major, server.minor, server.patch + 1);
1015
1016 let mut state = ConnectionState::default();
1017 assert!(core
1018 .handle_message(&mut state, &format!("HELLO {patch}"))
1019 .starts_with("HELLO "));
1020
1021 let mut other = ConnectionState::default();
1022 assert_eq!(
1023 core.handle_message(&mut other, "HELLO"),
1024 "ERROR Invalid HELLO format. Usage: HELLO <version>"
1025 );
1026 assert_eq!(
1027 core.handle_message(&mut other, "HELLO nope"),
1028 "ERROR Invalid version"
1029 );
1030}
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 @@
1use zagan_bet::version::{
2 compatible, hello_line, is_newer, parse_hello, Version, VERSION,
3};
4
5#[test]
6fn versions_parse_and_reject_nonsense() {
7 assert_eq!(
8 Version::parse("4.0.0"),
9 Some(Version {
10 major: 4,
11 minor: 0,
12 patch: 0
13 })
14 );
15 assert_eq!(
16 Version::parse("v4.1.2"),
17 Some(Version {
18 major: 4,
19 minor: 1,
20 patch: 2
21 })
22 );
23 assert_eq!(
24 Version::parse("4.0.0-rc1").map(|v| (v.major, v.minor, v.patch)),
25 Some((4, 0, 0))
26 );
27 assert_eq!(
28 Version::parse("4.0.0+build7").map(|v| v.patch),
29 Some(0)
30 );
31
32 for bad in ["", "4", "4.0", "4.0.0.1", "x.0.0", "4.x.0", "4.0.0-"] {
33 assert!(Version::parse(bad).is_none(), "{bad:?} should not parse");
34 }
35
36 assert!(Version::parse(VERSION).is_some(), "the build version must parse");
37}
38
39#[test]
40fn compatibility_is_major_and_minor_only() {
41 assert!(compatible("4.0.0", "4.0.9"));
42 assert!(compatible("4.2.7", "4.2.1"));
43 assert!(!compatible("4.0.0", "4.1.0"));
44 assert!(!compatible("4.0.0", "5.0.0"));
45 assert!(!compatible("nope", "4.0.0"));
46}
47
48#[test]
49fn newer_versions_are_ordered_by_major_minor_patch() {
50 assert!(is_newer("4.0.1", "4.0.0"));
51 assert!(is_newer("4.1.0", "4.0.9"));
52 assert!(is_newer("5.0.0", "4.9.9"));
53 assert!(!is_newer("4.0.0", "4.0.0"));
54 assert!(!is_newer("4.0.0", "4.0.1"));
55 assert!(!is_newer("nonsense", "4.0.0"));
56}
57
58#[test]
59fn the_hello_reply_round_trips() {
60 let line = hello_line("4.0.0", "4.1.2", "https://zagan.example/");
61 let reply = parse_hello(&line).expect("parse a generated reply");
62
63 assert_eq!(reply.version, "4.0.0");
64 assert_eq!(reply.latest, "4.1.2");
65 assert_eq!(reply.url, "https://zagan.example/");
66
67 assert!(parse_hello("HELLO 4.0.0 url=https://x/").is_none(), "latest is required");
68 assert!(parse_hello("HELLO 4.0.0 latest=4.0.0").is_none(), "url is required");
69 assert!(parse_hello("PONG").is_none());
70 assert!(parse_hello("HELLO").is_none());
71}