221 lines
6.6 KiB
Rust
221 lines
6.6 KiB
Rust
|
|
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()
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|