step
This commit is contained in:
parent
ce8bdcf09d
commit
0e099ebb18
|
|
@ -0,0 +1,5 @@
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
target
|
||||||
|
data
|
||||||
|
example-wasm-agent/target
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"] }
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
251
README.md
251
README.md
|
|
@ -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`).
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 "$@"
|
||||||
172
src/cli.rs
172
src/cli.rs
|
|
@ -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()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
706
src/server.rs
706
src/server.rs
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
Loading…
Reference in New Issue