use std::error::Error; use std::fs; use std::net::SocketAddr; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use clap::{Args, Parser, Subcommand}; use rcgen::KeyPair; use reqwest::{Client, Method}; use ring::signature::Ed25519KeyPair; use serde::Serialize; #[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 certificates and the SQLite database. #[arg(long, env = "WOLLOSEUM_DATA_DIR", default_value = "data")] data_dir: PathBuf, /// HTTP listener address. #[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, /// Client certificate whose PEM fingerprint is the identity ID. #[arg( long, global = true, env = "WOLLOSEUM_CLIENT_CERT", default_value = "data/certificates/admin-client.pem" )] client_cert: PathBuf, /// Private key for the selected client certificate. #[arg( long, global = true, env = "WOLLOSEUM_CLIENT_KEY", default_value = "data/certificates/admin-client-key.pem" )] client_key: PathBuf, #[command(subcommand)] command: ClientCommand, } #[derive(Debug, Subcommand)] enum ClientCommand { /// Read the current game status. Status, /// Create a user and print their certificate and private key as JSON. CreateUser { username: String }, /// Delete a player by certificate 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 client = Client::builder().build()?; let (method, path, body, needs_signature) = match arguments.command { ClientCommand::Status => (Method::GET, "/api/status", Vec::new(), false), ClientCommand::CreateUser { username } => ( Method::POST, "/api/create_user", serde_json::to_vec(&CreateUserBody { username })?, true, ), ClientCommand::DeleteUser { client_id } => ( Method::POST, "/api/delete_user", serde_json::to_vec(&DeleteUserBody { 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 url = format!("{}{}", arguments.server.trim_end_matches('/'), path); let mut request = client.request(method.clone(), url).body(body.clone()); if needs_signature { let certificate_pem = fs::read_to_string(&arguments.client_cert)?; let key = KeyPair::from_pem(&fs::read_to_string(&arguments.client_key)?)?; let signer = Ed25519KeyPair::from_pkcs8(&key.serialize_der()) .map_err(|_| "client private key is not a valid Ed25519 PKCS#8 key")?; let client_id = certificate_id(&certificate_pem); let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); let nonce = uuid::Uuid::new_v4().to_string(); let message = signature_message(method.as_str(), path, timestamp, &nonce, &body); let signature = BASE64.encode(signer.sign(&message).as_ref()); request = request .header("x-client-id", client_id) .header("x-request-timestamp", timestamp.to_string()) .header("x-request-nonce", nonce) .header("x-request-signature", signature); } let response = request.send().await?; let status = response.status(); let body = response.bytes().await?; if !status.is_success() { return Err(format!( "server returned {status}: {}", String::from_utf8_lossy(&body) ) .into()); } if body.is_empty() { println!("HTTP {status}"); } else if let Ok(json) = serde_json::from_slice::(&body) { println!("{}", serde_json::to_string_pretty(&json)?); } else { println!("{}", String::from_utf8_lossy(&body)); } Ok(()) } #[derive(Serialize)] struct CreateUserBody { username: String, } #[derive(Serialize)] struct DeleteUserBody { client_id: String, } fn signature_message( method: &str, path: &str, timestamp: u64, nonce: &str, body: &[u8], ) -> Vec { let mut message = format!("{method}\n{path}\n{timestamp}\n{nonce}\n").into_bytes(); message.extend_from_slice(body); message } 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_user_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() ); } }