This commit is contained in:
Steve Biedermann 2026-09-30 10:47:33 +02:00
parent ce8bdcf09d
commit 0e099ebb18
14 changed files with 2056 additions and 496 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
.git
.github
target
data
example-wasm-agent/target

76
Cargo.lock generated
View File

@ -103,15 +103,6 @@ version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "arc-swap"
version = "1.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "asn1-rs" name = "asn1-rs"
version = "0.7.2" version = "0.7.2"
@ -229,28 +220,6 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "axum-server"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc"
dependencies = [
"arc-swap",
"bytes",
"either",
"fs-err",
"http",
"http-body",
"hyper",
"hyper-util",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@ -963,16 +932,6 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs-err"
version = "3.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a"
dependencies = [
"autocfg",
"tokio",
]
[[package]] [[package]]
name = "fs-set-times" name = "fs-set-times"
version = "0.20.3" version = "0.20.3"
@ -1124,25 +1083,6 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "h2"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
@ -1233,7 +1173,6 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@ -2947,20 +2886,6 @@ dependencies = [
"tungstenite", "tungstenite",
] ]
[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"libc",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.9.12+spec-1.1.0" version = "0.9.12+spec-1.1.0"
@ -4047,7 +3972,6 @@ name = "wolloseum"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"axum-server",
"base64 0.22.1", "base64 0.22.1",
"clap", "clap",
"extism", "extism",

View File

@ -5,15 +5,14 @@ edition = "2024"
[dependencies] [dependencies]
axum = { version = "0.8", features = ["ws"] } axum = { version = "0.8", features = ["ws"] }
axum-server = { version = "0.8", default-features = false, features = ["tls-rustls-no-provider"] }
base64 = "0.22" base64 = "0.22"
clap = { version = "4.6.7", features = ["derive", "env"] } clap = { version = "4.6.7", features = ["derive", "env"] }
extism = { version = "1.30", default-features = false, features = ["wasmtime-default-features"] } extism = { version = "1.30", default-features = false, features = ["wasmtime-default-features"] }
rcgen = { version = "0.14", features = ["x509-parser"] } rcgen = "0.14"
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"] } reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"] }
ring = "0.17" ring = "0.17"
rusqlite = { version = "0.40", features = ["bundled"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
rusqlite = { version = "0.40", features = ["bundled"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }

27
Dockerfile Normal file
View File

@ -0,0 +1,27 @@
FROM rust:1-bookworm AS builder
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY web ./web
RUN cargo build --release --bin wolloseum
FROM debian:bookworm-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends util-linux \
&& rm -rf /var/lib/apt/lists/* \
&& install -d --owner=10001 --group=10001 /var/lib/wolloseum
COPY --from=builder --chown=10001:10001 /build/target/release/wolloseum /usr/local/bin/wolloseum
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh
ENV WOLLOSEUM_DATA_DIR=/var/lib/wolloseum \
WOLLOSEUM_BIND=0.0.0.0:8080
VOLUME ["/var/lib/wolloseum"]
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["/usr/local/bin/wolloseum", "serve"]

253
README.md
View File

@ -1,161 +1,156 @@
# Wolloseum # Wolloseum
Wolloseum is a deterministic, procedurally generated game server. Players Wolloseum is a deterministic, procedurally generated game server. Players
upload untrusted WASM agents (run in a sandboxed Extism/Wasmtime runtime with upload untrusted WASM agents that control spawned agents in a tick-based
fuel and memory limits) that control spawned agents in a tick-based simulation. Agent code runs in a sandboxed Extism/Wasmtime runtime with fuel
simulation. All identity and request authentication is certificate-based and memory limits.
(Ed25519 client certificates signed by a locally issued root CA); there are no
passwords or bearer tokens.
## Building the server Identity is based on Ed25519 keypairs. The server stores public keys; private
keys stay with their owners. HTTPS and WSS termination are intended to be
provided by a reverse proxy such as nginx or Caddy. The Wolloseum process
serves HTTP and does not create TLS certificates.
## Build and Run
```sh ```sh
cargo build --release cargo build --release
```
The binary is produced at `target/release/wolloseum`.
## Configuration
The server takes its configuration from CLI flags (each with an environment
variable fallback), defined in [src/cli.rs](src/cli.rs):
| Flag | Env var | Default | Purpose |
| ------------ | -------------------- | ---------------- | -------------------------------------------------- |
| `--data-dir` | `WOLLOSEUM_DATA_DIR` | `data` | Directory for certificates and the SQLite database |
| `--bind` | `WOLLOSEUM_BIND` | `127.0.0.1:8080` | HTTP listener address |
On first start the server bootstraps a PKI under `<data-dir>/certificates/`:
- `root-ca.pem` / `root-ca-key.pem` — self-signed root CA
- `server.pem` / `server-key.pem` — HTTP server certificate (issued for `localhost`)
- `admin-client.pem` / `admin-client-key.pem` — the initial admin client identity
The SQLite database is created at `<data-dir>/wolloseum.sqlite3` and stores
identities, used request nonces, per-player agent WASM, and the core
simulation WASM module. Private key files and the certificate directory are
written with restrictive (owner-only) permissions on Unix.
## Running the server
```sh
cargo run --release -- serve --data-dir data --bind 127.0.0.1:8080 cargo run --release -- serve --data-dir data --bind 127.0.0.1:8080
``` ```
On startup the server prints the admin certificate ID and the paths to the ### Docker
admin client certificate/key and root CA certificate — you'll need these to
issue authenticated CLI/API requests.
## API endpoints Build and start the server with Docker Compose:
All `/api/*` endpoints except `/api/status` and `/api/events` require request
signing. Each signed request must include:
```
X-Client-Id: <sha256 hex of the client's certificate PEM>
X-Request-Timestamp: <unix seconds, within 60s of server time>
X-Request-Nonce: <unique, ASCII alphanumeric/-/_, replay-checked>
X-Request-Signature: <base64 Ed25519 signature>
```
The signed message is `METHOD\nPATH\nTIMESTAMP\nNONCE\n` followed by the raw
request body bytes.
| Method | Path | Auth | Description |
| ------ | ------------------- | ------------------- | ----------------------------------------------------------- |
| GET | `/` | none | Player web UI |
| GET | `/admin` | none | Admin web UI |
| GET | `/api/status` | none | Current game running state |
| GET | `/api/events` | none (WebSocket) | Live game/agent/core state change events |
| POST | `/api/create_user` | admin | Create a player identity, returns certificate + private key |
| POST | `/api/delete_user` | admin | Delete a player identity and their uploaded agent |
| POST | `/api/start_game` | admin | Resume the simulation |
| POST | `/api/stop_game` | admin | Pause the simulation |
| POST | `/api/update_agent` | any active identity | Upload/replace the caller's agent WASM module |
| POST | `/api/upload_core` | admin | Upload/replace the core simulation WASM module |
### `/api/upload_core`
Replaces the core simulation logic module. The request body is the raw WASM
bytes (not JSON), following the same conventions as `/api/update_agent`:
- Body limit: 1 MiB (`Content-Length` over the limit is rejected)
- The module is sandboxed-loaded (no WASI imports, bounded memory) to confirm
it is valid before being persisted
- Requires admin authentication (signed request headers as above)
- On success the module is stored in the `core_module` table (single active
row) and a `core_uploaded` event is published over `/api/events`
```sh ```sh
curl -k http://127.0.0.1:8080/api/upload_core \ docker compose up --build -d
-X POST \ docker compose logs -f wolloseum
--data-binary @core_simulation.wasm \
-H "x-client-id: $ADMIN_ID" \
-H "x-request-timestamp: $TS" \
-H "x-request-nonce: $NONCE" \
-H "x-request-signature: $SIGNATURE"
``` ```
In practice, use the CLI (below) rather than constructing signatures by hand. The HTTP service is published on `127.0.0.1:8080`; configure nginx or Caddy to
proxy HTTP and WebSocket traffic to that address and terminate TLS. Compose
stores the SQLite database and generated administrator keypair in `./data`.
The entrypoint sets the data tree's ownership before starting the server as an
unprivileged user.
## Using the CLI client Set `WOLLOSEUM_UID` and `WOLLOSEUM_GID` in the environment or a Compose `.env`
file to choose the container process identity. Both default to `1000`. The
ownership of the host `./data` tree is changed to these IDs at startup.
The same binary provides a signing HTTP client: The first startup logs the administrator identity ID and the key path inside
the container. Copy the key to a secure host location for admin sign-in:
```sh
docker compose cp wolloseum:/var/lib/wolloseum/keys/admin-private.pem /secure/path/admin-private.pem
chmod 600 /secure/path/admin-private.pem
```
Back up the key and database volume. To stop the server while preserving data,
run `docker compose down`. To remove all persistent identity and game data as
well, run `docker compose down --volumes`.
The server bootstraps an administrator keypair on first start:
- `data/keys/admin-private.pem` is the owner-only Ed25519 PKCS#8 private key.
- The server prints the corresponding identity ID at startup.
- Keep a secure backup of the private key. The server cannot recover it.
Existing installations continue to use `data/certificates/admin-client-key.pem`
and the old certificate fingerprint as the admin identity ID when those legacy
files are present. Existing player identities and uploaded data remain in the
SQLite database. HTTPS certificates, if needed, belong to the reverse proxy.
Configuration flags and environment variables:
| Flag | Environment variable | Default | Purpose |
| ------------ | -------------------- | ---------------- | ------------------------------------------- |
| `--data-dir` | `WOLLOSEUM_DATA_DIR` | `data` | Keypairs and SQLite database directory |
| `--bind` | `WOLLOSEUM_BIND` | `127.0.0.1:8080` | HTTP listener, usually private to the proxy |
## Web Interfaces
- `/` is the player console. Users sign in with their identity key and upload or replace their agent WASM.
- `/admin` is the administrator console. It creates and removes users, pauses or resumes the simulation, and replaces the core WASM module.
- `/viewer` is a public live event viewer. It connects to `/api/events`, tracks event sequence numbers, and restores state from snapshots after reconnecting.
The admin console generates a fresh Ed25519 keypair when creating a player.
The server receives only the public key. The browser downloads a JSON identity
bundle containing the new identity ID and private key; deliver that bundle to
the player through a secure channel. The private key is not stored by the
server.
On the admin page, enter the startup identity ID and select
`data/keys/admin-private.pem`. For an existing installation, select the legacy
admin key and use the identity ID printed by the server.
## Browser Authentication
1. The browser requests `POST /api/auth/challenge` with `{ "client_id": "..." }`.
2. The server returns a random, short-lived challenge.
3. The browser signs the decoded challenge bytes using Ed25519 and posts the identity ID, challenge, and base64 signature to `POST /api/auth/login`.
4. The server verifies the signature against the stored public key and returns an opaque session token.
5. The browser stores the token and sends it as `Authorization: Bearer <token>` on protected API requests.
Challenges expire after five minutes and are single-use. Session tokens expire
seven days after the last authenticated API interaction. Each authenticated
request extends the expiry. Only a SHA-256 hash of each token is persisted.
Revoking an identity by deleting it also removes its sessions.
## API
| Method | Path | Access | Purpose |
| ------ | --------------------- | ---------------- | ------------------------------------------------- |
| `POST` | `/api/auth/challenge` | Public | Request an identity challenge |
| `POST` | `/api/auth/login` | Public | Verify a signed challenge and issue a token |
| `GET` | `/api/me` | Session | Read the signed-in identity and agent status |
| `GET` | `/api/users` | Admin session | List identities and agent digests |
| `GET` | `/api/status` | Public | Read the simulation running state |
| `GET` | `/api/events` | Public WebSocket | Receive snapshots and ordered state events |
| `POST` | `/api/create_user` | Admin session | Register a username and base64 Ed25519 public key |
| `POST` | `/api/delete_user` | Admin session | Delete a player and its uploaded agent |
| `POST` | `/api/start_game` | Admin session | Resume the simulation |
| `POST` | `/api/stop_game` | Admin session | Pause the simulation |
| `POST` | `/api/update_agent` | User session | Upload or replace the caller's agent WASM |
| `POST` | `/api/upload_core` | Admin session | Upload or replace the core simulation WASM |
Agent and core WASM uploads are limited to 1 MiB and validated in the
sandboxed runtime before they are persisted. Existing signed request headers
remain accepted for compatibility with older clients.
## CLI
The binary also provides an authenticated client. New installations use the
admin keypair directly:
```sh ```sh
cargo run --release -- cli \ cargo run --release -- cli \
--server http://127.0.0.1:84080\ --client-key data/keys/admin-private.pem \
--root-ca data/certificates/root-ca.pem \ create-user alice
--client-cert data/certificates/admin-client.pem \
--client-key data/certificates/admin-client-key.pem \
status
``` ```
Available subcommands (see [src/cli.rs](src/cli.rs)): `status`, `create-user` prints the identity ID and private key as JSON. For commands run
`create-user <username>`, `delete-user <client_id>`, `stop-game`, as a player, use that player's private key and identity ID:
`start-game`, `update-agent <path>`.
## Building the example WASM agent ```sh
cargo run --release -- cli \
--client-id <identity-id> \
--client-key alice-private.pem \
update-agent example-wasm-agent/target/wasm32-unknown-unknown/release/example_wasm_agent.wasm
```
[example-wasm-agent/](example-wasm-agent) is a standalone crate implementing Legacy installations can pass `--client-cert` and `--client-key` to derive the
a minimal Extism plugin agent matching the ABI expected by historical certificate-based admin ID. The CLI exchanges a challenge for a
[src/wasm_agent.rs](src/wasm_agent.rs) (a JSON `decide` call taking session token before making protected requests.
`{abi_version, tick, x, y, adjacent_terrain}` and returning
`{abi_version, action}`). Movement is a deterministic function of the tick
and position — no RNG, clock, or host access is used, so replays are
reproducible.
Build it to a `.wasm` file: ## Example Agent
`example-wasm-agent/` is a standalone crate implementing the Extism plugin ABI
used by `src/wasm_agent.rs`. Build it with:
```sh ```sh
cd example-wasm-agent cd example-wasm-agent
rustup target add wasm32-unknown-unknown # if not already installed rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown cargo build --release --target wasm32-unknown-unknown
``` ```
The compiled module is at The compiled module is at
`example-wasm-agent/target/wasm32-unknown-unknown/release/example_wasm_agent.wasm`. `example-wasm-agent/target/wasm32-unknown-unknown/release/example_wasm_agent.wasm`.
## Uploading the example agent
First create a player identity (as admin), then upload the agent WASM signed
with that player's own certificate:
```sh
# As admin, create a player and save the returned certificate/private key:
cargo run --release -- cli \
--client-cert data/certificates/admin-client.pem \
--client-key data/certificates/admin-client-key.pem \
create-user alice
# -> write the returned certificate_pem/private_key_pem to alice.pem / alice-key.pem
# As alice, upload the compiled agent:
cargo run --release -- cli \
--client-cert alice.pem \
--client-key alice-key.pem \
update-agent example-wasm-agent/target/wasm32-unknown-unknown/release/example_wasm_agent.wasm
```
Core simulation WASM can be uploaded the same way by an admin using
`POST /api/upload_core` (no dedicated CLI subcommand yet — use `curl` as shown
above, or extend the CLI client following the same pattern as `update-agent`).

15
compose.yaml Normal file
View File

@ -0,0 +1,15 @@
services:
wolloseum:
build: .
image: wolloseum:local
container_name: wolloseum
restart: unless-stopped
environment:
WOLLOSEUM_BIND: 0.0.0.0:8080
WOLLOSEUM_DATA_DIR: /var/lib/wolloseum
WOLLOSEUM_UID: "${WOLLOSEUM_UID:-1000}"
WOLLOSEUM_GID: "${WOLLOSEUM_GID:-1000}"
ports:
- "8080:8080"
volumes:
- ./data:/var/lib/wolloseum

17
docker-entrypoint.sh Normal file
View File

@ -0,0 +1,17 @@
#!/bin/sh
set -eu
uid=${WOLLOSEUM_UID:-10001}
gid=${WOLLOSEUM_GID:-10001}
data_dir=${WOLLOSEUM_DATA_DIR:-/var/lib/wolloseum}
case "$uid" in
''|*[!0-9]*) echo "WOLLOSEUM_UID must be a numeric user ID" >&2; exit 1 ;;
esac
case "$gid" in
''|*[!0-9]*) echo "WOLLOSEUM_GID must be a numeric group ID" >&2; exit 1 ;;
esac
mkdir -p "$data_dir"
chown -R "$uid:$gid" "$data_dir"
exec setpriv --reuid="$uid" --regid="$gid" --clear-groups "$@"

View File

@ -2,15 +2,13 @@ use std::error::Error;
use std::fs; use std::fs;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use base64::Engine as _; use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64; use base64::engine::general_purpose::STANDARD as BASE64;
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
use rcgen::KeyPair; use rcgen::{KeyPair, PKCS_ED25519};
use reqwest::{Client, Method}; use reqwest::{Client, Method};
use ring::signature::Ed25519KeyPair; use ring::signature::Ed25519KeyPair;
use serde::Serialize;
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command( #[command(
@ -33,10 +31,10 @@ enum TopLevelCommand {
#[derive(Debug, Args)] #[derive(Debug, Args)]
struct ServeArguments { struct ServeArguments {
/// Directory for certificates and the SQLite database. /// Directory for keypairs and the SQLite database.
#[arg(long, env = "WOLLOSEUM_DATA_DIR", default_value = "data")] #[arg(long, env = "WOLLOSEUM_DATA_DIR", default_value = "data")]
data_dir: PathBuf, data_dir: PathBuf,
/// HTTP listener address. /// HTTP listener address, usually behind a TLS reverse proxy.
#[arg(long, env = "WOLLOSEUM_BIND", default_value = "127.0.0.1:8080")] #[arg(long, env = "WOLLOSEUM_BIND", default_value = "127.0.0.1:8080")]
bind: SocketAddr, bind: SocketAddr,
} }
@ -51,20 +49,18 @@ struct ClientArguments {
default_value = "http://127.0.0.1:8080" default_value = "http://127.0.0.1:8080"
)] )]
server: String, server: String,
/// Client certificate whose PEM fingerprint is the identity ID. /// Identity ID for legacy certificate-fingerprint identities.
#[arg( #[arg(long, global = true, env = "WOLLOSEUM_CLIENT_ID")]
long, client_id: Option<String>,
global = true, /// Optional legacy certificate used to derive its historical identity ID.
env = "WOLLOSEUM_CLIENT_CERT", #[arg(long, global = true, env = "WOLLOSEUM_CLIENT_CERT")]
default_value = "data/certificates/admin-client.pem" client_cert: Option<PathBuf>,
)] /// Ed25519 private key for the selected identity.
client_cert: PathBuf,
/// Private key for the selected client certificate.
#[arg( #[arg(
long, long,
global = true, global = true,
env = "WOLLOSEUM_CLIENT_KEY", env = "WOLLOSEUM_CLIENT_KEY",
default_value = "data/certificates/admin-client-key.pem" default_value = "data/keys/admin-private.pem"
)] )]
client_key: PathBuf, client_key: PathBuf,
#[command(subcommand)] #[command(subcommand)]
@ -75,9 +71,9 @@ struct ClientArguments {
enum ClientCommand { enum ClientCommand {
/// Read the current game status. /// Read the current game status.
Status, Status,
/// Create a user and print their certificate and private key as JSON. /// Create a user and print its identity bundle as JSON.
CreateUser { username: String }, CreateUser { username: String },
/// Delete a player by certificate ID. /// Delete a player by identity ID.
DeleteUser { client_id: String }, DeleteUser { client_id: String },
/// Pause the game. /// Pause the game.
StopGame, StopGame,
@ -97,20 +93,25 @@ pub async fn run(arguments: Arguments) -> Result<(), Box<dyn Error>> {
} }
async fn run_client(arguments: ClientArguments) -> Result<(), Box<dyn Error>> { async fn run_client(arguments: ClientArguments) -> Result<(), Box<dyn Error>> {
let _ = rustls::crypto::ring::default_provider().install_default();
let client = Client::builder().build()?; let client = Client::builder().build()?;
let base_url = arguments.server.trim_end_matches('/');
let (method, path, body, needs_signature) = match arguments.command { let mut created_identity = None;
let (method, path, body, requires_auth) = match arguments.command {
ClientCommand::Status => (Method::GET, "/api/status", Vec::new(), false), ClientCommand::Status => (Method::GET, "/api/status", Vec::new(), false),
ClientCommand::CreateUser { username } => ( ClientCommand::CreateUser { username } => {
Method::POST, let key = KeyPair::generate_for(&PKCS_ED25519)?;
"/api/create_user", let body = serde_json::to_vec(&serde_json::json!({
serde_json::to_vec(&CreateUserBody { username })?, "username": username,
true, "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 } => ( ClientCommand::DeleteUser { client_id } => (
Method::POST, Method::POST,
"/api/delete_user", "/api/delete_user",
serde_json::to_vec(&DeleteUserBody { client_id })?, serde_json::to_vec(&serde_json::json!({ "client_id": client_id }))?,
true, true,
), ),
ClientCommand::StopGame => (Method::POST, "/api/stop_game", Vec::new(), true), ClientCommand::StopGame => (Method::POST, "/api/stop_game", Vec::new(), true),
@ -120,65 +121,87 @@ async fn run_client(arguments: ClientArguments) -> Result<(), Box<dyn Error>> {
} }
}; };
let url = format!("{}{}", arguments.server.trim_end_matches('/'), path); let mut request = client
let mut request = client.request(method.clone(), url).body(body.clone()); .request(method, format!("{base_url}{path}"))
if needs_signature { .body(body);
let certificate_pem = fs::read_to_string(&arguments.client_cert)?; if requires_auth {
let key = KeyPair::from_pem(&fs::read_to_string(&arguments.client_key)?)?; let private_key = KeyPair::from_pem(&fs::read_to_string(&arguments.client_key)?)?;
let signer = Ed25519KeyPair::from_pkcs8(&key.serialize_der()) let signer = Ed25519KeyPair::from_pkcs8(&private_key.serialize_der())
.map_err(|_| "client private key is not a valid Ed25519 PKCS#8 key")?; .map_err(|_| "client private key is not a valid Ed25519 PKCS#8 key")?;
let client_id = certificate_id(&certificate_pem); let client_id = if let Some(client_id) = arguments.client_id {
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); client_id
let nonce = uuid::Uuid::new_v4().to_string(); } else if let Some(certificate_path) = arguments.client_cert {
let message = signature_message(method.as_str(), path, timestamp, &nonce, &body); certificate_id(&fs::read_to_string(certificate_path)?)
let signature = BASE64.encode(signer.sign(&message).as_ref()); } else {
request = request public_key_id(private_key.public_key_raw())
.header("x-client-id", client_id) };
.header("x-request-timestamp", timestamp.to_string()) let challenge_response_body = client
.header("x-request-nonce", nonce) .post(format!("{base_url}/api/auth/challenge"))
.header("x-request-signature", signature); .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 response = request.send().await?;
let status = response.status(); let status = response.status();
let body = response.bytes().await?; let response_body = response.bytes().await?;
if !status.is_success() { if !status.is_success() {
return Err(format!( return Err(format!(
"server returned {status}: {}", "server returned {status}: {}",
String::from_utf8_lossy(&body) String::from_utf8_lossy(&response_body)
) )
.into()); .into());
} }
if body.is_empty() { if response_body.is_empty() {
println!("HTTP {status}"); println!("HTTP {status}");
} else if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&body) { } 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)?); println!("{}", serde_json::to_string_pretty(&json)?);
} else { } else {
println!("{}", String::from_utf8_lossy(&body)); println!("{}", String::from_utf8_lossy(&response_body));
} }
Ok(()) Ok(())
} }
#[derive(Serialize)] fn public_key_id(public_key: &[u8]) -> String {
struct CreateUserBody { ring::digest::digest(&ring::digest::SHA256, public_key)
username: String, .as_ref()
} .iter()
.map(|byte| format!("{byte:02x}"))
#[derive(Serialize)] .collect()
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 { fn certificate_id(certificate_pem: &str) -> String {
@ -202,7 +225,7 @@ mod tests {
} }
#[test] #[test]
fn cli_can_select_user_credentials_for_upload() { fn cli_can_select_legacy_or_keypair_credentials_for_upload() {
assert!( assert!(
Arguments::try_parse_from([ Arguments::try_parse_from([
"wolloseum", "wolloseum",
@ -216,5 +239,18 @@ mod tests {
]) ])
.is_ok() .is_ok()
); );
assert!(
Arguments::try_parse_from([
"wolloseum",
"cli",
"--client-id",
"identity-id",
"--client-key",
"player-key.pem",
"update-agent",
"agent.wasm",
])
.is_ok()
);
} }
} }

View File

@ -1,7 +1,7 @@
use std::error::Error; use std::error::Error;
use std::fs::{self, OpenOptions}; use std::fs::{self, OpenOptions};
use std::io::Write; use std::io::Write;
use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -14,13 +14,10 @@ use axum::http::{HeaderMap, Method, StatusCode};
use axum::response::{Html, IntoResponse, Response}; use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use axum_server::tls_rustls::RustlsConfig;
use base64::Engine as _; use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64; use base64::engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD};
use rcgen::{ use rcgen::{KeyPair, PKCS_ED25519};
BasicConstraints, Certificate, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, use ring::rand::{SecureRandom, SystemRandom};
Issuer, KeyPair, KeyUsagePurpose, PKCS_ED25519, SanType,
};
use ring::signature::{ED25519, UnparsedPublicKey}; use ring::signature::{ED25519, UnparsedPublicKey};
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -32,12 +29,176 @@ use crate::wasm_agent::WasmAgent;
const MAX_WASM_BYTES: usize = 1_048_576; const MAX_WASM_BYTES: usize = 1_048_576;
const MAX_REQUEST_SKEW_SECONDS: u64 = 60; const MAX_REQUEST_SKEW_SECONDS: u64 = 60;
const REQUEST_NONCE_MAX_BYTES: usize = 128; const REQUEST_NONCE_MAX_BYTES: usize = 128;
const SESSION_LIFETIME_SECONDS: u64 = 7 * 24 * 60 * 60;
const CHALLENGE_LIFETIME_SECONDS: u64 = 5 * 60;
async fn auth_challenge(
State(state): State<AppState>,
Json(request): Json<AuthChallengeRequest>,
) -> Result<Json<AuthChallengeResponse>, ApiError> {
let now = unix_time();
let database = state.database.lock().map_err(ApiError::internal)?;
let active = database
.query_row(
"SELECT active FROM identities WHERE id = ?1",
[&request.client_id],
|row| row.get::<_, bool>(0),
)
.optional()
.map_err(ApiError::internal)?
.unwrap_or(false);
if !active {
return Err(ApiError::unauthorized());
}
let mut random_bytes = [0u8; 32];
SystemRandom::new()
.fill(&mut random_bytes)
.map_err(|_| ApiError::internal("secure random generation failed"))?;
let challenge = BASE64.encode(random_bytes);
database
.execute(
"DELETE FROM auth_challenges WHERE expires_at < ?1",
[now as i64],
)
.map_err(ApiError::internal)?;
database
.execute(
"INSERT INTO auth_challenges (identity_id, challenge_hash, expires_at) VALUES (?1, ?2, ?3)",
params![request.client_id, digest_hex(&random_bytes), now.saturating_add(CHALLENGE_LIFETIME_SECONDS) as i64],
)
.map_err(ApiError::internal)?;
Ok(Json(AuthChallengeResponse { challenge }))
}
async fn auth_login(
State(state): State<AppState>,
Json(request): Json<AuthLoginRequest>,
) -> Result<Json<AuthLoginResponse>, ApiError> {
let challenge = BASE64
.decode(request.challenge)
.map_err(|_| ApiError::unauthorized())?;
let signature = BASE64
.decode(request.signature)
.map_err(|_| ApiError::unauthorized())?;
let challenge_hash = digest_hex(&challenge);
let now = unix_time();
let mut database = state.database.lock().map_err(ApiError::internal)?;
let transaction = database.transaction().map_err(ApiError::internal)?;
let identity = transaction
.query_row(
"SELECT identities.public_key, identities.active, auth_challenges.expires_at
FROM identities JOIN auth_challenges ON identities.id = auth_challenges.identity_id
WHERE identities.id = ?1 AND auth_challenges.challenge_hash = ?2",
params![request.client_id, challenge_hash],
|row| {
Ok((
row.get::<_, Vec<u8>>(0)?,
row.get::<_, bool>(1)?,
row.get::<_, i64>(2)?,
))
},
)
.optional()
.map_err(ApiError::internal)?
.ok_or_else(ApiError::unauthorized)?;
if !identity.1
|| identity.2 <= now as i64
|| !verify_signature(&identity.0, &challenge, &signature)
{
return Err(ApiError::unauthorized());
}
let deleted = transaction
.execute(
"DELETE FROM auth_challenges WHERE identity_id = ?1 AND challenge_hash = ?2 AND expires_at > ?3",
params![request.client_id, challenge_hash, now as i64],
)
.map_err(ApiError::internal)?;
if deleted != 1 {
return Err(ApiError::unauthorized());
}
let mut token_bytes = [0u8; 32];
SystemRandom::new()
.fill(&mut token_bytes)
.map_err(|_| ApiError::internal("secure random generation failed"))?;
let token = URL_SAFE_NO_PAD.encode(token_bytes);
let expires_at = now.saturating_add(SESSION_LIFETIME_SECONDS);
transaction
.execute(
"INSERT INTO sessions (token_hash, identity_id, expires_at, last_used) VALUES (?1, ?2, ?3, ?4)",
params![digest_hex(token.as_bytes()), request.client_id, expires_at as i64, now as i64],
)
.map_err(ApiError::internal)?;
transaction.commit().map_err(ApiError::internal)?;
Ok(Json(AuthLoginResponse { token, expires_at }))
}
async fn current_identity(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<IdentityInfo>, ApiError> {
let token = bearer_token(&headers)?;
let client_id = authenticate_session(&state, token, false)?;
let database = state.database.lock().map_err(ApiError::internal)?;
let identity = database
.query_row(
"SELECT identities.username, identities.is_admin, EXISTS(SELECT 1 FROM agents WHERE agents.identity_id = identities.id), (SELECT sha256 FROM agents WHERE agents.identity_id = identities.id) FROM identities WHERE identities.id = ?1",
[&client_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, bool>(1)?,
row.get::<_, bool>(2)?,
row.get::<_, Option<String>>(3)?,
))
},
)
.map_err(ApiError::internal)?;
Ok(Json(IdentityInfo {
client_id,
username: identity.0,
is_admin: identity.1,
has_agent: identity.2,
agent_sha256: identity.3,
}))
}
async fn list_users(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Vec<IdentityInfo>>, ApiError> {
let token = bearer_token(&headers)?;
authenticate_session(&state, token, true)?;
let database = state.database.lock().map_err(ApiError::internal)?;
let mut statement = database
.prepare("SELECT identities.id, identities.username, identities.is_admin, EXISTS(SELECT 1 FROM agents WHERE agents.identity_id = identities.id), (SELECT sha256 FROM agents WHERE agents.identity_id = identities.id) FROM identities ORDER BY identities.is_admin DESC, identities.username COLLATE NOCASE")
.map_err(ApiError::internal)?;
let users = statement
.query_map([], |row| {
Ok(IdentityInfo {
client_id: row.get(0)?,
username: row.get(1)?,
is_admin: row.get(2)?,
has_agent: row.get(3)?,
agent_sha256: row.get(4)?,
})
})
.map_err(ApiError::internal)?
.collect::<Result<Vec<_>, _>>()
.map_err(ApiError::internal)?;
Ok(Json(users))
}
fn bearer_token(headers: &HeaderMap) -> Result<&str, ApiError> {
headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or_else(ApiError::unauthorized)
}
#[derive(Clone)] #[derive(Clone)]
struct AppState { struct AppState {
database: Arc<Mutex<Connection>>, database: Arc<Mutex<Connection>>,
root_certificate_pem: Arc<String>,
root_key_pem: Arc<String>,
game_running: Arc<AtomicBool>, game_running: Arc<AtomicBool>,
event_sender: broadcast::Sender<StateEvent>, event_sender: broadcast::Sender<StateEvent>,
event_sequence: Arc<Mutex<u64>>, event_sequence: Arc<Mutex<u64>>,
@ -51,141 +212,64 @@ struct StateEvent {
} }
struct Pki { struct Pki {
root_certificate_pem: String,
root_key_pem: String,
admin_id: String, admin_id: String,
admin_public_key: Vec<u8>, admin_public_key: Vec<u8>,
admin_certificate_pem: String, admin_key_path: PathBuf,
} }
impl Pki { impl Pki {
fn initialize(directory: &Path) -> Result<Self, Box<dyn Error>> { fn initialize(data_directory: &Path) -> Result<Self, Box<dyn Error>> {
fs::create_dir_all(directory)?; let legacy_directory = data_directory.join("certificates");
set_private_directory_permissions(directory)?; let legacy_certificate_path = legacy_directory.join("admin-client.pem");
let legacy_key_path = legacy_directory.join("admin-client-key.pem");
let key_directory = data_directory.join("keys");
fs::create_dir_all(&key_directory)?;
set_private_directory_permissions(&key_directory)?;
let key_path = key_directory.join("admin-private.pem");
let root_certificate_path = directory.join("root-ca.pem"); let (admin_key, admin_id, admin_key_path) =
let root_key_path = directory.join("root-ca-key.pem"); if legacy_certificate_path.exists() && legacy_key_path.exists() {
let admin_certificate_path = directory.join("admin-client.pem"); let certificate = fs::read_to_string(&legacy_certificate_path)?;
let admin_key_path = directory.join("admin-client-key.pem"); let key = KeyPair::from_pem(&fs::read_to_string(legacy_key_path)?)?;
let server_certificate_path = directory.join("server.pem"); (
let server_key_path = directory.join("server-key.pem"); key,
certificate_id(&certificate),
let root_certificate_exists = root_certificate_path.exists(); legacy_directory.join("admin-client-key.pem"),
let root_key_exists = root_key_path.exists(); )
if root_certificate_exists != root_key_exists { } else if key_path.exists() {
return Err("root CA certificate/key pair is incomplete".into()); let key = KeyPair::from_pem(&fs::read_to_string(&key_path)?)?;
} let id = public_key_id(key.public_key_raw());
(key, id, key_path.clone())
if !root_certificate_exists { } else {
if admin_certificate_path.exists() let key = KeyPair::generate_for(&PKCS_ED25519)?;
|| admin_key_path.exists() let id = public_key_id(key.public_key_raw());
|| server_certificate_path.exists() write_new_file(&key_path, key.serialize_pem().as_bytes(), true)?;
|| server_key_path.exists() (key, id, key_path.clone())
{ };
return Err(
"certificate files exist without a root CA; refusing partial bootstrap".into(),
);
}
let root_key = KeyPair::generate_for(&PKCS_ED25519)?;
let mut root_params = CertificateParams::default();
root_params
.distinguished_name
.push(DnType::CommonName, "Wolloseum Root CA");
root_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
root_params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::CrlSign,
];
let root_certificate = root_params.self_signed(&root_key)?;
let root_certificate_pem = root_certificate.pem();
let root_key_pem = root_key.serialize_pem();
write_new_file(
&root_certificate_path,
root_certificate_pem.as_bytes(),
false,
)?;
write_new_file(&root_key_path, root_key_pem.as_bytes(), true)?;
let issuer =
Issuer::from_ca_cert_pem(&root_certificate_pem, KeyPair::from_pem(&root_key_pem)?)?;
let (admin_certificate, admin_key) =
issue_client_certificate("Wolloseum Admin", &issuer)?;
write_new_file(
&admin_certificate_path,
admin_certificate.pem().as_bytes(),
false,
)?;
write_new_file(&admin_key_path, admin_key.serialize_pem().as_bytes(), true)?;
} else if !admin_certificate_path.exists() || !admin_key_path.exists() {
return Err(
"admin certificate/key is missing; refusing to generate a replacement".into(),
);
}
let root_certificate_pem = fs::read_to_string(&root_certificate_path)?;
let root_key_pem = fs::read_to_string(&root_key_path)?;
let issuer =
Issuer::from_ca_cert_pem(&root_certificate_pem, KeyPair::from_pem(&root_key_pem)?)?;
let server_certificate_exists = server_certificate_path.exists();
let server_key_exists = server_key_path.exists();
if server_certificate_exists != server_key_exists {
return Err("HTTPS server certificate/key pair is incomplete".into());
}
if !server_certificate_exists {
let (certificate, key) = issue_server_certificate(&issuer)?;
write_new_file(
&server_certificate_path,
certificate.pem().as_bytes(),
false,
)?;
write_new_file(&server_key_path, key.serialize_pem().as_bytes(), true)?;
}
let admin_certificate_pem = fs::read_to_string(&admin_certificate_path)?;
let admin_key = KeyPair::from_pem(&fs::read_to_string(&admin_key_path)?)?;
let admin_id = certificate_id(&admin_certificate_pem);
Ok(Self { Ok(Self {
root_certificate_pem,
root_key_pem,
admin_id, admin_id,
admin_public_key: admin_key.public_key_raw().to_vec(), admin_public_key: admin_key.public_key_raw().to_vec(),
admin_certificate_pem, admin_key_path,
}) })
} }
} }
pub async fn run(data_directory: PathBuf, address: SocketAddr) -> Result<(), Box<dyn Error>> { pub async fn run(data_directory: PathBuf, address: SocketAddr) -> Result<(), Box<dyn Error>> {
let certificate_directory = data_directory.join("certificates"); let pki = Pki::initialize(&data_directory)?;
let pki = Pki::initialize(&certificate_directory)?;
let database_path = data_directory.join("wolloseum.sqlite3"); let database_path = data_directory.join("wolloseum.sqlite3");
let database = initialize_database( let database = initialize_database(&database_path, &pki.admin_id, &pki.admin_public_key)?;
&database_path, let admin_id: String = database.query_row(
&pki.admin_id, "SELECT id FROM identities WHERE username = 'admin'",
&pki.admin_public_key, [],
&pki.admin_certificate_pem, |row| row.get(0),
)?; )?;
eprintln!("Admin certificate ID: {}", pki.admin_id); eprintln!("Admin identity ID: {}", admin_id);
eprintln!( eprintln!("Admin private key: {}", pki.admin_key_path.display());
"Admin client certificate: {}",
certificate_directory.join("admin-client.pem").display()
);
eprintln!(
"Admin private key: {}",
certificate_directory.join("admin-client-key.pem").display()
);
eprintln!(
"Root CA certificate: {}",
certificate_directory.join("root-ca.pem").display()
);
let state = AppState { let state = AppState {
database: Arc::new(Mutex::new(database)), database: Arc::new(Mutex::new(database)),
root_certificate_pem: Arc::new(pki.root_certificate_pem),
root_key_pem: Arc::new(pki.root_key_pem),
game_running: Arc::new(AtomicBool::new(true)), game_running: Arc::new(AtomicBool::new(true)),
event_sender: broadcast::channel(256).0, event_sender: broadcast::channel(256).0,
event_sequence: Arc::new(Mutex::new(0)), event_sequence: Arc::new(Mutex::new(0)),
@ -193,6 +277,13 @@ pub async fn run(data_directory: PathBuf, address: SocketAddr) -> Result<(), Box
let app = Router::new() let app = Router::new()
.route("/", get(user_ui)) .route("/", get(user_ui))
.route("/admin", get(admin_ui)) .route("/admin", get(admin_ui))
.route("/viewer", get(viewer_ui))
.route("/assets/app.css", get(app_css))
.route("/assets/app.js", get(app_js))
.route("/api/auth/challenge", post(auth_challenge))
.route("/api/auth/login", post(auth_login))
.route("/api/me", get(current_identity))
.route("/api/users", get(list_users))
.route("/api/status", get(game_status)) .route("/api/status", get(game_status))
.route("/api/events", get(websocket_events)) .route("/api/events", get(websocket_events))
.route("/api/create_user", post(create_user)) .route("/api/create_user", post(create_user))
@ -205,9 +296,8 @@ pub async fn run(data_directory: PathBuf, address: SocketAddr) -> Result<(), Box
.with_state(state); .with_state(state);
eprintln!("Wolloseum HTTP listening on http://{address}"); eprintln!("Wolloseum HTTP listening on http://{address}");
axum_server::bind(address) let listener = tokio::net::TcpListener::bind(address).await?;
.serve(app.into_make_service()) axum::serve(listener, app).await?;
.await?;
Ok(()) Ok(())
} }
@ -215,7 +305,6 @@ fn initialize_database(
path: &Path, path: &Path,
admin_id: &str, admin_id: &str,
admin_public_key: &[u8], admin_public_key: &[u8],
admin_certificate_pem: &str,
) -> Result<Connection, Box<dyn Error>> { ) -> Result<Connection, Box<dyn Error>> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
@ -250,14 +339,28 @@ fn initialize_database(
wasm BLOB NOT NULL, wasm BLOB NOT NULL,
sha256 TEXT NOT NULL, sha256 TEXT NOT NULL,
uploaded_at INTEGER NOT NULL uploaded_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_challenges (
identity_id TEXT NOT NULL REFERENCES identities(id) ON DELETE CASCADE,
challenge_hash TEXT NOT NULL,
expires_at INTEGER NOT NULL,
PRIMARY KEY (identity_id, challenge_hash)
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
identity_id TEXT NOT NULL REFERENCES identities(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL,
last_used INTEGER NOT NULL
);", );",
)?; )?;
connection.execute( connection.execute(
"INSERT INTO identities (id, username, public_key, certificate_pem, is_admin, active) "INSERT INTO identities (id, username, public_key, certificate_pem, is_admin, active)
VALUES (?1, 'admin', ?2, ?3, 1, 1) VALUES (?1, 'admin', ?2, '', 1, 1)
ON CONFLICT(id) DO UPDATE SET public_key = excluded.public_key, ON CONFLICT(id) DO UPDATE SET public_key = excluded.public_key,
certificate_pem = excluded.certificate_pem, is_admin = 1, active = 1", is_admin = 1, active = 1
params![admin_id, admin_public_key, admin_certificate_pem], ON CONFLICT(username) DO UPDATE SET public_key = excluded.public_key,
is_admin = 1, active = 1",
params![admin_id, admin_public_key],
)?; )?;
Ok(connection) Ok(connection)
} }
@ -273,14 +376,14 @@ impl ApiError {
fn unauthorized() -> Self { fn unauthorized() -> Self {
Self( Self(
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
"invalid or missing request signature".to_string(), "invalid or missing authentication".to_string(),
) )
} }
fn forbidden() -> Self { fn forbidden() -> Self {
Self( Self(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"admin certificate required".to_string(), "administrator identity required".to_string(),
) )
} }
@ -298,13 +401,45 @@ impl IntoResponse for ApiError {
#[derive(Serialize)] #[derive(Serialize)]
struct ApiIdentity { struct ApiIdentity {
client_id: String, client_id: String,
certificate_pem: String, username: String,
private_key_pem: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct CreateUserRequest { struct CreateUserRequest {
username: String, username: String,
public_key: String,
}
#[derive(Deserialize)]
struct AuthChallengeRequest {
client_id: String,
}
#[derive(Serialize)]
struct AuthChallengeResponse {
challenge: String,
}
#[derive(Clone, Deserialize)]
struct AuthLoginRequest {
client_id: String,
challenge: String,
signature: String,
}
#[derive(Serialize)]
struct AuthLoginResponse {
token: String,
expires_at: u64,
}
#[derive(Serialize)]
struct IdentityInfo {
client_id: String,
username: String,
is_admin: bool,
has_agent: bool,
agent_sha256: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -325,6 +460,14 @@ fn authenticate(
body: &[u8], body: &[u8],
require_admin: bool, require_admin: bool,
) -> Result<String, ApiError> { ) -> Result<String, ApiError> {
if let Some(value) = headers.get(axum::http::header::AUTHORIZATION) {
let authorization = value.to_str().map_err(|_| ApiError::unauthorized())?;
let token = authorization
.strip_prefix("Bearer ")
.ok_or_else(ApiError::unauthorized)?;
return authenticate_session(state, token, require_admin);
}
let client_id = header(headers, "x-client-id")?; let client_id = header(headers, "x-client-id")?;
let timestamp = header(headers, "x-request-timestamp")? let timestamp = header(headers, "x-request-timestamp")?
.parse::<u64>() .parse::<u64>()
@ -392,6 +535,57 @@ fn authenticate(
Ok(client_id.to_string()) Ok(client_id.to_string())
} }
fn authenticate_session(
state: &AppState,
token: &str,
require_admin: bool,
) -> Result<String, ApiError> {
let token_hash = digest_hex(token.as_bytes());
let now = unix_time();
let mut database = state.database.lock().map_err(ApiError::internal)?;
let transaction = database.transaction().map_err(ApiError::internal)?;
let identity = transaction
.query_row(
"SELECT identities.id, identities.is_admin, identities.active, sessions.expires_at
FROM sessions JOIN identities ON identities.id = sessions.identity_id
WHERE sessions.token_hash = ?1",
[&token_hash],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, bool>(1)?,
row.get::<_, bool>(2)?,
row.get::<_, i64>(3)?,
))
},
)
.optional()
.map_err(ApiError::internal)?
.ok_or_else(ApiError::unauthorized)?;
if !identity.2 || identity.3 <= now as i64 {
transaction
.execute("DELETE FROM sessions WHERE token_hash = ?1", [&token_hash])
.map_err(ApiError::internal)?;
transaction.commit().map_err(ApiError::internal)?;
return Err(ApiError::unauthorized());
}
if require_admin && !identity.1 {
return Err(ApiError::forbidden());
}
transaction
.execute(
"UPDATE sessions SET expires_at = ?1, last_used = ?2 WHERE token_hash = ?3",
params![
now.saturating_add(SESSION_LIFETIME_SECONDS) as i64,
now as i64,
token_hash
],
)
.map_err(ApiError::internal)?;
transaction.commit().map_err(ApiError::internal)?;
Ok(identity.0)
}
fn header<'a>(headers: &'a HeaderMap, name: &'static str) -> Result<&'a str, ApiError> { fn header<'a>(headers: &'a HeaderMap, name: &'static str) -> Result<&'a str, ApiError> {
headers headers
.get(name) .get(name)
@ -439,21 +633,21 @@ async fn create_user(
)); ));
} }
let root_key = KeyPair::from_pem(&state.root_key_pem).map_err(ApiError::internal)?; let public_key = BASE64
let issuer = Issuer::from_ca_cert_pem(&state.root_certificate_pem, root_key) .decode(request.public_key)
.map_err(ApiError::internal)?; .map_err(|_| ApiError::bad_request("public_key must be base64-encoded Ed25519 bytes"))?;
let (certificate, key) = if public_key.len() != 32 {
issue_client_certificate(username, &issuer).map_err(ApiError::internal)?; return Err(ApiError::bad_request(
let certificate_pem = certificate.pem(); "public_key must contain a 32-byte Ed25519 public key",
let client_id = certificate_id(&certificate_pem); ));
let public_key = key.public_key_raw().to_vec(); }
let private_key_pem = key.serialize_pem(); let client_id = public_key_id(&public_key);
let database = state.database.lock().map_err(ApiError::internal)?; let database = state.database.lock().map_err(ApiError::internal)?;
database database
.execute( .execute(
"INSERT INTO identities (id, username, public_key, certificate_pem) VALUES (?1, ?2, ?3, ?4)", "INSERT INTO identities (id, username, public_key, certificate_pem) VALUES (?1, ?2, ?3, '')",
params![client_id, username, public_key, certificate_pem], params![client_id, username, public_key],
) )
.map_err(|error| { .map_err(|error| {
if error.sqlite_error_code() == Some(rusqlite::ErrorCode::ConstraintViolation) { if error.sqlite_error_code() == Some(rusqlite::ErrorCode::ConstraintViolation) {
@ -466,8 +660,7 @@ async fn create_user(
Ok(Json(ApiIdentity { Ok(Json(ApiIdentity {
client_id, client_id,
certificate_pem, username: username.to_string(),
private_key_pem,
})) }))
} }
@ -756,69 +949,34 @@ fn publish_state_event_locked(
} }
async fn user_ui() -> Html<&'static str> { async fn user_ui() -> Html<&'static str> {
Html(USER_UI) Html(include_str!("../web/user.html"))
} }
async fn admin_ui() -> Html<&'static str> { async fn admin_ui() -> Html<&'static str> {
Html(ADMIN_UI) Html(include_str!("../web/admin.html"))
} }
const USER_UI: &str = r#"<!doctype html> async fn viewer_ui() -> Html<&'static str> {
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> Html(include_str!("../web/viewer.html"))
<title>Wolloseum</title><style>body{font:16px system-ui;max-width:52rem;margin:4rem auto;padding:0 1rem;color:#18252a}h1{font-size:2.5rem}a{color:#087e72}pre{background:#eef4f2;padding:1rem;overflow:auto}</style>
<h1>Wolloseum</h1><p id="status">Connecting to live updates...</p><p><a href="/admin">Administration</a></p>
<h2>Agent upload</h2><p>Authenticate with your issued certificate and sign the exact WASM request body.</p>
<pre>POST /api/update_agent
X-Client-Id, X-Request-Timestamp, X-Request-Nonce, X-Request-Signature</pre>
<h2>Live state changes</h2><ul id="events"></ul>
<script>const status=document.querySelector('#status');const events=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/api/events');events.onmessage=message=>{const event=JSON.parse(message.data);if(event.event==='snapshot'||event.event==='game_state_changed')status.textContent=event.data.running?'Game is running':'Game is paused';const row=document.createElement('li');row.textContent=event.sequence+': '+event.event;document.querySelector('#events').prepend(row)};events.onclose=()=>status.textContent='Live updates disconnected'</script>
</html>"#;
const ADMIN_UI: &str = r#"<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wolloseum Admin</title><style>body{font:16px system-ui;max-width:52rem;margin:4rem auto;padding:0 1rem;color:#18252a}h1{font-size:2rem}a{color:#087e72}code,pre{background:#eef4f2;padding:.2rem .4rem}li{margin:.6rem 0}</style>
<h1>Wolloseum administration</h1><p><a href="/">Player view</a></p><p id="admin-state">Connecting to live updates...</p>
<p>Sign each API request with the admin client key. The signed message is method, path, timestamp, nonce, then the exact body bytes.</p>
<ul><li><code>POST /api/create_user</code></li><li><code>POST /api/delete_user</code></li><li><code>POST /api/stop_game</code></li><li><code>POST /api/start_game</code></li><li><code>POST /api/upload_core</code></li></ul>
<pre>Headers: X-Client-Id, X-Request-Timestamp, X-Request-Nonce, X-Request-Signature</pre>
<h2>Live state changes</h2><ul id="admin-events"></ul>
<script>const adminState=document.querySelector('#admin-state');const adminEvents=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/api/events');adminEvents.onmessage=message=>{const event=JSON.parse(message.data);if(event.event==='snapshot'||event.event==='game_state_changed')adminState.textContent=event.data.running?'Game is running':'Game is paused';const row=document.createElement('li');row.textContent=event.sequence+': '+event.event;document.querySelector('#admin-events').prepend(row)};adminEvents.onclose=()=>adminState.textContent='Live updates disconnected'</script>
</html>"#;
fn issue_client_certificate(
name: &str,
issuer: &Issuer<'_, KeyPair>,
) -> Result<(Certificate, KeyPair), Box<dyn Error>> {
let key = KeyPair::generate_for(&PKCS_ED25519)?;
let mut params = CertificateParams::new(Vec::<String>::new())?;
params.distinguished_name.push(DnType::CommonName, name);
params.use_authority_key_identifier_extension = true;
params.key_usages.push(KeyUsagePurpose::DigitalSignature);
params
.extended_key_usages
.push(ExtendedKeyUsagePurpose::ClientAuth);
let certificate = params.signed_by(&key, issuer)?;
Ok((certificate, key))
} }
fn issue_server_certificate( async fn app_css() -> Response {
issuer: &Issuer<'_, KeyPair>, (
) -> Result<(Certificate, KeyPair), Box<dyn Error>> { [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
let key = KeyPair::generate_for(&PKCS_ED25519)?; include_str!("../web/app.css"),
let mut params = CertificateParams::new(vec!["localhost".to_string()])?; )
params .into_response()
.distinguished_name }
.push(DnType::CommonName, "localhost");
params async fn app_js() -> Response {
.subject_alt_names (
.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::LOCALHOST))); [(
params.use_authority_key_identifier_extension = true; axum::http::header::CONTENT_TYPE,
params.key_usages.push(KeyUsagePurpose::DigitalSignature); "text/javascript; charset=utf-8",
params )],
.extended_key_usages include_str!("../web/app.js"),
.push(ExtendedKeyUsagePurpose::ServerAuth); )
let certificate = params.signed_by(&key, issuer)?; .into_response()
Ok((certificate, key))
} }
fn certificate_id(certificate_pem: &str) -> String { fn certificate_id(certificate_pem: &str) -> String {
@ -830,6 +988,18 @@ fn certificate_id(certificate_pem: &str) -> String {
.collect() .collect()
} }
fn public_key_id(public_key: &[u8]) -> String {
digest_hex(public_key)
}
fn digest_hex(value: &[u8]) -> String {
ring::digest::digest(&ring::digest::SHA256, value)
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn unix_time() -> u64 { fn unix_time() -> u64 {
SystemTime::now() SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@ -889,24 +1059,18 @@ mod tests {
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
fn test_state(directory: &Path) -> (AppState, String, KeyPair) { fn test_state(directory: &Path) -> (AppState, String, KeyPair) {
let cert_directory = directory.join("certificates"); let pki = Pki::initialize(directory).unwrap();
let pki = Pki::initialize(&cert_directory).unwrap();
let admin_id = pki.admin_id.clone(); let admin_id = pki.admin_id.clone();
let admin_key = KeyPair::from_pem( let admin_key =
&fs::read_to_string(cert_directory.join("admin-client-key.pem")).unwrap(), KeyPair::from_pem(&fs::read_to_string(&pki.admin_key_path).unwrap()).unwrap();
)
.unwrap();
let database = super::initialize_database( let database = super::initialize_database(
&directory.join("wolloseum.sqlite3"), &directory.join("wolloseum.sqlite3"),
&pki.admin_id, &pki.admin_id,
&pki.admin_public_key, &pki.admin_public_key,
&pki.admin_certificate_pem,
) )
.unwrap(); .unwrap();
let state = AppState { let state = AppState {
database: Arc::new(Mutex::new(database)), database: Arc::new(Mutex::new(database)),
root_certificate_pem: Arc::new(pki.root_certificate_pem),
root_key_pem: Arc::new(pki.root_key_pem),
game_running: Arc::new(AtomicBool::new(true)), game_running: Arc::new(AtomicBool::new(true)),
event_sender: tokio::sync::broadcast::channel(256).0, event_sender: tokio::sync::broadcast::channel(256).0,
event_sequence: Arc::new(Mutex::new(0)), event_sequence: Arc::new(Mutex::new(0)),
@ -974,6 +1138,105 @@ mod tests {
wat::parse_str(source).unwrap() wat::parse_str(source).unwrap()
} }
#[tokio::test]
async fn challenge_login_consumes_challenge_and_refreshes_hashed_session() {
let directory = tempfile::tempdir().unwrap();
let (state, admin_id, admin_key) = test_state(directory.path());
let Json(challenge) = super::auth_challenge(
State(state.clone()),
Json(super::AuthChallengeRequest {
client_id: admin_id.clone(),
}),
)
.await
.unwrap();
let challenge_bytes = BASE64.decode(&challenge.challenge).unwrap();
let signer = Ed25519KeyPair::from_pkcs8(&admin_key.serialize_der()).unwrap();
let signature = BASE64.encode(signer.sign(&challenge_bytes).as_ref());
let login_request = super::AuthLoginRequest {
client_id: admin_id.clone(),
challenge: challenge.challenge,
signature,
};
let Json(session) = super::auth_login(State(state.clone()), Json(login_request.clone()))
.await
.unwrap();
assert!(session.expires_at >= unix_time() + super::SESSION_LIFETIME_SECONDS - 1);
let replay = super::auth_login(State(state.clone()), Json(login_request)).await;
assert!(replay.is_err());
assert_eq!(
super::authenticate_session(&state, &session.token, true).unwrap(),
admin_id
);
let token_hash = super::digest_hex(session.token.as_bytes());
let refreshed_expiry: i64 = state
.database
.lock()
.unwrap()
.query_row(
"SELECT expires_at FROM sessions WHERE token_hash = ?1",
[&token_hash],
|row| row.get(0),
)
.unwrap();
assert!(refreshed_expiry as u64 >= unix_time() + super::SESSION_LIFETIME_SECONDS - 1);
assert_ne!(token_hash, session.token);
let player_key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
let player_id = super::public_key_id(player_key.public_key_raw());
state
.database
.lock()
.unwrap()
.execute(
"INSERT INTO identities (id, username, public_key, certificate_pem) VALUES (?1, 'player', ?2, '')",
rusqlite::params![player_id, player_key.public_key_raw()],
)
.unwrap();
let Json(player_challenge) = super::auth_challenge(
State(state.clone()),
Json(super::AuthChallengeRequest {
client_id: player_id.clone(),
}),
)
.await
.unwrap();
let player_signer = Ed25519KeyPair::from_pkcs8(&player_key.serialize_der()).unwrap();
let player_signature = BASE64.encode(
player_signer
.sign(&BASE64.decode(&player_challenge.challenge).unwrap())
.as_ref(),
);
let Json(player_session) = super::auth_login(
State(state.clone()),
Json(super::AuthLoginRequest {
client_id: player_id,
challenge: player_challenge.challenge,
signature: player_signature,
}),
)
.await
.unwrap();
let mut player_headers = HeaderMap::new();
player_headers.insert(
"authorization",
HeaderValue::from_str(&format!("Bearer {}", player_session.token)).unwrap(),
);
assert!(
authenticate(
&state,
&player_headers,
&Method::POST,
"/api/stop_game",
b"",
true,
)
.is_err()
);
}
#[test] #[test]
fn signatures_bind_the_exact_request_and_nonce() { fn signatures_bind_the_exact_request_and_nonce() {
let key = KeyPair::generate_for(&PKCS_ED25519).unwrap(); let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
@ -1013,22 +1276,25 @@ mod tests {
let snapshot = super::snapshot_event(&state); let snapshot = super::snapshot_event(&state);
assert_eq!(snapshot.sequence, 0); assert_eq!(snapshot.sequence, 0);
assert_eq!(snapshot.event, "snapshot"); assert_eq!(snapshot.event, "snapshot");
let body = br#"{"username":"player1"}"#; let user_key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
let body = serde_json::to_vec(&serde_json::json!({
"username": "player1",
"public_key": BASE64.encode(user_key.public_key_raw()),
}))
.unwrap();
let headers = signed_headers( let headers = signed_headers(
&admin_id, &admin_id,
&admin_key, &admin_key,
&Method::POST, &Method::POST,
"/api/create_user", "/api/create_user",
body, &body,
"admin-create-1", "admin-create-1",
); );
let Json(identity) = let Json(identity) = create_user(State(state.clone()), headers, Bytes::from(body))
create_user(State(state.clone()), headers, Bytes::copy_from_slice(body)) .await
.await .unwrap();
.unwrap();
assert!(identity.certificate_pem.contains("BEGIN CERTIFICATE")); assert_eq!(identity.username, "player1");
let user_key = KeyPair::from_pem(&identity.private_key_pem).unwrap();
let upload_body = b"signed wasm bytes"; let upload_body = b"signed wasm bytes";
let user_headers = signed_headers( let user_headers = signed_headers(
&identity.client_id, &identity.client_id,

109
web/admin.html Normal file
View File

@ -0,0 +1,109 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wolloseum | Administration</title>
<link rel="stylesheet" href="/assets/app.css">
<script src="/assets/app.js" defer></script>
</head>
<body data-page="admin">
<div class="shell">
<header class="topbar">
<a class="brand" href="/"><span class="brand-mark">W</span> WOLLOSEUM</a>
<nav class="nav"><a href="/">Player</a><a href="/viewer">Live viewer</a><a href="/admin"
aria-current="page">Admin</a></nav>
</header>
<main>
<section id="login-panel" class="panel login-panel">
<div class="accent-band"></div>
<div class="panel-body">
<p class="eyebrow">Restricted console</p>
<h2>Administrator sign in</h2>
<p class="login-note">Authenticate by signing a fresh server challenge with the admin Ed25519 private key.</p>
<form id="login-form" class="form-grid">
<div class="field"><label for="identity-id">Admin identity ID</label><input id="identity-id"
autocomplete="username" spellcheck="false" placeholder="Printed when the server starts" required></div>
<div class="field"><label for="private-key-file">Admin private key</label><input id="private-key-file"
type="file" accept=".pem,.json,application/json" required><span class="help">Use
data/keys/admin-private.pem.</span></div>
<div class="actions"><button class="button" type="submit">Open console</button></div>
<p id="login-message" class="message" role="status"></p>
</form>
</div>
</section>
<section id="admin-app" class="hidden">
<div class="page-head">
<div>
<p class="eyebrow">Operations | <span id="account-name"></span></p>
<h1>Server control</h1>
<p class="lede">Manage identities, simulation state, and the active core module.</p>
</div>
<div class="actions"><span id="game-status" class="status-chip"><i class="status-dot"></i><span
id="game-status-label">CONNECTING</span></span><button id="admin-logout" class="button secondary"
type="button">Sign out</button></div>
</div>
<p id="admin-message" class="message" role="status"></p>
<div class="layout">
<div class="main-column">
<section class="panel">
<div class="panel-head">
<h2>Identities</h2><span class="panel-kicker"><span id="user-count">0</span> PLAYERS</span>
</div>
<div class="panel-body">
<form id="user-form" class="inline-form">
<div class="field"><label for="new-username">Create player</label><input id="new-username"
maxlength="32" pattern="[A-Za-z0-9_\-]+" placeholder="username" required></div>
<button class="button" type="submit">Create keypair</button>
</form>
<p id="create-user-message" class="message" role="status" style="margin-top:10px"></p>
<a id="identity-download" class="button secondary hidden" href="#" download>Download identity bundle</a>
</div>
<div id="user-list" class="user-list">
<div class="empty">Loading identities...</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Core simulation</h2><span class="panel-kicker">SANDBOX VALIDATED</span>
</div>
<div class="panel-body">
<form id="core-form" class="inline-form">
<div class="field"><label for="core-file">Core WASM module</label><input id="core-file" type="file"
accept=".wasm,application/wasm" required></div>
<button class="button" type="submit">Replace core</button>
</form>
<p id="core-message" class="message" role="status" style="margin-top:10px"></p>
</div>
</section>
</div>
<aside class="side-column">
<section class="panel">
<div class="panel-head">
<h2>Simulation</h2><span class="panel-kicker">CONTROL</span>
</div>
<div class="panel-body stack">
<p class="side-note">Changes apply immediately. Current state is also published to the live event
stream.</p><button id="game-control" class="button" type="button">Pause simulation</button>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Session</h2>
</div>
<div class="panel-body">
<p class="side-note"><strong>Seven-day idle expiry.</strong> Each authenticated server request extends
the session. Only a hash of the browser token is persisted.</p>
</div>
</section>
</aside>
</div>
</section>
</main>
<footer><span>WOLLOSEUM | ADMIN</span><span>Privileged actions require an admin identity</span></footer>
</div>
</body>
</html>

630
web/app.css Normal file
View File

@ -0,0 +1,630 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap');
:root {
color-scheme: light;
--ink: #192825;
--muted: #66736e;
--line: #d9e1dc;
--paper: #f4f6f1;
--white: #fffefa;
--green: #176b50;
--green-dark: #124d3d;
--lime: #d5ef78;
--coral: #cf624c;
--blue: #477b8b;
--mono: 'DM Mono', ui-monospace, monospace;
--sans: 'Manrope', sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
background-color: var(--paper);
background-image: radial-gradient(#dce4dc 0.7px, transparent 0.7px);
background-size: 18px 18px;
font: 15px/1.55 var(--sans);
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
}
a {
color: inherit;
}
.shell {
max-width: 1180px;
margin: 0 auto;
padding: 0 30px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 76px;
border-bottom: 1px solid var(--line);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
font-size: 15px;
font-weight: 800;
letter-spacing: 0;
}
.brand-mark {
display: grid;
width: 30px;
height: 30px;
place-items: center;
color: var(--lime);
background: var(--green-dark);
border-radius: 8px;
font-size: 17px;
}
.nav {
display: flex;
align-items: center;
gap: 22px;
}
.nav a {
color: var(--muted);
text-decoration: none;
font-size: 13px;
font-weight: 700;
}
.nav a:hover,
.nav a[aria-current="page"] {
color: var(--green);
}
.eyebrow {
margin: 0 0 10px;
color: var(--green);
font: 500 11px var(--mono);
letter-spacing: 0;
text-transform: uppercase;
}
.page-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
padding: 44px 0 28px;
}
h1 {
margin: 0;
font-size: 36px;
line-height: 1.15;
letter-spacing: 0;
}
h2 {
margin: 0;
font-size: 17px;
line-height: 1.3;
letter-spacing: 0;
}
h3 {
margin: 0;
font-size: 13px;
letter-spacing: 0;
}
.lede {
margin: 10px 0 0;
color: var(--muted);
max-width: 620px;
}
.mono {
font-family: var(--mono);
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 7px 11px;
color: var(--muted);
background: var(--white);
font: 11px var(--mono);
white-space: nowrap;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #98a39d;
}
.status-chip.live .status-dot,
.status-chip.running .status-dot {
background: #48a674;
box-shadow: 0 0 0 3px #48a67424;
}
.status-chip.paused .status-dot,
.status-chip.offline .status-dot {
background: var(--coral);
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 290px;
gap: 26px;
padding-bottom: 60px;
}
.main-column,
.side-column {
display: grid;
align-content: start;
gap: 20px;
}
.panel {
border: 1px solid var(--line);
border-radius: 7px;
background: var(--white);
overflow: hidden;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
border-bottom: 1px solid var(--line);
}
.panel-body {
padding: 20px;
}
.panel-kicker {
color: var(--muted);
font: 11px var(--mono);
}
.login-panel {
max-width: 600px;
margin: 28px auto 70px;
border-top: 3px solid var(--green);
}
.login-panel .panel-body {
padding: 26px;
}
.login-panel h2 {
font-size: 21px;
}
.login-note {
margin: 8px 0 22px;
color: var(--muted);
font-size: 13px;
}
.form-grid {
display: grid;
gap: 14px;
}
.field {
display: grid;
gap: 7px;
}
.field label {
font-size: 12px;
font-weight: 800;
}
.field input,
.field select {
width: 100%;
min-height: 42px;
padding: 9px 11px;
border: 1px solid #cbd5ce;
border-radius: 4px;
color: var(--ink);
background: #fff;
outline: 0;
}
.field input:focus,
.field select:focus {
border-color: var(--green);
box-shadow: 0 0 0 3px #176b5018;
}
.field input[type="file"] {
padding: 7px;
font-size: 12px;
}
.help {
color: var(--muted);
font-size: 11px;
}
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 9px;
}
.button {
display: inline-flex;
min-height: 40px;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 14px;
border: 1px solid var(--green);
border-radius: 4px;
color: white;
background: var(--green);
font-size: 12px;
font-weight: 800;
transition: background 120ms ease, transform 120ms ease;
}
.button:hover {
background: var(--green-dark);
transform: translateY(-1px);
}
.button.secondary {
color: var(--ink);
border-color: var(--line);
background: white;
}
.button.secondary:hover {
background: #f2f5f0;
}
.button.danger {
border-color: #b64e3d;
background: #b64e3d;
}
.button:disabled {
cursor: wait;
opacity: .55;
transform: none;
}
.icon-button {
width: 36px;
min-height: 36px;
padding: 0;
}
.message {
min-height: 20px;
margin: 0;
color: var(--muted);
font-size: 12px;
}
.message.error {
color: #a43d30;
}
.message.success {
color: var(--green);
}
.stat-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.stat {
min-height: 105px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 5px;
background: #fbfcf8;
}
.stat-label {
color: var(--muted);
font: 10px var(--mono);
text-transform: uppercase;
}
.stat-value {
margin-top: 14px;
font-size: 19px;
font-weight: 800;
}
.stat-value.small {
overflow-wrap: anywhere;
font: 11px/1.5 var(--mono);
}
.stack {
display: grid;
gap: 15px;
}
.inline-form {
display: flex;
gap: 9px;
align-items: end;
}
.inline-form .field {
flex: 1;
}
.user-list {
display: grid;
}
.user-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
padding: 14px 20px;
border-bottom: 1px solid #e8ece7;
}
.user-row:last-child {
border-bottom: 0;
}
.user-name {
font-weight: 800;
}
.user-id {
max-width: 490px;
overflow: hidden;
color: var(--muted);
font: 10px var(--mono);
text-overflow: ellipsis;
}
.user-meta {
margin-top: 3px;
color: var(--muted);
font-size: 11px;
}
.table-tools {
display: flex;
align-items: center;
gap: 8px;
}
.table-tools input {
width: 180px;
min-height: 34px;
padding: 6px 9px;
border: 1px solid var(--line);
border-radius: 4px;
}
.event-list {
display: grid;
}
.event-row {
display: grid;
grid-template-columns: 68px minmax(120px, 180px) minmax(0, 1fr);
gap: 14px;
padding: 13px 17px;
border-bottom: 1px solid #e8ece7;
animation: arrive 180ms ease-out both;
}
.event-row:last-child {
border-bottom: 0;
}
.event-sequence,
.event-time {
color: var(--muted);
font: 10px var(--mono);
}
.event-name {
color: var(--green-dark);
font: 11px var(--mono);
overflow-wrap: anywhere;
}
.event-data {
color: #53615c;
font: 10px/1.5 var(--mono);
overflow-wrap: anywhere;
}
.event-filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.event-filters input {
width: min(260px, 100%);
min-height: 35px;
padding: 7px 10px;
border: 1px solid var(--line);
border-radius: 4px;
}
.empty {
padding: 25px 20px;
color: var(--muted);
font-size: 12px;
}
.side-note {
color: var(--muted);
font-size: 12px;
}
.side-note strong {
color: var(--ink);
}
.key-id {
display: block;
padding: 10px;
overflow-wrap: anywhere;
border: 1px solid var(--line);
border-radius: 4px;
color: var(--green-dark);
background: #f5f8f2;
font: 10px/1.5 var(--mono);
}
.hidden {
display: none !important;
}
.accent-band {
height: 4px;
background: linear-gradient(90deg, var(--green) 0 62%, var(--lime) 62% 82%, #e7a28b 82%);
}
.viewer-head {
align-items: center;
}
.viewer-summary {
display: flex;
gap: 20px;
color: var(--muted);
font: 11px var(--mono);
}
.viewer-summary strong {
color: var(--ink);
font-weight: 500;
}
footer {
display: flex;
justify-content: space-between;
padding: 16px 0 24px;
border-top: 1px solid var(--line);
color: var(--muted);
font: 10px var(--mono);
}
@keyframes arrive {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 760px) {
.shell {
padding: 0 17px;
}
h1 {
font-size: 30px;
}
.topbar {
min-height: 66px;
}
.nav {
gap: 13px;
}
.nav a {
font-size: 11px;
}
.page-head {
align-items: start;
flex-direction: column;
padding: 33px 0 21px;
}
.layout {
grid-template-columns: 1fr;
gap: 16px;
}
.event-row {
grid-template-columns: 52px 1fr;
gap: 5px 10px;
}
.event-data {
grid-column: 2;
}
.inline-form {
align-items: stretch;
flex-direction: column;
}
.user-row {
padding: 13px;
}
.user-row .button {
min-height: 34px;
padding: 7px 9px;
}
.viewer-summary {
gap: 12px;
flex-wrap: wrap;
}
footer {
gap: 10px;
}
}

399
web/app.js Normal file
View File

@ -0,0 +1,399 @@
const $ = (selector, root = document) => root.querySelector(selector);
const TOKEN_KEY = `wolloseum.session.${document.body?.dataset.page || 'app'}`;
const encoder = new TextEncoder();
function setMessage(element, text, kind = '') {
if (!element) return;
element.textContent = text;
element.className = `message ${kind}`.trim();
}
function fromBase64(value) {
return Uint8Array.from(atob(value), character => character.charCodeAt(0));
}
function readDerElement(bytes, offset) {
const start = offset;
const tag = bytes[offset++];
let length = bytes[offset++];
if (tag === undefined || length === undefined) return null;
if (length & 0x80) {
const lengthBytes = length & 0x7f;
if (!lengthBytes || lengthBytes > 4 || offset + lengthBytes > bytes.length) return null;
length = 0;
for (let index = 0; index < lengthBytes; index++) length = length * 256 + bytes[offset++];
}
const contentStart = offset;
const end = contentStart + length;
if (end > bytes.length) return null;
return { tag, start, contentStart, end };
}
function derLength(length) {
if (length < 0x80) return [length];
const bytes = [];
while (length) {
bytes.unshift(length & 0xff);
length >>>= 8;
}
return [0x80 | bytes.length, ...bytes];
}
function normalizeEd25519Pkcs8(bytes) {
const sequence = readDerElement(bytes, 0);
const version = sequence?.tag === 0x30 && sequence.end === bytes.length
? readDerElement(bytes, sequence.contentStart)
: null;
if (version?.tag !== 0x02 || version.end !== version.contentStart + 1) return bytes;
if (bytes[version.contentStart] === 0) return bytes;
if (bytes[version.contentStart] !== 1) return bytes;
const algorithm = readDerElement(bytes, version.end);
const privateKey = algorithm?.tag === 0x30 ? readDerElement(bytes, algorithm.end) : null;
const publicKey = privateKey?.tag === 0x04 ? readDerElement(bytes, privateKey.end) : null;
if (algorithm?.tag !== 0x30 || privateKey?.tag !== 0x04 || publicKey?.tag !== 0x81 || publicKey.end !== sequence.end) {
return bytes;
}
const body = new Uint8Array(3 + algorithm.end - algorithm.start + privateKey.end - privateKey.start);
body.set([0x02, 0x01, 0x00]);
body.set(bytes.subarray(algorithm.start, algorithm.end), 3);
body.set(bytes.subarray(privateKey.start, privateKey.end), 3 + algorithm.end - algorithm.start);
const encodedLength = derLength(body.length);
const normalized = new Uint8Array(1 + encodedLength.length + body.length);
normalized.set([0x30, ...encodedLength]);
normalized.set(body, 1 + encodedLength.length);
return normalized;
}
function toBase64(value) {
return btoa(String.fromCharCode(...new Uint8Array(value)));
}
function toPem(label, value) {
const base64 = toBase64(value).match(/.{1,64}/g).join('\n');
return `-----BEGIN ${label}-----\n${base64}\n-----END ${label}-----\n`;
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
const token = localStorage.getItem(TOKEN_KEY);
if (token) headers.set('Authorization', `Bearer ${token}`);
if (options.body && typeof options.body === 'string') headers.set('Content-Type', 'application/json');
const response = await fetch(path, { ...options, headers });
if (response.status === 204) return null;
const contentType = response.headers.get('content-type') || '';
const body = contentType.includes('application/json') ? await response.json() : await response.text();
if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`);
return body;
}
async function readCredential(file) {
const text = await file.text();
try {
const bundle = JSON.parse(text);
if (bundle.client_id && bundle.private_key_pem) return bundle;
} catch (_) { }
const identity = $('#identity-id').value.trim();
if (!identity) throw new Error('Enter the identity ID printed by the server or supplied by your administrator.');
return { client_id: identity, private_key_pem: text };
}
async function signIn() {
const file = $('#private-key-file').files[0];
if (!file) throw new Error('Choose your private key file.');
const credential = await readCredential(file);
const encodedKey = credential.private_key_pem.match(/-----BEGIN PRIVATE KEY-----([\s\S]*?)-----END PRIVATE KEY-----/);
if (!encodedKey) throw new Error('Expected an Ed25519 PKCS#8 private key.');
const pkcs8 = normalizeEd25519Pkcs8(fromBase64(encodedKey[1].replace(/\s/g, '')));
let privateKey;
try {
privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']);
} catch (error) {
if (error.name === 'NotSupportedError') {
throw new Error('This browser does not support Ed25519 Web Crypto. Update the browser and try again.');
}
throw new Error(`Ed25519 key import failed (${error.name}): ${error.message}. Choose data/keys/admin-private.pem or a generated identity JSON bundle.`);
}
const issued = await api('/api/auth/challenge', {
method: 'POST', body: JSON.stringify({ client_id: credential.client_id }),
});
const challenge = fromBase64(issued.challenge);
let signature;
try {
signature = await crypto.subtle.sign('Ed25519', privateKey, challenge);
} catch (error) {
throw new Error(`Ed25519 challenge signing failed (${error.name}): ${error.message}.`);
}
const session = await api('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ client_id: credential.client_id, challenge: issued.challenge, signature: toBase64(signature) }),
});
localStorage.setItem(TOKEN_KEY, session.token);
return api('/api/me');
}
function logout() {
localStorage.removeItem(TOKEN_KEY);
location.reload();
}
function connectEvents(onEvent, onConnection = () => { }) {
let reconnectDelay = 1000;
let socket;
let stopped = false;
const connect = () => {
if (stopped) return;
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(`${scheme}//${location.host}/api/events`);
socket.onopen = () => { reconnectDelay = 1000; onConnection(true); };
socket.onmessage = message => {
try { onEvent(JSON.parse(message.data)); } catch (_) { }
};
socket.onclose = () => {
onConnection(false);
if (!stopped) setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 15000);
};
socket.onerror = () => socket.close();
};
connect();
return () => { stopped = true; socket?.close(); };
}
function eventRow(event) {
const row = document.createElement('div');
row.className = 'event-row';
const sequence = document.createElement('span');
sequence.className = 'event-sequence';
sequence.textContent = `#${event.sequence}`;
const name = document.createElement('span');
name.className = 'event-name';
name.textContent = event.event;
const data = document.createElement('span');
data.className = 'event-data';
data.textContent = JSON.stringify(event.data ?? {});
row.append(sequence, name, data);
return row;
}
function bootViewer() {
const list = $('#event-list');
const empty = $('#event-empty');
const status = $('#socket-status');
const sequence = $('#latest-sequence');
const total = $('#event-count');
const search = $('#event-search');
let count = 0;
const events = [];
const redraw = () => {
list.replaceChildren();
const query = search.value.trim().toLowerCase();
const visible = events.filter(event => `${event.event} ${JSON.stringify(event.data)}`.toLowerCase().includes(query));
empty.classList.toggle('hidden', visible.length > 0);
visible.forEach(event => list.append(eventRow(event)));
};
search.addEventListener('input', redraw);
connectEvents(event => {
count += 1;
events.unshift(event);
if (events.length > 250) events.pop();
sequence.textContent = String(event.sequence);
total.textContent = String(count);
redraw();
}, connected => {
status.classList.toggle('live', connected);
status.classList.toggle('offline', !connected);
$('#socket-label').textContent = connected ? 'LIVE' : 'RECONNECTING';
});
}
function bootLogin() {
const form = $('#login-form');
const panel = $('#login-panel');
const message = $('#login-message');
const page = document.body.dataset.page;
const app = page === 'admin' ? $('#admin-app') : $('#user-app');
const account = $('#account-name');
const reveal = async identity => {
const requiredAdmin = page === 'admin';
if (identity.is_admin !== requiredAdmin) {
throw new Error(requiredAdmin ? 'This key does not belong to an administrator.' : 'Use the administration page to sign in with an admin key.');
}
panel.classList.add('hidden');
app.classList.remove('hidden');
account.textContent = identity.username;
if (requiredAdmin) await bootAdmin(identity);
else await bootUser(identity);
};
form.addEventListener('submit', async event => {
event.preventDefault();
const button = $('button[type="submit"]', form);
button.disabled = true;
setMessage(message, 'Verifying challenge...');
try { await reveal(await signIn()); }
catch (error) { setMessage(message, error.message, 'error'); }
finally { button.disabled = false; }
});
$('#logout-button, #admin-logout').addEventListener('click', logout);
$('#private-key-file').addEventListener('change', async event => {
const file = event.currentTarget.files[0];
if (!file || !file.name.endsWith('.json')) return;
try {
const bundle = JSON.parse(await file.text());
if (bundle.client_id) $('#identity-id').value = bundle.client_id;
} catch (_) { }
});
if (localStorage.getItem(TOKEN_KEY)) {
api('/api/me').then(reveal).catch(() => localStorage.removeItem(TOKEN_KEY));
}
}
async function bootUser(identity) {
$('#player-id').textContent = identity.client_id;
$('#agent-digest').textContent = identity.agent_sha256 || 'No agent uploaded';
const status = await api('/api/status');
paintGameStatus(status.running);
$('#agent-form').addEventListener('submit', async event => {
event.preventDefault();
const file = $('#agent-file').files[0];
if (!file) return setMessage($('#agent-message'), 'Choose a compiled WASM module.', 'error');
const button = $('button[type="submit"]', event.currentTarget);
button.disabled = true;
setMessage($('#agent-message'), `Uploading ${file.name}...`);
try {
const result = await api('/api/update_agent', { method: 'POST', body: await file.arrayBuffer() });
$('#agent-digest').textContent = result.sha256;
setMessage($('#agent-message'), `Agent accepted | ${result.bytes.toLocaleString()} bytes`, 'success');
} catch (error) { setMessage($('#agent-message'), error.message, 'error'); }
finally { button.disabled = false; }
});
connectEvents(event => {
if (event.event === 'snapshot' || event.event === 'game_state_changed') paintGameStatus(event.data.running);
});
}
function paintGameStatus(running) {
const chip = $('#game-status');
if (!chip) return;
chip.classList.toggle('running', running);
chip.classList.toggle('paused', !running);
$('#game-status-label').textContent = running ? 'RUNNING' : 'PAUSED';
const copy = $('#game-status-copy');
if (copy) copy.textContent = running ? 'RUNNING' : 'PAUSED';
}
async function bootAdmin() {
const status = await api('/api/status');
paintGameStatus(status.running);
$('#game-control').addEventListener('click', async () => {
const running = $('#game-status-label').textContent === 'RUNNING';
const button = $('#game-control');
button.disabled = true;
try {
await api(running ? '/api/stop_game' : '/api/start_game', { method: 'POST', body: '' });
paintGameStatus(!running);
button.textContent = running ? 'Resume simulation' : 'Pause simulation';
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
finally { button.disabled = false; }
});
$('#game-control').textContent = status.running ? 'Pause simulation' : 'Resume simulation';
$('#user-form').addEventListener('submit', createUser);
$('#core-form').addEventListener('submit', uploadCore);
await refreshUsers();
connectEvents(event => {
if (event.event === 'snapshot' || event.event === 'game_state_changed') {
paintGameStatus(event.data.running);
$('#game-control').textContent = event.data.running ? 'Pause simulation' : 'Resume simulation';
}
if (event.event === 'user_created' || event.event === 'user_deleted' || event.event === 'agent_uploaded') refreshUsers();
});
}
async function createUser(event) {
event.preventDefault();
const username = $('#new-username').value.trim();
const button = $('button[type="submit"]', event.currentTarget);
const downloadLink = $('#identity-download');
if (downloadLink.dataset.objectUrl) URL.revokeObjectURL(downloadLink.dataset.objectUrl);
delete downloadLink.dataset.objectUrl;
downloadLink.removeAttribute('href');
downloadLink.classList.add('hidden');
button.disabled = true;
setMessage($('#create-user-message'), 'Generating identity keypair...');
try {
const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
const publicKey = await crypto.subtle.exportKey('raw', pair.publicKey);
const privateKey = await crypto.subtle.exportKey('pkcs8', pair.privateKey);
const identity = await api('/api/create_user', {
method: 'POST',
body: JSON.stringify({ username, public_key: toBase64(publicKey) }),
});
const bundle = JSON.stringify({ username, client_id: identity.client_id, private_key_pem: toPem('PRIVATE KEY', privateKey) }, null, 2);
downloadLink.dataset.objectUrl = URL.createObjectURL(new Blob([bundle], { type: 'application/json' }));
downloadLink.href = downloadLink.dataset.objectUrl;
downloadLink.download = `${username}-identity.json`;
downloadLink.classList.remove('hidden');
$('#new-username').value = '';
setMessage($('#create-user-message'), `Created ${username}. Download the private-key bundle before closing this page.`, 'success');
await refreshUsers();
} catch (error) { setMessage($('#create-user-message'), error.message, 'error'); }
finally { button.disabled = false; }
}
async function refreshUsers() {
try {
const users = await api('/api/users');
const list = $('#user-list');
list.replaceChildren();
users.forEach(user => {
const row = document.createElement('div');
row.className = 'user-row';
const info = document.createElement('div');
const name = document.createElement('div');
name.className = 'user-name';
name.textContent = `${user.username}${user.is_admin ? ' | admin' : ''}`;
const id = document.createElement('div');
id.className = 'user-id';
id.textContent = user.client_id;
const meta = document.createElement('div');
meta.className = 'user-meta';
meta.textContent = user.has_agent ? `Agent | ${user.agent_sha256 || 'uploaded'}` : 'No agent';
info.append(name, id, meta);
row.append(info);
if (!user.is_admin) {
const remove = document.createElement('button');
remove.className = 'button danger';
remove.type = 'button';
remove.textContent = 'Remove';
remove.addEventListener('click', async () => {
if (!confirm(`Remove ${user.username} and their uploaded agent?`)) return;
try {
await api('/api/delete_user', { method: 'POST', body: JSON.stringify({ client_id: user.client_id }) });
await refreshUsers();
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
});
row.append(remove);
}
list.append(row);
});
$('#user-count').textContent = String(users.filter(user => !user.is_admin).length);
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
}
async function uploadCore(event) {
event.preventDefault();
const file = $('#core-file').files[0];
if (!file) return setMessage($('#core-message'), 'Choose a WASM core module.', 'error');
const button = $('button[type="submit"]', event.currentTarget);
button.disabled = true;
setMessage($('#core-message'), `Validating ${file.name}...`);
try {
const result = await api('/api/upload_core', { method: 'POST', body: await file.arrayBuffer() });
setMessage($('#core-message'), `Core module accepted | ${result.sha256}`, 'success');
} catch (error) { setMessage($('#core-message'), error.message, 'error'); }
finally { button.disabled = false; }
}
if (document.body.dataset.page === 'viewer') bootViewer();
else bootLogin();

94
web/user.html Normal file
View File

@ -0,0 +1,94 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wolloseum | Player</title>
<link rel="stylesheet" href="/assets/app.css">
<script src="/assets/app.js" defer></script>
</head>
<body data-page="user">
<div class="shell">
<header class="topbar">
<a class="brand" href="/"><span class="brand-mark">W</span> WOLLOSEUM</a>
<nav class="nav"><a href="/" aria-current="page">Player</a><a href="/viewer">Live viewer</a><a
href="/admin">Admin</a></nav>
</header>
<main>
<section id="login-panel" class="panel login-panel">
<div class="accent-band"></div>
<div class="panel-body">
<p class="eyebrow">Player access</p>
<h2>Sign in with your keypair</h2>
<p class="login-note">The server issues a one-time challenge. Your private key signs it locally and never
leaves this browser.</p>
<form id="login-form" class="form-grid">
<div class="field"><label for="identity-id">Identity ID</label><input id="identity-id"
autocomplete="username" spellcheck="false" placeholder="SHA-256 public-key fingerprint" required></div>
<div class="field"><label for="private-key-file">Private key or identity bundle</label><input
id="private-key-file" type="file" accept=".pem,.json,application/json" required><span
class="help">Choose the downloaded identity JSON or an Ed25519 PKCS#8 PEM key.</span></div>
<div class="actions"><button class="button" type="submit">Sign in</button></div>
<p id="login-message" class="message" role="status"></p>
</form>
</div>
</section>
<section id="user-app" class="hidden">
<div class="page-head">
<div>
<p class="eyebrow">Player console | <span id="account-name"></span></p>
<h1>Your agent</h1>
<p class="lede">Manage the WASM module associated with your identity.</p>
</div>
<div class="actions"><span id="game-status" class="status-chip"><i class="status-dot"></i><span
id="game-status-label">CONNECTING</span></span><button id="logout-button" class="button secondary"
type="button">Sign out</button></div>
</div>
<div class="layout">
<div class="main-column">
<section class="panel">
<div class="panel-head">
<h2>Agent module</h2><span class="panel-kicker">WASM | MAX 1 MiB</span>
</div>
<div class="panel-body stack">
<div class="stat-row">
<div class="stat">
<div class="stat-label">Current module</div>
<div id="agent-digest" class="stat-value small">Loading...</div>
</div>
<div class="stat">
<div class="stat-label">Simulation</div>
<div class="stat-value"><span id="game-status-copy">CONNECTING</span></div>
</div>
</div>
<form id="agent-form" class="stack">
<div class="field"><label for="agent-file">Compiled agent</label><input id="agent-file" type="file"
accept=".wasm,application/wasm" required></div>
<div class="actions"><button class="button" type="submit">Upload agent</button>
<p id="agent-message" class="message" role="status"></p>
</div>
</form>
</div>
</section>
</div>
<aside class="side-column">
<section class="panel">
<div class="panel-head">
<h2>Identity</h2>
</div>
<div class="panel-body stack"><span id="player-id" class="key-id"></span>
<p class="side-note">Your browser stores the session token. The private key is read only when you sign
in.</p>
</div>
</section>
</aside>
</div>
</section>
</main>
<footer><span>WOLLOSEUM | PLAYER</span><span>Ed25519 identity</span></footer>
</div>
</body>
</html>

44
web/viewer.html Normal file
View File

@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wolloseum | Live viewer</title>
<link rel="stylesheet" href="/assets/app.css">
<script src="/assets/app.js" defer></script>
</head>
<body data-page="viewer">
<div class="shell">
<header class="topbar">
<a class="brand" href="/"><span class="brand-mark">W</span> WOLLOSEUM</a>
<nav class="nav"><a href="/">Player</a><a href="/viewer" aria-current="page">Live viewer</a><a
href="/admin">Admin</a></nav>
</header>
<main>
<div class="page-head viewer-head">
<div>
<p class="eyebrow">Public stream | websocket</p>
<h1>World activity</h1>
<p class="lede">A live, ordered view of server state changes.</p>
</div>
<span id="socket-status" class="status-chip"><i class="status-dot"></i><span
id="socket-label">CONNECTING</span></span>
</div>
<section class="panel">
<div class="panel-head">
<div class="viewer-summary"><span>Latest <strong id="latest-sequence">-</strong></span><span>Received <strong
id="event-count">0</strong></span></div>
<div class="event-filters"><input id="event-search" type="search" placeholder="Filter event names or data"
aria-label="Filter events"></div>
</div>
<div id="event-list" class="event-list"></div>
<div id="event-empty" class="empty">Waiting for the first event...</div>
</section>
</main>
<footer><span>WOLLOSEUM | LIVE VIEWER</span><span>Snapshots restore state after reconnect</span></footer>
</div>
</body>
</html>