From 87440d5a23f0fd894d949295a7c1ff85043550fc Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 22:55:51 +0100 Subject: v0.4.0: add first-time TLS setup and server help --- .gitignore | 5 +- Cargo.toml | 2 +- docs/SERVER_SETUP.md | 100 +++++++++++++++++++++++++++++++++++ docs/TESTING.md | 8 +-- src/bin/server/args.rs | 85 ++++++++++++++++++++++-------- src/bin/server/main.rs | 21 +++++++- src/bin/server/tls.rs | 34 ++++++------ src/server/mod.rs | 1 + src/server/setup.rs | 140 +++++++++++++++++++++++++++++++++++++++++++++++++ tests/server_setup.rs | 38 ++++++++++++++ 10 files changed, 388 insertions(+), 46 deletions(-) create mode 100644 docs/SERVER_SETUP.md create mode 100644 src/server/setup.rs create mode 100644 tests/server_setup.rs diff --git a/.gitignore b/.gitignore index 3f32615..a140ec2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /target *db *.lock -/text \ No newline at end of file +*.p12 +*.pem +*.key +/text diff --git a/Cargo.toml b/Cargo.toml index 5e50626..0ab5ada 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "0.3.1" +version = "0.4.0" edition = "2024" [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 @@ +# Server setup + +The server speaks TLS and needs a PKCS#12 identity (`identity.p12`) plus the +PEM key/certificate used to build it. The files are read from the current +working directory. + +## Quick start + +From the project root: + +```sh +cargo run --bin server -- --init +cargo run --bin server +``` + +`--init` creates a self-signed development identity and exits. The next +command starts the server on `127.0.0.1:11525`. + +If `identity.p12` is missing at startup, the server now prints these same +instructions instead of only the raw OS error. + +## What `--init` creates + +| File | Purpose | +| --- | --- | +| `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. | +| `key.pem` | RSA private key used to build the identity. Not read at runtime. | +| `cert.pem` | Public certificate, useful for inspection. | + +On Unix, `identity.p12` and `key.pem` are set to mode `0600`. All three are +ignored by git (`*.p12`, `*.pem`, `*.key`). + +## Manual setup + +If `openssl` is not installed or you want to control the certificate: + +```sh +openssl req -x509 -newkey rsa:2048 \ + -keyout key.pem -out cert.pem \ + -days 3650 -nodes -subj "/CN=localhost" + +openssl pkcs12 -export -out identity.p12 \ + -inkey key.pem -in cert.pem -passout pass: +``` + +The password must be empty because the server calls +`Identity::from_pkcs12(&data, "")`. + +## Running + +```sh +# default: 127.0.0.1:11525 +cargo run --bin server + +# listen on all interfaces +cargo run --bin server -- -i 0.0.0.0 -p 11525 + +# show options +cargo run --bin server -- --help +``` + +Then start the client in another terminal: + +```sh +cargo run --bin client +``` + +On first contact the client prints the SPKI fingerprint and pins it. If you +regenerate the server identity, the client will reject the new key; run +`forget-host` in the client if the change is intentional. + +## Windows + +`--init` shells out to `openssl`, so OpenSSL must be installed and on `PATH` +(Git for Windows, WSL, or a standalone OpenSSL build). The generated files work +the same way. `--init` uses SChannel-friendly PKCS#12 algorithms; if you build +the identity by hand on Windows, use: + +```sh +openssl pkcs12 -export -out identity.p12 -inkey key.pem -in cert.pem \ + -passout pass: -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 +``` + +## Security notes + +- This is a self-signed development identity with an empty PKCS#12 password. + Treat `key.pem` and `identity.p12` as secrets and do not commit them. +- The trust model is TOFU: clients pin the SPKI fingerprint on first contact. + A changed key is a loud error, not an automatic re-trust. +- For a public deployment, use a CA-issued certificate and protect the + PKCS#12 with a password; the current server code expects an empty password. + +## Troubleshooting + +| Symptom | Fix | +| --- | --- | +| `Failed to initialize TLS: Could not read TLS identity` | Run `cargo run --bin server -- --init`. | +| `Address already in use` | Stop the other server or pass `-p `. | +| Client says `HOST KEY CHANGED` | The server certificate changed. If intentional, run `forget-host` in the client. | +| `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. | `tests/client_ui.rs` | Help, history, completion, bet parsing, display width, cursor math. | | `tests/protocol.rs` | Newline buffering and timeout/error classification. | | `tests/server_core.rs` | Handler, bcrypt migration, SQL injection, transaction rollback, `plays` audit, sessions. | +| `tests/server_setup.rs` | `--init` identity generation, PKCS#12 loadability, overwrite refusal. | | `tests/slots.rs` | Server slots payout rules and overflow handling. | | `tests/storage.rs` | Client/server SQLite storage helpers. | | `tests/tofu.rs` | SPKI fingerprint and TOFU decisions. | -`tests/tofu.rs` shells out to `openssl`, so run it on a machine where the -`openssl` CLI is available. +`tests/tofu.rs` and `tests/server_setup.rs` shell out to `openssl`, so run them +on a machine where the `openssl` CLI is available. ## Manual TUI checklist There is no CI. Run this on Linux and on a Windows VM before a release. Use an 80-column terminal because several output lines are width-sensitive. -1. Start the server with a valid `identity.p12`, then start the client. +1. Run `cargo run --bin server -- --init` once, then start the server with a + valid `identity.p12` and start the client. 2. First contact prints three lines and the 64-character fingerprint does not wrap at 80 columns. `Connected to ...` shows a one-decimal ping. 3. `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 { pub ip: String, pub port: u16, pub unsafe_mode: bool, + pub init: bool, } pub fn parse_args() -> Args { @@ -9,42 +10,80 @@ pub fn parse_args() -> Args { let mut ip = "127.0.0.1".to_string(); let mut port = 11525; let mut unsafe_mode = false; + let mut init = false; let mut i = 1; + while i < args.len() { - if args[i] == "-p" { - if i + 1 < args.len() { - port = args[i + 1].parse::().unwrap_or_else(|_| { - eprintln!("Invalid port number: {}", args[i + 1]); + match args[i].as_str() { + "-p" => { + if i + 1 < args.len() { + port = args[i + 1].parse::().unwrap_or_else(|_| { + eprintln!("Invalid port number: {}", args[i + 1]); + std::process::exit(1); + }); + i += 2; + } else { + eprintln!("-p requires a port number"); std::process::exit(1); - }); - i += 2; - } else { - eprintln!("-p requires a port number"); - std::process::exit(1); + } } - } else if args[i] == "-i" { - if i + 1 < args.len() { - ip = args[i + 1].clone(); - if ip != "127.0.0.1" && ip != "0.0.0.0" { - eprintln!("Invalid IP address: {}. Must be 127.0.0.1 or 0.0.0.0", ip); + "-i" => { + if i + 1 < args.len() { + ip = args[i + 1].clone(); + if ip != "127.0.0.1" && ip != "0.0.0.0" { + eprintln!( + "Invalid IP address: {}. Must be 127.0.0.1 or 0.0.0.0", + ip + ); + std::process::exit(1); + } + i += 2; + } else { + eprintln!("-i requires an IP address"); std::process::exit(1); } - i += 2; - } else { - eprintln!("-i requires an IP address"); + } + "--unsafe" => { + unsafe_mode = true; + i += 1; + } + "--init" => { + init = true; + i += 1; + } + "-h" | "--help" => { + print_usage(); + std::process::exit(0); + } + other => { + eprintln!("Unknown argument: {other}"); + print_usage(); std::process::exit(1); } - } else if args[i] == "--unsafe" { - unsafe_mode = true; - i += 1; - } else { - eprintln!("Unknown argument: {}", args[i]); - std::process::exit(1); } } + Args { ip, port, unsafe_mode, + init, } } + +fn print_usage() { + println!( + "Usage: server [OPTIONS]\n\ + \n\ + Options:\n\ + \x20 -i Bind address (127.0.0.1 or 0.0.0.0)\n\ + \x20 -p Bind port (default 11525)\n\ + \x20 --init Generate identity.p12, key.pem and cert.pem, then exit\n\ + \x20 --unsafe Run in unsafe mode (warning only)\n\ + \x20 -h, --help Show this help\n\ + \n\ + First run:\n\ + \x20 cargo run --bin server -- --init\n\ + \x20 cargo run --bin server" + ); +} 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; fn main() { let args = args::parse_args(); + + if args.init { + match zagan_bet::server::setup::init_identity(std::path::Path::new("."), "localhost") { + Ok(report) => { + println!("TLS identity created:"); + println!(" {} (PKCS#12, empty password)", report.identity.display()); + println!(" {} (private key)", report.key.display()); + println!(" {} (certificate)", report.cert.display()); + println!("\nStart the server with:\n cargo run --bin server"); + println!("Clients will trust this certificate on first contact."); + } + Err(error) => { + eprintln!("TLS setup failed:\n{error}"); + std::process::exit(1); + } + } + return; + } + let listener = match TcpListener::bind(format!("{}:{}", args.ip, args.port)) { Ok(l) => l, Err(e) => { @@ -16,7 +35,7 @@ fn main() { } }; - let tls = match tls::TlsServer::new("identity.p12", "key.pem", args.unsafe_mode) { + let tls = match tls::TlsServer::new("identity.p12") { Ok(t) => Arc::new(t), Err(e) => { 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 { } impl TlsServer { - pub fn new( - cert_path: &str, - _key_path: &str, - unsafe_mode: bool, - ) -> Result> { - let cert_data = if unsafe_mode { - if std::path::Path::new(cert_path).exists() { - std::fs::read(cert_path)? - } else { + pub fn new(cert_path: &str) -> Result> { + let cert_data = match std::fs::read(cert_path) { + Ok(data) => data, + Err(error) => { return Err(format!( - "Certificate file '{}' not found. In --unsafe mode, you must provide a valid PKCS12 certificate file.\n\ - You can generate one with:\n\ - openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes\n\ - openssl pkcs12 -export -out identity.p12 -inkey key.pem -in cert.pem", - cert_path - ).into()); + "Could not read TLS identity '{}': {}\n\ + \n\ + First-time setup:\n\ + \x20 cargo run --bin server -- --init\n\ + \n\ + Or generate it manually:\n\ + \x20 openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \\\n\ + \x20 -days 3650 -nodes -subj \"/CN=localhost\"\n\ + \x20 openssl pkcs12 -export -out identity.p12 -inkey key.pem -in cert.pem \\\n\ + \x20 -passout pass:", + cert_path, error + ) + .into()); } - } else { - std::fs::read(cert_path)? }; 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 @@ pub mod core; pub mod database; pub mod games; +pub 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 @@ +//! First-time TLS identity generation for the server. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug)] +pub struct SetupReport { + pub identity: PathBuf, + pub key: PathBuf, + pub cert: PathBuf, +} + +/// Generate a self-signed RSA identity in `dir`. +/// +/// Produces `identity.p12` (empty password, matching `native_tls`), `key.pem`, +/// and `cert.pem`. Refuses to overwrite an existing identity. +pub fn init_identity(dir: &Path, common_name: &str) -> Result { + let common_name = if common_name.trim().is_empty() { + "localhost" + } else { + common_name.trim() + }; + + let identity = dir.join("identity.p12"); + let key = dir.join("key.pem"); + let cert = dir.join("cert.pem"); + + let existing: Vec = [&identity, &key, &cert] + .into_iter() + .filter(|path| path.exists()) + .map(|path| path.display().to_string()) + .collect(); + if !existing.is_empty() { + return Err(format!( + "Refusing to overwrite existing TLS files: {}\n\ + Remove them first if you really want a new identity.", + existing.join(", ") + )); + } + + std::fs::create_dir_all(dir) + .map_err(|error| format!("Could not create {}: {error}", dir.display()))?; + + let manual = manual_instructions(common_name); + let subject = format!("/CN={common_name}"); + + run_openssl( + dir, + &[ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + "key.pem", + "-out", + "cert.pem", + "-days", + "3650", + "-nodes", + "-subj", + &subject, + ], + "generate the certificate", + &manual, + )?; + + run_openssl( + dir, + &[ + "pkcs12", + "-export", + "-out", + "identity.p12", + "-inkey", + "key.pem", + "-in", + "cert.pem", + "-passout", + "pass:", + "-keypbe", + "PBE-SHA1-3DES", + "-certpbe", + "PBE-SHA1-3DES", + "-macalg", + "sha1", + ], + "build identity.p12", + &manual, + )?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + for path in [&key, &identity] { + let metadata = std::fs::metadata(path) + .map_err(|error| format!("Could not inspect {}: {error}", path.display()))?; + let mut permissions = metadata.permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(path, permissions).map_err(|error| { + format!("Could not protect {}: {error}", path.display()) + })?; + } + } + + Ok(SetupReport { + identity, + key, + cert, + }) +} + +fn run_openssl(dir: &Path, args: &[&str], action: &str, manual: &str) -> Result<(), String> { + let output = Command::new("openssl") + .current_dir(dir) + .args(args) + .output() + .map_err(|error| format!("Could not run openssl to {action}: {error}\n\n{manual}"))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!( + "openssl failed to {action}:\n{}\n\n{manual}", + stderr.trim() + )) +} + +fn manual_instructions(common_name: &str) -> String { + format!( + "Install openssl, or run these commands manually:\n\ + \x20 openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \\\n\ + \x20 -days 3650 -nodes -subj \"/CN={common_name}\"\n\ + \x20 openssl pkcs12 -export -out identity.p12 -inkey key.pem -in cert.pem \\\n\ + \x20 -passout pass:" + ) +} 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 @@ +use std::path::PathBuf; + +use zagan_bet::server::setup::init_identity; + +#[test] +fn creates_a_usable_pkcs12_identity_and_refuses_to_overwrite() { + let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("server-setup"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + init_identity(&dir, "localhost").expect("openssl must be available for this test"); + + for name in ["identity.p12", "key.pem", "cert.pem"] { + let path = dir.join(name); + assert!(path.exists(), "{name} was not created"); + assert!( + std::fs::metadata(&path).unwrap().len() > 0, + "{name} is empty" + ); + } + + let p12 = std::fs::read(dir.join("identity.p12")).unwrap(); + native_tls::Identity::from_pkcs12(&p12, "").expect("generated identity must load"); + + let second = init_identity(&dir, "localhost"); + assert!(second.is_err(), "setup must not overwrite existing keys"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.join("key.pem")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "private key should not be world-readable"); + } +} -- cgit v1.2.3