summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-28 22:55:51 +0100
committersalmonik <salmonik@tuta.io>2026-10-28 22:55:51 +0100
commit87440d5a23f0fd894d949295a7c1ff85043550fc (patch)
tree53b2bada6872b6e565bb79982213616a50419b1c
parentv0.3.1: clean up dependencies, stale tests and document verification (diff)
downloadzagan-bet-87440d5a23f0fd894d949295a7c1ff85043550fc.tar.gz
zagan-bet-87440d5a23f0fd894d949295a7c1ff85043550fc.zip
v0.4.0: add first-time TLS setup and server help
-rw-r--r--.gitignore5
-rw-r--r--Cargo.toml2
-rw-r--r--docs/SERVER_SETUP.md100
-rw-r--r--docs/TESTING.md8
-rw-r--r--src/bin/server/args.rs85
-rw-r--r--src/bin/server/main.rs21
-rw-r--r--src/bin/server/tls.rs34
-rw-r--r--src/server/mod.rs1
-rw-r--r--src/server/setup.rs140
-rw-r--r--tests/server_setup.rs38
10 files changed, 388 insertions, 46 deletions
diff --git a/.gitignore b/.gitignore
index 3f32615..a140ec2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,7 @@
1/target 1/target
2*db 2*db
3*.lock 3*.lock
4/text \ No newline at end of file 4*.p12
5*.pem
6*.key
7/text
diff --git a/Cargo.toml b/Cargo.toml
index 5e50626..0ab5ada 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "0.3.1" 3version = "0.4.0"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[dependencies]
diff --git a/docs/SERVER_SETUP.md b/docs/SERVER_SETUP.md
new file mode 100644
index 0000000..e2f3177
--- /dev/null
+++ b/docs/SERVER_SETUP.md
@@ -0,0 +1,100 @@
1# Server setup
2
3The server speaks TLS and needs a PKCS#12 identity (`identity.p12`) plus the
4PEM key/certificate used to build it. The files are read from the current
5working directory.
6
7## Quick start
8
9From the project root:
10
11```sh
12cargo run --bin server -- --init
13cargo run --bin server
14```
15
16`--init` creates a self-signed development identity and exits. The next
17command starts the server on `127.0.0.1:11525`.
18
19If `identity.p12` is missing at startup, the server now prints these same
20instructions instead of only the raw OS error.
21
22## What `--init` creates
23
24| File | Purpose |
25| --- | --- |
26| `identity.p12` | PKCS#12 identity loaded by `native-tls`. Empty password. Uses PBE-SHA1-3DES and a SHA-1 MAC so the Windows SChannel backend can load it. |
27| `key.pem` | RSA private key used to build the identity. Not read at runtime. |
28| `cert.pem` | Public certificate, useful for inspection. |
29
30On Unix, `identity.p12` and `key.pem` are set to mode `0600`. All three are
31ignored by git (`*.p12`, `*.pem`, `*.key`).
32
33## Manual setup
34
35If `openssl` is not installed or you want to control the certificate:
36
37```sh
38openssl req -x509 -newkey rsa:2048 \
39 -keyout key.pem -out cert.pem \
40 -days 3650 -nodes -subj "/CN=localhost"
41
42openssl pkcs12 -export -out identity.p12 \
43 -inkey key.pem -in cert.pem -passout pass:
44```
45
46The password must be empty because the server calls
47`Identity::from_pkcs12(&data, "")`.
48
49## Running
50
51```sh
52# default: 127.0.0.1:11525
53cargo run --bin server
54
55# listen on all interfaces
56cargo run --bin server -- -i 0.0.0.0 -p 11525
57
58# show options
59cargo run --bin server -- --help
60```
61
62Then start the client in another terminal:
63
64```sh
65cargo run --bin client
66```
67
68On first contact the client prints the SPKI fingerprint and pins it. If you
69regenerate the server identity, the client will reject the new key; run
70`forget-host` in the client if the change is intentional.
71
72## Windows
73
74`--init` shells out to `openssl`, so OpenSSL must be installed and on `PATH`
75(Git for Windows, WSL, or a standalone OpenSSL build). The generated files work
76the same way. `--init` uses SChannel-friendly PKCS#12 algorithms; if you build
77the identity by hand on Windows, use:
78
79```sh
80openssl pkcs12 -export -out identity.p12 -inkey key.pem -in cert.pem \
81 -passout pass: -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1
82```
83
84## Security notes
85
86- This is a self-signed development identity with an empty PKCS#12 password.
87 Treat `key.pem` and `identity.p12` as secrets and do not commit them.
88- The trust model is TOFU: clients pin the SPKI fingerprint on first contact.
89 A changed key is a loud error, not an automatic re-trust.
90- For a public deployment, use a CA-issued certificate and protect the
91 PKCS#12 with a password; the current server code expects an empty password.
92
93## Troubleshooting
94
95| Symptom | Fix |
96| --- | --- |
97| `Failed to initialize TLS: Could not read TLS identity` | Run `cargo run --bin server -- --init`. |
98| `Address already in use` | Stop the other server or pass `-p <port>`. |
99| Client says `HOST KEY CHANGED` | The server certificate changed. If intentional, run `forget-host` in the client. |
100| `openssl` not found | Install OpenSSL or use the manual commands on a machine that has it. |
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 6cac6fa..55f5422 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -15,19 +15,21 @@ shared global database. Each test owns its state.
15| `tests/client_ui.rs` | Help, history, completion, bet parsing, display width, cursor math. | 15| `tests/client_ui.rs` | Help, history, completion, bet parsing, display width, cursor math. |
16| `tests/protocol.rs` | Newline buffering and timeout/error classification. | 16| `tests/protocol.rs` | Newline buffering and timeout/error classification. |
17| `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. | 17| `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. |
18| `tests/server_setup.rs` | `--init` identity generation, PKCS#12 loadability, overwrite refusal. |
18| `tests/slots.rs` | Server slots payout rules and overflow handling. | 19| `tests/slots.rs` | Server slots payout rules and overflow handling. |
19| `tests/storage.rs` | Client/server SQLite storage helpers. | 20| `tests/storage.rs` | Client/server SQLite storage helpers. |
20| `tests/tofu.rs` | SPKI fingerprint and TOFU decisions. | 21| `tests/tofu.rs` | SPKI fingerprint and TOFU decisions. |
21 22
22`tests/tofu.rs` shells out to `openssl`, so run it on a machine where the 23`tests/tofu.rs` and `tests/server_setup.rs` shell out to `openssl`, so run them
23`openssl` CLI is available. 24on a machine where the `openssl` CLI is available.
24 25
25## Manual TUI checklist 26## Manual TUI checklist
26 27
27There is no CI. Run this on Linux and on a Windows VM before a release. 28There is no CI. Run this on Linux and on a Windows VM before a release.
28Use an 80-column terminal because several output lines are width-sensitive. 29Use an 80-column terminal because several output lines are width-sensitive.
29 30
301. Start the server with a valid `identity.p12`, then start the client. 311. Run `cargo run --bin server -- --init` once, then start the server with a
32 valid `identity.p12` and start the client.
312. First contact prints three lines and the 64-character fingerprint does not 332. First contact prints three lines and the 64-character fingerprint does not
32 wrap at 80 columns. `Connected to ...` shows a one-decimal ping. 34 wrap at 80 columns. `Connected to ...` shows a one-decimal ping.
333. `register` / `login`: backspace removes one `*` per key, Esc cancels the 353. `register` / `login`: backspace removes one `*` per key, Esc cancels the
diff --git a/src/bin/server/args.rs b/src/bin/server/args.rs
index fc844ea..1babd1d 100644
--- a/src/bin/server/args.rs
+++ b/src/bin/server/args.rs
@@ -2,6 +2,7 @@ pub struct Args {
2 pub ip: String, 2 pub ip: String,
3 pub port: u16, 3 pub port: u16,
4 pub unsafe_mode: bool, 4 pub unsafe_mode: bool,
5 pub init: bool,
5} 6}
6 7
7pub fn parse_args() -> Args { 8pub fn parse_args() -> Args {
@@ -9,42 +10,80 @@ pub fn parse_args() -> Args {
9 let mut ip = "127.0.0.1".to_string(); 10 let mut ip = "127.0.0.1".to_string();
10 let mut port = 11525; 11 let mut port = 11525;
11 let mut unsafe_mode = false; 12 let mut unsafe_mode = false;
13 let mut init = false;
12 let mut i = 1; 14 let mut i = 1;
15
13 while i < args.len() { 16 while i < args.len() {
14 if args[i] == "-p" { 17 match args[i].as_str() {
15 if i + 1 < args.len() { 18 "-p" => {
16 port = args[i + 1].parse::<u16>().unwrap_or_else(|_| { 19 if i + 1 < args.len() {
17 eprintln!("Invalid port number: {}", args[i + 1]); 20 port = args[i + 1].parse::<u16>().unwrap_or_else(|_| {
21 eprintln!("Invalid port number: {}", args[i + 1]);
22 std::process::exit(1);
23 });
24 i += 2;
25 } else {
26 eprintln!("-p requires a port number");
18 std::process::exit(1); 27 std::process::exit(1);
19 }); 28 }
20 i += 2;
21 } else {
22 eprintln!("-p requires a port number");
23 std::process::exit(1);
24 } 29 }
25 } else if args[i] == "-i" { 30 "-i" => {
26 if i + 1 < args.len() { 31 if i + 1 < args.len() {
27 ip = args[i + 1].clone(); 32 ip = args[i + 1].clone();
28 if ip != "127.0.0.1" && ip != "0.0.0.0" { 33 if ip != "127.0.0.1" && ip != "0.0.0.0" {
29 eprintln!("Invalid IP address: {}. Must be 127.0.0.1 or 0.0.0.0", ip); 34 eprintln!(
35 "Invalid IP address: {}. Must be 127.0.0.1 or 0.0.0.0",
36 ip
37 );
38 std::process::exit(1);
39 }
40 i += 2;
41 } else {
42 eprintln!("-i requires an IP address");
30 std::process::exit(1); 43 std::process::exit(1);
31 } 44 }
32 i += 2; 45 }
33 } else { 46 "--unsafe" => {
34 eprintln!("-i requires an IP address"); 47 unsafe_mode = true;
48 i += 1;
49 }
50 "--init" => {
51 init = true;
52 i += 1;
53 }
54 "-h" | "--help" => {
55 print_usage();
56 std::process::exit(0);
57 }
58 other => {
59 eprintln!("Unknown argument: {other}");
60 print_usage();
35 std::process::exit(1); 61 std::process::exit(1);
36 } 62 }
37 } else if args[i] == "--unsafe" {
38 unsafe_mode = true;
39 i += 1;
40 } else {
41 eprintln!("Unknown argument: {}", args[i]);
42 std::process::exit(1);
43 } 63 }
44 } 64 }
65
45 Args { 66 Args {
46 ip, 67 ip,
47 port, 68 port,
48 unsafe_mode, 69 unsafe_mode,
70 init,
49 } 71 }
50} 72}
73
74fn print_usage() {
75 println!(
76 "Usage: server [OPTIONS]\n\
77 \n\
78 Options:\n\
79 \x20 -i <ip> Bind address (127.0.0.1 or 0.0.0.0)\n\
80 \x20 -p <port> Bind port (default 11525)\n\
81 \x20 --init Generate identity.p12, key.pem and cert.pem, then exit\n\
82 \x20 --unsafe Run in unsafe mode (warning only)\n\
83 \x20 -h, --help Show this help\n\
84 \n\
85 First run:\n\
86 \x20 cargo run --bin server -- --init\n\
87 \x20 cargo run --bin server"
88 );
89}
diff --git a/src/bin/server/main.rs b/src/bin/server/main.rs
index 8dba415..2770387 100644
--- a/src/bin/server/main.rs
+++ b/src/bin/server/main.rs
@@ -8,6 +8,25 @@ use std::thread;
8 8
9fn main() { 9fn main() {
10 let args = args::parse_args(); 10 let args = args::parse_args();
11
12 if args.init {
13 match zagan_bet::server::setup::init_identity(std::path::Path::new("."), "localhost") {
14 Ok(report) => {
15 println!("TLS identity created:");
16 println!(" {} (PKCS#12, empty password)", report.identity.display());
17 println!(" {} (private key)", report.key.display());
18 println!(" {} (certificate)", report.cert.display());
19 println!("\nStart the server with:\n cargo run --bin server");
20 println!("Clients will trust this certificate on first contact.");
21 }
22 Err(error) => {
23 eprintln!("TLS setup failed:\n{error}");
24 std::process::exit(1);
25 }
26 }
27 return;
28 }
29
11 let listener = match TcpListener::bind(format!("{}:{}", args.ip, args.port)) { 30 let listener = match TcpListener::bind(format!("{}:{}", args.ip, args.port)) {
12 Ok(l) => l, 31 Ok(l) => l,
13 Err(e) => { 32 Err(e) => {
@@ -16,7 +35,7 @@ fn main() {
16 } 35 }
17 }; 36 };
18 37
19 let tls = match tls::TlsServer::new("identity.p12", "key.pem", args.unsafe_mode) { 38 let tls = match tls::TlsServer::new("identity.p12") {
20 Ok(t) => Arc::new(t), 39 Ok(t) => Arc::new(t),
21 Err(e) => { 40 Err(e) => {
22 eprintln!("Failed to initialize TLS: {}", e); 41 eprintln!("Failed to initialize TLS: {}", e);
diff --git a/src/bin/server/tls.rs b/src/bin/server/tls.rs
index 62a25db..292ebf7 100644
--- a/src/bin/server/tls.rs
+++ b/src/bin/server/tls.rs
@@ -9,25 +9,25 @@ pub struct TlsServer {
9} 9}
10 10
11impl TlsServer { 11impl TlsServer {
12 pub fn new( 12 pub fn new(cert_path: &str) -> Result<Self, Box<dyn std::error::Error>> {
13 cert_path: &str, 13 let cert_data = match std::fs::read(cert_path) {
14 _key_path: &str, 14 Ok(data) => data,
15 unsafe_mode: bool, 15 Err(error) => {
16 ) -> Result<Self, Box<dyn std::error::Error>> {
17 let cert_data = if unsafe_mode {
18 if std::path::Path::new(cert_path).exists() {
19 std::fs::read(cert_path)?
20 } else {
21 return Err(format!( 16 return Err(format!(
22 "Certificate file '{}' not found. In --unsafe mode, you must provide a valid PKCS12 certificate file.\n\ 17 "Could not read TLS identity '{}': {}\n\
23 You can generate one with:\n\ 18 \n\
24 openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes\n\ 19 First-time setup:\n\
25 openssl pkcs12 -export -out identity.p12 -inkey key.pem -in cert.pem", 20 \x20 cargo run --bin server -- --init\n\
26 cert_path 21 \n\
27 ).into()); 22 Or generate it manually:\n\
23 \x20 openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \\\n\
24 \x20 -days 3650 -nodes -subj \"/CN=localhost\"\n\
25 \x20 openssl pkcs12 -export -out identity.p12 -inkey key.pem -in cert.pem \\\n\
26 \x20 -passout pass:",
27 cert_path, error
28 )
29 .into());
28 } 30 }
29 } else {
30 std::fs::read(cert_path)?
31 }; 31 };
32 32
33 let identity = native_tls::Identity::from_pkcs12(&cert_data, "")?; 33 let identity = native_tls::Identity::from_pkcs12(&cert_data, "")?;
diff --git a/src/server/mod.rs b/src/server/mod.rs
index 6e80f78..d61177c 100644
--- a/src/server/mod.rs
+++ b/src/server/mod.rs
@@ -1,3 +1,4 @@
1pub mod core; 1pub mod core;
2pub mod database; 2pub mod database;
3pub mod games; 3pub mod games;
4pub mod setup;
diff --git a/src/server/setup.rs b/src/server/setup.rs
new file mode 100644
index 0000000..96982c5
--- /dev/null
+++ b/src/server/setup.rs
@@ -0,0 +1,140 @@
1//! First-time TLS identity generation for the server.
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6#[derive(Debug)]
7pub struct SetupReport {
8 pub identity: PathBuf,
9 pub key: PathBuf,
10 pub cert: PathBuf,
11}
12
13/// Generate a self-signed RSA identity in `dir`.
14///
15/// Produces `identity.p12` (empty password, matching `native_tls`), `key.pem`,
16/// and `cert.pem`. Refuses to overwrite an existing identity.
17pub fn init_identity(dir: &Path, common_name: &str) -> Result<SetupReport, String> {
18 let common_name = if common_name.trim().is_empty() {
19 "localhost"
20 } else {
21 common_name.trim()
22 };
23
24 let identity = dir.join("identity.p12");
25 let key = dir.join("key.pem");
26 let cert = dir.join("cert.pem");
27
28 let existing: Vec<String> = [&identity, &key, &cert]
29 .into_iter()
30 .filter(|path| path.exists())
31 .map(|path| path.display().to_string())
32 .collect();
33 if !existing.is_empty() {
34 return Err(format!(
35 "Refusing to overwrite existing TLS files: {}\n\
36 Remove them first if you really want a new identity.",
37 existing.join(", ")
38 ));
39 }
40
41 std::fs::create_dir_all(dir)
42 .map_err(|error| format!("Could not create {}: {error}", dir.display()))?;
43
44 let manual = manual_instructions(common_name);
45 let subject = format!("/CN={common_name}");
46
47 run_openssl(
48 dir,
49 &[
50 "req",
51 "-x509",
52 "-newkey",
53 "rsa:2048",
54 "-keyout",
55 "key.pem",
56 "-out",
57 "cert.pem",
58 "-days",
59 "3650",
60 "-nodes",
61 "-subj",
62 &subject,
63 ],
64 "generate the certificate",
65 &manual,
66 )?;
67
68 run_openssl(
69 dir,
70 &[
71 "pkcs12",
72 "-export",
73 "-out",
74 "identity.p12",
75 "-inkey",
76 "key.pem",
77 "-in",
78 "cert.pem",
79 "-passout",
80 "pass:",
81 "-keypbe",
82 "PBE-SHA1-3DES",
83 "-certpbe",
84 "PBE-SHA1-3DES",
85 "-macalg",
86 "sha1",
87 ],
88 "build identity.p12",
89 &manual,
90 )?;
91
92 #[cfg(unix)]
93 {
94 use std::os::unix::fs::PermissionsExt;
95
96 for path in [&key, &identity] {
97 let metadata = std::fs::metadata(path)
98 .map_err(|error| format!("Could not inspect {}: {error}", path.display()))?;
99 let mut permissions = metadata.permissions();
100 permissions.set_mode(0o600);
101 std::fs::set_permissions(path, permissions).map_err(|error| {
102 format!("Could not protect {}: {error}", path.display())
103 })?;
104 }
105 }
106
107 Ok(SetupReport {
108 identity,
109 key,
110 cert,
111 })
112}
113
114fn run_openssl(dir: &Path, args: &[&str], action: &str, manual: &str) -> Result<(), String> {
115 let output = Command::new("openssl")
116 .current_dir(dir)
117 .args(args)
118 .output()
119 .map_err(|error| format!("Could not run openssl to {action}: {error}\n\n{manual}"))?;
120
121 if output.status.success() {
122 return Ok(());
123 }
124
125 let stderr = String::from_utf8_lossy(&output.stderr);
126 Err(format!(
127 "openssl failed to {action}:\n{}\n\n{manual}",
128 stderr.trim()
129 ))
130}
131
132fn manual_instructions(common_name: &str) -> String {
133 format!(
134 "Install openssl, or run these commands manually:\n\
135 \x20 openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \\\n\
136 \x20 -days 3650 -nodes -subj \"/CN={common_name}\"\n\
137 \x20 openssl pkcs12 -export -out identity.p12 -inkey key.pem -in cert.pem \\\n\
138 \x20 -passout pass:"
139 )
140}
diff --git a/tests/server_setup.rs b/tests/server_setup.rs
new file mode 100644
index 0000000..d527cd9
--- /dev/null
+++ b/tests/server_setup.rs
@@ -0,0 +1,38 @@
1use std::path::PathBuf;
2
3use zagan_bet::server::setup::init_identity;
4
5#[test]
6fn creates_a_usable_pkcs12_identity_and_refuses_to_overwrite() {
7 let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("server-setup");
8 let _ = std::fs::remove_dir_all(&dir);
9 std::fs::create_dir_all(&dir).unwrap();
10
11 init_identity(&dir, "localhost").expect("openssl must be available for this test");
12
13 for name in ["identity.p12", "key.pem", "cert.pem"] {
14 let path = dir.join(name);
15 assert!(path.exists(), "{name} was not created");
16 assert!(
17 std::fs::metadata(&path).unwrap().len() > 0,
18 "{name} is empty"
19 );
20 }
21
22 let p12 = std::fs::read(dir.join("identity.p12")).unwrap();
23 native_tls::Identity::from_pkcs12(&p12, "").expect("generated identity must load");
24
25 let second = init_identity(&dir, "localhost");
26 assert!(second.is_err(), "setup must not overwrite existing keys");
27
28 #[cfg(unix)]
29 {
30 use std::os::unix::fs::PermissionsExt;
31 let mode = std::fs::metadata(dir.join("key.pem"))
32 .unwrap()
33 .permissions()
34 .mode()
35 & 0o777;
36 assert_eq!(mode, 0o600, "private key should not be world-readable");
37 }
38}