From f4e3ef4045064cc65dd642ce0bb5cb9fcb688164 Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Tue, 13 Oct 2026 18:45:47 +0200 Subject: [PATCH] feat(admin): persist generated admin password Co-Authored-By: GitHub Copilot --- .gitignore | 1 + CHANGELOG.md | 1 + Cargo.lock | 2 + Cargo.toml | 4 ++ Justfile | 3 + README.md | 2 + compose.yaml | 4 ++ src/main.rs | 190 ++++++++++++++++++++++++++++++++++++++++++++++++-- ui/src/api.rs | 25 +++++-- 9 files changed, 222 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 1c1d1dd..3d90451 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/sim-admin-password /.NOCOMMIT sim.db sim.db-shm diff --git a/CHANGELOG.md b/CHANGELOG.md index 792a208..3727325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Notable changes to this project are documented here. ### Added +- Add an HTTP Basic Auth-protected `/admin` path with a random password persisted in the app data volume. - Add an About dialog explaining the simulation, shards, actors, and persistence. - Docker Compose runs the gateway and each shard in its own container, with one persistent SQLite volume per shard. - Public clients are view-only: the UI hides editing controls and the server rejects remote writes; localhost clients retain editing access. diff --git a/Cargo.lock b/Cargo.lock index 1804ea6..e027a31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2798,6 +2798,7 @@ name = "sim" version = "0.1.0" dependencies = [ "axum", + "base64", "include_dir", "picard", "rand 0.8.8", @@ -2805,6 +2806,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "subtle", "tokio", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 5b19a7c..90c828b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,8 @@ edition = "2024" members = ["ui"] [workspace.dependencies] +base64 = "0.22" +subtle = "2.6" tracing = "0.1" tungstenite = "0.29" uuid = { version = "1", features = ["v4", "serde"] } @@ -17,6 +19,8 @@ console = ["picard/console", "serde"] serde = ["picard/serde"] [dependencies] +base64.workspace = true +subtle.workspace = true rune = "0.14.2" axum = { version = "0.8", features = ["ws"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time"] } diff --git a/Justfile b/Justfile index bd7eb28..64db2b8 100644 --- a/Justfile +++ b/Justfile @@ -7,5 +7,8 @@ run: stop: docker compose down --remove-orphans +admin: + docker compose exec -T app cat /data/admin-password + dev: SHARD_DATABASE_URL="sqlite://sim-shard-{shard}.db?mode=rwc" cargo run \ No newline at end of file diff --git a/README.md b/README.md index 69dd88a..654b700 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ People, animals and carts that cross a shard border are migrated with their whol Clients outside localhost can view the simulation but cannot edit it: the UI hides editing controls, and the server rejects remote write requests and WebSocket edit commands. When exposing the app through a reverse proxy, keep the published port bound to host loopback and ensure the proxy sets `X-Forwarded-For` or `X-Real-IP`. Compose enables `SIM_TRUST_PROXY_HEADERS=1` for this setup. Docker-host requests to `localhost` remain editable, while forwarded requests are classified by their client IP. +The `/admin` path on the same host and port provides authenticated editing. On first start, the app generates a random password and persists it in the `app_data` Docker volume; it stays the same across container restarts. The default username is `admin` (override with `SIM_ADMIN_USER`). Run `just admin` to print the password from the running app container. The normal `/` routes remain view-only. Keep the admin route behind HTTPS through Caddy because Basic Auth credentials are only encoded, not encrypted, without TLS. Removing volumes with `docker compose down -v` also removes this password, so the next start generates a new one. + ## Economy Every shard founds markets and workplaces: farms, woodcutters, quarries, hunters, toolmakers and cartwrights. Adults are hired, commute to their job site each day, work their shift, sell the goods at a market, get paid and go home. Farmers sow fields that ripen into crops; gatherers fell trees, quarry stone and hunt; crafters buy materials and make tools and carts. Markets are actors with their own coins and stock; prices follow supply and demand, and buyers and sellers haggle. Merchants carry surplus goods to markets on other shards. Markets, workplaces, carts and crop fields are drawn on the map, and the side panel shows each person's job and coins. diff --git a/compose.yaml b/compose.yaml index cf3a8a8..a2c17aa 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,8 +9,11 @@ services: SIM_SHARD_ADDRS: shard-0:9000,shard-1:9000,shard-2:9000 SIM_CLUSTER_TOKEN: ${SIM_CLUSTER_TOKEN:-compose-dev-token} SIM_TRUST_PROXY_HEADERS: "1" + SIM_ADMIN_USER: ${SIM_ADMIN_USER:-admin} + SIM_ADMIN_PASSWORD_FILE: /data/admin-password volumes: - ./config.rn:/etc/sim/config.rn:ro + - app_data:/data ports: - "127.0.0.1:7878:7878" depends_on: @@ -65,6 +68,7 @@ services: restart: unless-stopped volumes: + app_data: shard_0_data: shard_1_data: shard_2_data: \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 147a7ff..7f03e2f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,15 +4,21 @@ mod world; use axum::Router; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::extract::{ConnectInfo, DefaultBodyLimit, Path, Request, State}; +use axum::extract::{ConnectInfo, DefaultBodyLimit, OriginalUri, Path, Request, State}; use axum::http::{HeaderMap, StatusCode, header}; use axum::middleware::{self, Next}; use axum::response::{Html, IntoResponse, Json, Response}; use axum::routing::{get, post}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; use cluster::gateway::{GatewayOptions, Msg, Outputs}; use cluster::shard::ShardOptions; use include_dir::{Dir, include_dir}; +use rand::Rng; +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; use std::net::{IpAddr, SocketAddr}; +use std::path::{Path as FilePath, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::{mpsc, oneshot, watch}; @@ -25,10 +31,20 @@ static UI: Dir = include_dir!("$OUT_DIR/ui-pkg"); const INDEX: &str = r#" World Simulation "#; +#[derive(Clone)] +struct AdminCredentials { + username: String, + password: String, +} + #[derive(Clone)] struct App { terrain: Arc>, @@ -36,11 +52,23 @@ struct App { state: watch::Receiver>, tx: mpsc::UnboundedSender, trust_proxy_headers: bool, + admin_credentials: AdminCredentials, } /// Rejects cross-site requests and remote write requests. async fn same_origin(State(app): State, req: Request, next: Next) -> Response { let h = req.headers(); + let admin_path = is_admin_path(req.uri().path()); + if admin_path && !has_valid_admin_credentials(h, &app.admin_credentials) { + let mut response = StatusCode::UNAUTHORIZED.into_response(); + response.headers_mut().insert( + header::WWW_AUTHENTICATE, + "Basic realm=\"World Simulation Admin\", charset=\"UTF-8\"" + .parse() + .expect("valid authentication challenge"), + ); + return response; + } if let Some(origin) = h.get(header::ORIGIN).and_then(|v| v.to_str().ok()) { let host = h .get(header::HOST) @@ -55,7 +83,8 @@ async fn same_origin(State(app): State, req: Request, next: Next) -> Respon .extensions() .get::>() .map(|ConnectInfo(address)| *address); - if !can_edit(peer, h, app.trust_proxy_headers) + if !admin_path + && !can_edit(peer, h, app.trust_proxy_headers) && (req.method() != axum::http::Method::GET || req.uri().path() == "/api/actors/ws") { return StatusCode::FORBIDDEN.into_response(); @@ -63,6 +92,104 @@ async fn same_origin(State(app): State, req: Request, next: Next) -> Respon next.run(req).await } +fn is_admin_path(path: &str) -> bool { + path == "/admin" || path.starts_with("/admin/") +} + +fn has_valid_admin_credentials(headers: &HeaderMap, credentials: &AdminCredentials) -> bool { + let Some(encoded) = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Basic ")) + else { + return false; + }; + let Ok(decoded) = BASE64.decode(encoded) else { + return false; + }; + let Some(separator) = decoded.iter().position(|byte| *byte == b':') else { + return false; + }; + use subtle::ConstantTimeEq; + let username_matches = decoded[..separator] + .ct_eq(credentials.username.as_bytes()) + .unwrap_u8() + == 1; + let password_matches = decoded[separator + 1..] + .ct_eq(credentials.password.as_bytes()) + .unwrap_u8() + == 1; + username_matches & password_matches +} + +fn load_admin_credentials() -> AdminCredentials { + let username = std::env::var("SIM_ADMIN_USER").unwrap_or_else(|_| "admin".into()); + assert!( + !username.is_empty() && !username.contains(':'), + "SIM_ADMIN_USER must be non-empty and must not contain ':'" + ); + let path = std::env::var_os("SIM_ADMIN_PASSWORD_FILE") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("sim-admin-password")); + let password = load_or_create_admin_password(&path).unwrap_or_else(|error| { + panic!( + "failed to load admin password at {}: {error}", + path.display() + ) + }); + AdminCredentials { username, password } +} + +fn load_or_create_admin_password(path: &FilePath) -> io::Result { + match read_admin_password(path) { + Ok(password) => Ok(password), + Err(error) if error.kind() == io::ErrorKind::NotFound => create_admin_password(path), + Err(error) => Err(error), + } +} + +fn read_admin_password(path: &FilePath) -> io::Result { + let password = fs::read_to_string(path)?; + let password = password.trim_end_matches(['\r', '\n']).to_owned(); + if password.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "admin password file is empty", + )); + } + Ok(password) +} + +fn create_admin_password(path: &FilePath) -> io::Result { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent)?; + } + let password: String = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(32) + .map(char::from) + .collect(); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(path) { + Ok(mut file) => { + writeln!(file, "{password}")?; + file.sync_all()?; + Ok(password) + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => read_admin_password(path), + Err(error) => Err(error), + } +} + fn can_edit(peer: Option, headers: &HeaderMap, trust_proxy_headers: bool) -> bool { let client_ips = headers .get("x-forwarded-for") @@ -119,8 +246,9 @@ async fn access( State(app): State, ConnectInfo(peer): ConnectInfo, headers: HeaderMap, + OriginalUri(uri): OriginalUri, ) -> Json { - Json(can_edit(Some(peer), &headers, app.trust_proxy_headers)) + Json(is_admin_path(uri.path()) || can_edit(Some(peer), &headers, app.trust_proxy_headers)) } async fn get_script(State(app): State) -> String { @@ -416,8 +544,9 @@ async fn main() { tx, trust_proxy_headers: std::env::var("SIM_TRUST_PROXY_HEADERS") .is_ok_and(|value| value == "1"), + admin_credentials: load_admin_credentials(), }; - let router = Router::new() + let mut router = Router::new() .route("/", get(index)) .route("/api/access", get(access)) .route("/api/terrain", get(terrain)) @@ -429,7 +558,11 @@ async fn main() { .route("/ws", get(ws)) .route("/pkg/{*path}", get(pkg)); #[cfg(feature = "console")] - let router = router.route("/api/actors/ws", get(picard_ws)); + { + router = router.route("/api/actors/ws", get(picard_ws)); + } + let admin_routes = router.clone(); + router = router.nest("/admin", admin_routes); let router = router .layer(DefaultBodyLimit::max(65536)) .layer(middleware::from_fn_with_state(app.clone(), same_origin)) @@ -493,4 +626,49 @@ mod access_tests { headers.insert("x-forwarded-for", "127.0.0.1".parse().unwrap()); assert!(can_edit(Some(peer), &headers, true)); } + + #[test] + fn admin_path_matches_only_its_route_tree() { + assert!(is_admin_path("/admin")); + assert!(is_admin_path("/admin/api/edit")); + assert!(!is_admin_path("/administer")); + assert!(!is_admin_path("/api/edit")); + } + + #[test] + fn admin_path_requires_matching_basic_credentials() { + let credentials = AdminCredentials { + username: "operator".into(), + password: "long-secret".into(), + }; + let mut headers = HeaderMap::new(); + let encoded = BASE64.encode("operator:long-secret"); + headers.insert( + header::AUTHORIZATION, + format!("Basic {encoded}").parse().unwrap(), + ); + assert!(has_valid_admin_credentials(&headers, &credentials)); + + headers.insert( + header::AUTHORIZATION, + format!("Basic {}", BASE64.encode("operator:wrong")) + .parse() + .unwrap(), + ); + assert!(!has_valid_admin_credentials(&headers, &credentials)); + } + + #[test] + fn generated_admin_password_is_persisted_and_reused() { + let directory = std::env::temp_dir().join(format!("sim-admin-{}", uuid::Uuid::new_v4())); + let path = directory.join("admin-password"); + + let first = load_or_create_admin_password(&path).unwrap(); + assert_eq!(first.len(), 32); + assert!(first.bytes().all(|byte| byte.is_ascii_alphanumeric())); + assert_eq!(read_admin_password(&path).unwrap(), first); + assert_eq!(load_or_create_admin_password(&path).unwrap(), first); + + std::fs::remove_dir_all(directory).unwrap(); + } } diff --git a/ui/src/api.rs b/ui/src/api.rs index 2c552c5..fc55f06 100644 --- a/ui/src/api.rs +++ b/ui/src/api.rs @@ -12,6 +12,17 @@ thread_local! { static SOCKET: RefCell> = const { RefCell::new(None) }; } +fn endpoint(path: &str) -> String { + let admin = web_sys::window() + .and_then(|window| window.location().pathname().ok()) + .is_some_and(|pathname| pathname == "/admin" || pathname.starts_with("/admin/")); + if admin { + format!("/admin{path}") + } else { + path.to_owned() + } +} + /// Opens the live socket and calls `on_state` with each pushed snapshot; reconnects after a drop. pub fn connect(on_state: impl Fn(String) + 'static) { let Some(loc) = web_sys::window().map(|w| w.location()) else { @@ -24,7 +35,10 @@ pub fn connect(on_state: impl Fn(String) + 'static) { "ws" }; let Ok(host) = loc.host() else { return }; - open(&format!("{proto}://{host}/ws"), on_state.clone()); + open( + &format!("{proto}://{host}{}", endpoint("/ws")), + on_state.clone(), + ); spawn_local(async move { loop { @@ -71,16 +85,19 @@ fn open(url: &str, on_state: Rc) { } pub async fn get_json(url: &str) -> Option { - Request::get(url).send().await.ok()?.json().await.ok() + let url = endpoint(url); + Request::get(&url).send().await.ok()?.json().await.ok() } pub async fn get_text(url: &str) -> Option { - Request::get(url).send().await.ok()?.text().await.ok() + let url = endpoint(url); + Request::get(&url).send().await.ok()?.text().await.ok() } /// Returns (success, response body). pub async fn post(url: &str, body: String) -> Option<(bool, String)> { - let r = Request::post(url).body(body).ok()?.send().await.ok()?; + let url = endpoint(url); + let r = Request::post(&url).body(body).ok()?.send().await.ok()?; Some((r.ok(), r.text().await.ok()?)) }