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 | |
| 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
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/SERVER_SETUP.md | 100 | ||||
| -rw-r--r-- | docs/TESTING.md | 8 | ||||
| -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 | ||||
| -rw-r--r-- | tests/server_setup.rs | 38 |
10 files changed, 388 insertions, 46 deletions
| @@ -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 | ||
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "0.3.1" | 3 | version = "0.4.0" |
| 4 | edition = "2024" | 4 | edition = "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 | |||
| 3 | The server speaks TLS and needs a PKCS#12 identity (`identity.p12`) plus the | ||
| 4 | PEM key/certificate used to build it. The files are read from the current | ||
| 5 | working directory. | ||
| 6 | |||
| 7 | ## Quick start | ||
| 8 | |||
| 9 | From the project root: | ||
| 10 | |||
| 11 | ```sh | ||
| 12 | cargo run --bin server -- --init | ||
| 13 | cargo run --bin server | ||
| 14 | ``` | ||
| 15 | |||
| 16 | `--init` creates a self-signed development identity and exits. The next | ||
| 17 | command starts the server on `127.0.0.1:11525`. | ||
| 18 | |||
| 19 | If `identity.p12` is missing at startup, the server now prints these same | ||
| 20 | instructions 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 | |||
| 30 | On Unix, `identity.p12` and `key.pem` are set to mode `0600`. All three are | ||
| 31 | ignored by git (`*.p12`, `*.pem`, `*.key`). | ||
| 32 | |||
| 33 | ## Manual setup | ||
| 34 | |||
| 35 | If `openssl` is not installed or you want to control the certificate: | ||
| 36 | |||
| 37 | ```sh | ||
| 38 | openssl req -x509 -newkey rsa:2048 \ | ||
| 39 | -keyout key.pem -out cert.pem \ | ||
| 40 | -days 3650 -nodes -subj "/CN=localhost" | ||
| 41 | |||
| 42 | openssl pkcs12 -export -out identity.p12 \ | ||
| 43 | -inkey key.pem -in cert.pem -passout pass: | ||
| 44 | ``` | ||
| 45 | |||
| 46 | The 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 | ||
| 53 | cargo run --bin server | ||
| 54 | |||
| 55 | # listen on all interfaces | ||
| 56 | cargo run --bin server -- -i 0.0.0.0 -p 11525 | ||
| 57 | |||
| 58 | # show options | ||
| 59 | cargo run --bin server -- --help | ||
| 60 | ``` | ||
| 61 | |||
| 62 | Then start the client in another terminal: | ||
| 63 | |||
| 64 | ```sh | ||
| 65 | cargo run --bin client | ||
| 66 | ``` | ||
| 67 | |||
| 68 | On first contact the client prints the SPKI fingerprint and pins it. If you | ||
| 69 | regenerate 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 | ||
| 76 | the same way. `--init` uses SChannel-friendly PKCS#12 algorithms; if you build | ||
| 77 | the identity by hand on Windows, use: | ||
| 78 | |||
| 79 | ```sh | ||
| 80 | openssl 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. | 24 | on a machine where the `openssl` CLI is available. |
| 24 | 25 | ||
| 25 | ## Manual TUI checklist | 26 | ## Manual TUI checklist |
| 26 | 27 | ||
| 27 | There is no CI. Run this on Linux and on a Windows VM before a release. | 28 | There is no CI. Run this on Linux and on a Windows VM before a release. |
| 28 | Use an 80-column terminal because several output lines are width-sensitive. | 29 | Use an 80-column terminal because several output lines are width-sensitive. |
| 29 | 30 | ||
| 30 | 1. Start the server with a valid `identity.p12`, then start the client. | 31 | 1. Run `cargo run --bin server -- --init` once, then start the server with a |
| 32 | valid `identity.p12` and start the client. | ||
| 31 | 2. First contact prints three lines and the 64-character fingerprint does not | 33 | 2. 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. |
| 33 | 3. `register` / `login`: backspace removes one `*` per key, Esc cancels the | 35 | 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 { | |||
| 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 | } | ||
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 @@ | |||
| 1 | use std::path::PathBuf; | ||
| 2 | |||
| 3 | use zagan_bet::server::setup::init_identity; | ||
| 4 | |||
| 5 | #[test] | ||
| 6 | fn 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 | } | ||
