summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-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
4 files changed, 175 insertions, 2 deletions
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}