use std::error::Error; use std::fs; use std::net::SocketAddr; use std::path::PathBuf; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use clap::{Args, Parser, Subcommand}; use rcgen::{KeyPair, PKCS_ED25519}; use reqwest::{Client, Method}; use ring::signature::Ed25519KeyPair; #[derive(Debug, Parser)] #[command( name = "wolloseum", version, about = "Wolloseum game server and client" )] pub struct Arguments { #[command(subcommand)] command: TopLevelCommand, } #[derive(Debug, Subcommand)] enum TopLevelCommand { /// Start the HTTP game server. Serve(ServeArguments), /// Send authenticated requests to a Wolloseum server. Cli(ClientArguments), } #[derive(Debug, Args)] struct ServeArguments { /// Directory for keypairs and the SQLite database. #[arg(long, env = "WOLLOSEUM_DATA_DIR", default_value = "data")] data_dir: PathBuf, /// HTTP listener address, usually behind a TLS reverse proxy. #[arg(long, env = "WOLLOSEUM_BIND", default_value = "127.0.0.1:8080")] bind: SocketAddr, } #[derive(Debug, Args)] struct ClientArguments { /// HTTP base URL for the server. #[arg( long, global = true, env = "WOLLOSEUM_SERVER", default_value = "http://127.0.0.1:8080" )] server: String, /// Identity ID for legacy certificate-fingerprint identities. #[arg(long, global = true, env = "WOLLOSEUM_CLIENT_ID")] client_id: Option, /// Optional legacy certificate used to derive its historical identity ID. #[arg(long, global = true, env = "WOLLOSEUM_CLIENT_CERT")] client_cert: Option, /// Ed25519 private key for the selected identity. #[arg( long, global = true, env = "WOLLOSEUM_CLIENT_KEY", default_value = "data/keys/admin-private.pem" )] client_key: PathBuf, #[command(subcommand)] command: ClientCommand, } #[derive(Debug, Subcommand)] enum ClientCommand { /// Read the current game status. Status, /// Create a user and print its identity bundle as JSON. CreateUser { username: String }, /// Delete a player by identity ID. DeleteUser { client_id: String }, /// Pause the game. StopGame, /// Resume the game. StartGame, /// Upload or update this client identity's Extism WASM agent. UpdateAgent { path: PathBuf }, } pub async fn run(arguments: Arguments) -> Result<(), Box> { match arguments.command { TopLevelCommand::Serve(arguments) => { crate::server::run(arguments.data_dir, arguments.bind).await } TopLevelCommand::Cli(arguments) => run_client(arguments).await, } } async fn run_client(arguments: ClientArguments) -> Result<(), Box> { let _ = rustls::crypto::ring::default_provider().install_default(); let client = Client::builder().build()?; let base_url = arguments.server.trim_end_matches('/'); let mut created_identity = None; let (method, path, body, requires_auth) = match arguments.command { ClientCommand::Status => (Method::GET, "/api/status", Vec::new(), false), ClientCommand::CreateUser { username } => { let key = KeyPair::generate_for(&PKCS_ED25519)?; let body = serde_json::to_vec(&serde_json::json!({ "username": username, "public_key": BASE64.encode(key.public_key_raw()), }))?; created_identity = Some((username, key.serialize_pem())); (Method::POST, "/api/create_user", body, true) } ClientCommand::DeleteUser { client_id } => ( Method::POST, "/api/delete_user", serde_json::to_vec(&serde_json::json!({ "client_id": client_id }))?, true, ), ClientCommand::StopGame => (Method::POST, "/api/stop_game", Vec::new(), true), ClientCommand::StartGame => (Method::POST, "/api/start_game", Vec::new(), true), ClientCommand::UpdateAgent { path } => { (Method::POST, "/api/update_agent", fs::read(path)?, true) } }; let mut request = client .request(method, format!("{base_url}{path}")) .body(body); if requires_auth { let private_key = KeyPair::from_pem(&fs::read_to_string(&arguments.client_key)?)?; let signer = Ed25519KeyPair::from_pkcs8(&private_key.serialize_der()) .map_err(|_| "client private key is not a valid Ed25519 PKCS#8 key")?; let client_id = if let Some(client_id) = arguments.client_id { client_id } else if let Some(certificate_path) = arguments.client_cert { certificate_id(&fs::read_to_string(certificate_path)?) } else { public_key_id(private_key.public_key_raw()) }; let challenge_response_body = client .post(format!("{base_url}/api/auth/challenge")) .header("content-type", "application/json") .body(serde_json::to_vec( &serde_json::json!({ "client_id": client_id }), )?) .send() .await? .error_for_status()? .bytes() .await?; let challenge_response: serde_json::Value = serde_json::from_slice(&challenge_response_body)?; let challenge_text = challenge_response["challenge"] .as_str() .ok_or("missing authentication challenge")?; let challenge = BASE64.decode(challenge_text)?; let login_response_body = client .post(format!("{base_url}/api/auth/login")) .header("content-type", "application/json") .body(serde_json::to_vec(&serde_json::json!({ "client_id": client_id, "challenge": challenge_text, "signature": BASE64.encode(signer.sign(&challenge).as_ref()), }))?) .send() .await? .error_for_status()? .bytes() .await?; let login_response: serde_json::Value = serde_json::from_slice(&login_response_body)?; let token = login_response["token"] .as_str() .ok_or("missing session token")?; request = request.header("authorization", format!("Bearer {token}")); } let response = request.send().await?; let status = response.status(); let response_body = response.bytes().await?; if !status.is_success() { return Err(format!( "server returned {status}: {}", String::from_utf8_lossy(&response_body) ) .into()); } if response_body.is_empty() { println!("HTTP {status}"); } else if let Ok(mut json) = serde_json::from_slice::(&response_body) { if let Some((username, private_key_pem)) = created_identity { json["username"] = serde_json::Value::String(username); json["private_key_pem"] = serde_json::Value::String(private_key_pem); } println!("{}", serde_json::to_string_pretty(&json)?); } else { println!("{}", String::from_utf8_lossy(&response_body)); } Ok(()) } fn public_key_id(public_key: &[u8]) -> String { ring::digest::digest(&ring::digest::SHA256, public_key) .as_ref() .iter() .map(|byte| format!("{byte:02x}")) .collect() } fn certificate_id(certificate_pem: &str) -> String { ring::digest::digest(&ring::digest::SHA256, certificate_pem.as_bytes()) .as_ref() .iter() .map(|byte| format!("{byte:02x}")) .collect() } #[cfg(test)] mod tests { use super::Arguments; use clap::Parser; #[test] fn serve_and_cli_are_explicit_top_level_commands() { assert!(Arguments::try_parse_from(["wolloseum"]).is_err()); assert!(Arguments::try_parse_from(["wolloseum", "serve"]).is_ok()); assert!(Arguments::try_parse_from(["wolloseum", "cli", "status"]).is_ok()); } #[test] fn cli_can_select_legacy_or_keypair_credentials_for_upload() { assert!( Arguments::try_parse_from([ "wolloseum", "cli", "--client-cert", "player.pem", "--client-key", "player-key.pem", "update-agent", "agent.wasm", ]) .is_ok() ); assert!( Arguments::try_parse_from([ "wolloseum", "cli", "--client-id", "identity-id", "--client-key", "player-key.pem", "update-agent", "agent.wasm", ]) .is_ok() ); } }