wolloseum/src/cli.rs

221 lines
6.6 KiB
Rust
Raw Normal View History

2026-09-29 12:21:04 +00:00
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<dyn Error>> {
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<dyn Error>> {
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::<serde_json::Value>(&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<u8> {
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()
);
}
}