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 --- 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 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 240 insertions(+), 41 deletions(-) create mode 100644 src/server/setup.rs (limited to 'src') 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:" + ) +} -- cgit v1.2.3