diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-28 22:55:51 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-28 22:55:51 +0100 |
| commit | 87440d5a23f0fd894d949295a7c1ff85043550fc (patch) | |
| tree | 53b2bada6872b6e565bb79982213616a50419b1c /src | |
| parent | v0.3.1: clean up dependencies, stale tests and document verification (diff) | |
| download | zagan-bet-87440d5a23f0fd894d949295a7c1ff85043550fc.tar.gz zagan-bet-87440d5a23f0fd894d949295a7c1ff85043550fc.zip | |
v0.4.0: add first-time TLS setup and server help
Diffstat (limited to 'src')
| -rw-r--r-- | src/bin/server/args.rs | 85 | ||||
| -rw-r--r-- | src/bin/server/main.rs | 21 | ||||
| -rw-r--r-- | src/bin/server/tls.rs | 34 | ||||
| -rw-r--r-- | src/server/mod.rs | 1 | ||||
| -rw-r--r-- | src/server/setup.rs | 140 |
5 files changed, 240 insertions, 41 deletions
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 | ||
| 7 | pub fn parse_args() -> Args { | 8 | pub 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 | |||
| 74 | fn 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 | ||
| 9 | fn main() { | 9 | fn 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 | ||
| 11 | impl TlsServer { | 11 | impl 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 @@ | |||
| 1 | pub mod core; | 1 | pub mod core; |
| 2 | pub mod database; | 2 | pub mod database; |
| 3 | pub mod games; | 3 | pub mod games; |
| 4 | 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 @@ | |||
| 1 | //! First-time TLS identity generation for the server. | ||
| 2 | |||
| 3 | use std::path::{Path, PathBuf}; | ||
| 4 | use std::process::Command; | ||
| 5 | |||
| 6 | #[derive(Debug)] | ||
| 7 | pub 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. | ||
| 17 | pub 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 | |||
| 114 | fn 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 | |||
| 132 | fn 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 | } | ||
