wolloseum/src/cli.rs

257 lines
8.6 KiB
Rust

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<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.
#[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<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 _ = 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::<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);
}
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()
);
}
}