2026-09-29 12:21:04 +00:00
|
|
|
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};
|
2026-09-30 08:47:33 +00:00
|
|
|
use rcgen::{KeyPair, PKCS_ED25519};
|
2026-09-29 12:21:04 +00:00
|
|
|
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 {
|
2026-09-30 08:47:33 +00:00
|
|
|
/// Directory for keypairs and the SQLite database.
|
2026-09-29 12:21:04 +00:00
|
|
|
#[arg(long, env = "WOLLOSEUM_DATA_DIR", default_value = "data")]
|
|
|
|
|
data_dir: PathBuf,
|
2026-09-30 08:47:33 +00:00
|
|
|
/// HTTP listener address, usually behind a TLS reverse proxy.
|
2026-09-29 12:21:04 +00:00
|
|
|
#[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,
|
2026-09-30 08:47:33 +00:00
|
|
|
/// Identity ID for legacy certificate-fingerprint identities.
|
|
|
|
|
#[arg(long, global = true, env = "WOLLOSEUM_CLIENT_ID")]
|
|
|
|
|
client_id: Option<String>,
|
|
|
|
|
/// Optional legacy certificate used to derive its historical identity ID.
|
|
|
|
|
#[arg(long, global = true, env = "WOLLOSEUM_CLIENT_CERT")]
|
|
|
|
|
client_cert: Option<PathBuf>,
|
|
|
|
|
/// Ed25519 private key for the selected identity.
|
2026-09-29 12:21:04 +00:00
|
|
|
#[arg(
|
|
|
|
|
long,
|
|
|
|
|
global = true,
|
|
|
|
|
env = "WOLLOSEUM_CLIENT_KEY",
|
2026-09-30 08:47:33 +00:00
|
|
|
default_value = "data/keys/admin-private.pem"
|
2026-09-29 12:21:04 +00:00
|
|
|
)]
|
|
|
|
|
client_key: PathBuf,
|
|
|
|
|
#[command(subcommand)]
|
|
|
|
|
command: ClientCommand,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
|
|
|
enum ClientCommand {
|
|
|
|
|
/// Read the current game status.
|
|
|
|
|
Status,
|
2026-09-30 08:47:33 +00:00
|
|
|
/// Create a user and print its identity bundle as JSON.
|
2026-09-29 12:21:04 +00:00
|
|
|
CreateUser { username: String },
|
2026-09-30 08:47:33 +00:00
|
|
|
/// Delete a player by identity ID.
|
2026-09-29 12:21:04 +00:00
|
|
|
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>> {
|
2026-09-30 08:47:33 +00:00
|
|
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
2026-09-29 12:21:04 +00:00
|
|
|
let client = Client::builder().build()?;
|
2026-09-30 08:47:33 +00:00
|
|
|
let base_url = arguments.server.trim_end_matches('/');
|
|
|
|
|
let mut created_identity = None;
|
|
|
|
|
let (method, path, body, requires_auth) = match arguments.command {
|
2026-09-29 12:21:04 +00:00
|
|
|
ClientCommand::Status => (Method::GET, "/api/status", Vec::new(), false),
|
2026-09-30 08:47:33 +00:00
|
|
|
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)
|
|
|
|
|
}
|
2026-09-29 12:21:04 +00:00
|
|
|
ClientCommand::DeleteUser { client_id } => (
|
|
|
|
|
Method::POST,
|
|
|
|
|
"/api/delete_user",
|
2026-09-30 08:47:33 +00:00
|
|
|
serde_json::to_vec(&serde_json::json!({ "client_id": client_id }))?,
|
2026-09-29 12:21:04 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-30 08:47:33 +00:00
|
|
|
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())
|
2026-09-29 12:21:04 +00:00
|
|
|
.map_err(|_| "client private key is not a valid Ed25519 PKCS#8 key")?;
|
2026-09-30 08:47:33 +00:00
|
|
|
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}"));
|
2026-09-29 12:21:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let response = request.send().await?;
|
|
|
|
|
let status = response.status();
|
2026-09-30 08:47:33 +00:00
|
|
|
let response_body = response.bytes().await?;
|
2026-09-29 12:21:04 +00:00
|
|
|
if !status.is_success() {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"server returned {status}: {}",
|
2026-09-30 08:47:33 +00:00
|
|
|
String::from_utf8_lossy(&response_body)
|
2026-09-29 12:21:04 +00:00
|
|
|
)
|
|
|
|
|
.into());
|
|
|
|
|
}
|
2026-09-30 08:47:33 +00:00
|
|
|
if response_body.is_empty() {
|
2026-09-29 12:21:04 +00:00
|
|
|
println!("HTTP {status}");
|
2026-09-30 08:47:33 +00:00
|
|
|
} else if let Ok(mut json) = serde_json::from_slice::<serde_json::Value>(&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);
|
|
|
|
|
}
|
2026-09-29 12:21:04 +00:00
|
|
|
println!("{}", serde_json::to_string_pretty(&json)?);
|
|
|
|
|
} else {
|
2026-09-30 08:47:33 +00:00
|
|
|
println!("{}", String::from_utf8_lossy(&response_body));
|
2026-09-29 12:21:04 +00:00
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 08:47:33 +00:00
|
|
|
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()
|
2026-09-29 12:21:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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]
|
2026-09-30 08:47:33 +00:00
|
|
|
fn cli_can_select_legacy_or_keypair_credentials_for_upload() {
|
2026-09-29 12:21:04 +00:00
|
|
|
assert!(
|
|
|
|
|
Arguments::try_parse_from([
|
|
|
|
|
"wolloseum",
|
|
|
|
|
"cli",
|
|
|
|
|
"--client-cert",
|
|
|
|
|
"player.pem",
|
|
|
|
|
"--client-key",
|
|
|
|
|
"player-key.pem",
|
|
|
|
|
"update-agent",
|
|
|
|
|
"agent.wasm",
|
|
|
|
|
])
|
|
|
|
|
.is_ok()
|
|
|
|
|
);
|
2026-09-30 08:47:33 +00:00
|
|
|
assert!(
|
|
|
|
|
Arguments::try_parse_from([
|
|
|
|
|
"wolloseum",
|
|
|
|
|
"cli",
|
|
|
|
|
"--client-id",
|
|
|
|
|
"identity-id",
|
|
|
|
|
"--client-key",
|
|
|
|
|
"player-key.pem",
|
|
|
|
|
"update-agent",
|
|
|
|
|
"agent.wasm",
|
|
|
|
|
])
|
|
|
|
|
.is_ok()
|
|
|
|
|
);
|
2026-09-29 12:21:04 +00:00
|
|
|
}
|
|
|
|
|
}
|