feat(admin): persist generated admin password

Co-Authored-By: GitHub Copilot <noreply@github.com>
This commit is contained in:
Steve Biedermann 2026-10-13 18:45:47 +02:00
parent 4ae5d40bea
commit f4e3ef4045
9 changed files with 222 additions and 10 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
/target /target
/sim-admin-password
/.NOCOMMIT /.NOCOMMIT
sim.db sim.db
sim.db-shm sim.db-shm

View File

@ -6,6 +6,7 @@ Notable changes to this project are documented here.
### Added ### 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. - 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. - 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. - Public clients are view-only: the UI hides editing controls and the server rejects remote writes; localhost clients retain editing access.

2
Cargo.lock generated
View File

@ -2798,6 +2798,7 @@ name = "sim"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"base64",
"include_dir", "include_dir",
"picard", "picard",
"rand 0.8.8", "rand 0.8.8",
@ -2805,6 +2806,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"subtle",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",

View File

@ -7,6 +7,8 @@ edition = "2024"
members = ["ui"] members = ["ui"]
[workspace.dependencies] [workspace.dependencies]
base64 = "0.22"
subtle = "2.6"
tracing = "0.1" tracing = "0.1"
tungstenite = "0.29" tungstenite = "0.29"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
@ -17,6 +19,8 @@ console = ["picard/console", "serde"]
serde = ["picard/serde"] serde = ["picard/serde"]
[dependencies] [dependencies]
base64.workspace = true
subtle.workspace = true
rune = "0.14.2" rune = "0.14.2"
axum = { version = "0.8", features = ["ws"] } axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time"] }

View File

@ -7,5 +7,8 @@ run:
stop: stop:
docker compose down --remove-orphans docker compose down --remove-orphans
admin:
docker compose exec -T app cat /data/admin-password
dev: dev:
SHARD_DATABASE_URL="sqlite://sim-shard-{shard}.db?mode=rwc" cargo run SHARD_DATABASE_URL="sqlite://sim-shard-{shard}.db?mode=rwc" cargo run

View File

@ -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. 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 ## 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. 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.

View File

@ -9,8 +9,11 @@ services:
SIM_SHARD_ADDRS: shard-0:9000,shard-1:9000,shard-2:9000 SIM_SHARD_ADDRS: shard-0:9000,shard-1:9000,shard-2:9000
SIM_CLUSTER_TOKEN: ${SIM_CLUSTER_TOKEN:-compose-dev-token} SIM_CLUSTER_TOKEN: ${SIM_CLUSTER_TOKEN:-compose-dev-token}
SIM_TRUST_PROXY_HEADERS: "1" SIM_TRUST_PROXY_HEADERS: "1"
SIM_ADMIN_USER: ${SIM_ADMIN_USER:-admin}
SIM_ADMIN_PASSWORD_FILE: /data/admin-password
volumes: volumes:
- ./config.rn:/etc/sim/config.rn:ro - ./config.rn:/etc/sim/config.rn:ro
- app_data:/data
ports: ports:
- "127.0.0.1:7878:7878" - "127.0.0.1:7878:7878"
depends_on: depends_on:
@ -65,6 +68,7 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
app_data:
shard_0_data: shard_0_data:
shard_1_data: shard_1_data:
shard_2_data: shard_2_data:

View File

@ -4,15 +4,21 @@ mod world;
use axum::Router; use axum::Router;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; 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::http::{HeaderMap, StatusCode, header};
use axum::middleware::{self, Next}; use axum::middleware::{self, Next};
use axum::response::{Html, IntoResponse, Json, Response}; use axum::response::{Html, IntoResponse, Json, Response};
use axum::routing::{get, post}; use axum::routing::{get, post};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use cluster::gateway::{GatewayOptions, Msg, Outputs}; use cluster::gateway::{GatewayOptions, Msg, Outputs};
use cluster::shard::ShardOptions; use cluster::shard::ShardOptions;
use include_dir::{Dir, include_dir}; 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::net::{IpAddr, SocketAddr};
use std::path::{Path as FilePath, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use tokio::sync::{mpsc, oneshot, watch}; use tokio::sync::{mpsc, oneshot, watch};
@ -25,10 +31,20 @@ static UI: Dir = include_dir!("$OUT_DIR/ui-pkg");
const INDEX: &str = r#"<!doctype html> const INDEX: &str = r#"<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>World Simulation</title></head> <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>World Simulation</title></head>
<body style="background:#111;color:#ddd;font:13px monospace"><script type="module"> <body style="background:#111;color:#ddd;font:13px monospace"><script type="module">
try { const m = await import('/pkg/ui.js'); await m.default({ module_or_path: '/pkg/ui_bg.wasm' }); } try {
const base = location.pathname === '/admin' || location.pathname.startsWith('/admin/') ? '/admin' : '';
const m = await import(`${base}/pkg/ui.js`);
await m.default({ module_or_path: `${base}/pkg/ui_bg.wasm` });
}
catch (e) { document.body.textContent = 'Failed to load the UI: ' + e; } catch (e) { document.body.textContent = 'Failed to load the UI: ' + e; }
</script></body></html>"#; </script></body></html>"#;
#[derive(Clone)]
struct AdminCredentials {
username: String,
password: String,
}
#[derive(Clone)] #[derive(Clone)]
struct App { struct App {
terrain: Arc<Mutex<String>>, terrain: Arc<Mutex<String>>,
@ -36,11 +52,23 @@ struct App {
state: watch::Receiver<Arc<str>>, state: watch::Receiver<Arc<str>>,
tx: mpsc::UnboundedSender<Msg>, tx: mpsc::UnboundedSender<Msg>,
trust_proxy_headers: bool, trust_proxy_headers: bool,
admin_credentials: AdminCredentials,
} }
/// Rejects cross-site requests and remote write requests. /// Rejects cross-site requests and remote write requests.
async fn same_origin(State(app): State<App>, req: Request, next: Next) -> Response { async fn same_origin(State(app): State<App>, req: Request, next: Next) -> Response {
let h = req.headers(); 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()) { if let Some(origin) = h.get(header::ORIGIN).and_then(|v| v.to_str().ok()) {
let host = h let host = h
.get(header::HOST) .get(header::HOST)
@ -55,7 +83,8 @@ async fn same_origin(State(app): State<App>, req: Request, next: Next) -> Respon
.extensions() .extensions()
.get::<ConnectInfo<SocketAddr>>() .get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(address)| *address); .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") && (req.method() != axum::http::Method::GET || req.uri().path() == "/api/actors/ws")
{ {
return StatusCode::FORBIDDEN.into_response(); return StatusCode::FORBIDDEN.into_response();
@ -63,6 +92,104 @@ async fn same_origin(State(app): State<App>, req: Request, next: Next) -> Respon
next.run(req).await 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<String> {
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<String> {
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<String> {
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<SocketAddr>, headers: &HeaderMap, trust_proxy_headers: bool) -> bool { fn can_edit(peer: Option<SocketAddr>, headers: &HeaderMap, trust_proxy_headers: bool) -> bool {
let client_ips = headers let client_ips = headers
.get("x-forwarded-for") .get("x-forwarded-for")
@ -119,8 +246,9 @@ async fn access(
State(app): State<App>, State(app): State<App>,
ConnectInfo(peer): ConnectInfo<SocketAddr>, ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap, headers: HeaderMap,
OriginalUri(uri): OriginalUri,
) -> Json<bool> { ) -> Json<bool> {
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<App>) -> String { async fn get_script(State(app): State<App>) -> String {
@ -416,8 +544,9 @@ async fn main() {
tx, tx,
trust_proxy_headers: std::env::var("SIM_TRUST_PROXY_HEADERS") trust_proxy_headers: std::env::var("SIM_TRUST_PROXY_HEADERS")
.is_ok_and(|value| value == "1"), .is_ok_and(|value| value == "1"),
admin_credentials: load_admin_credentials(),
}; };
let router = Router::new() let mut router = Router::new()
.route("/", get(index)) .route("/", get(index))
.route("/api/access", get(access)) .route("/api/access", get(access))
.route("/api/terrain", get(terrain)) .route("/api/terrain", get(terrain))
@ -429,7 +558,11 @@ async fn main() {
.route("/ws", get(ws)) .route("/ws", get(ws))
.route("/pkg/{*path}", get(pkg)); .route("/pkg/{*path}", get(pkg));
#[cfg(feature = "console")] #[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 let router = router
.layer(DefaultBodyLimit::max(65536)) .layer(DefaultBodyLimit::max(65536))
.layer(middleware::from_fn_with_state(app.clone(), same_origin)) .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()); headers.insert("x-forwarded-for", "127.0.0.1".parse().unwrap());
assert!(can_edit(Some(peer), &headers, true)); 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();
}
} }

View File

@ -12,6 +12,17 @@ thread_local! {
static SOCKET: RefCell<Option<WebSocket>> = const { RefCell::new(None) }; static SOCKET: RefCell<Option<WebSocket>> = 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. /// 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) { pub fn connect(on_state: impl Fn(String) + 'static) {
let Some(loc) = web_sys::window().map(|w| w.location()) else { 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" "ws"
}; };
let Ok(host) = loc.host() else { return }; 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 { spawn_local(async move {
loop { loop {
@ -71,16 +85,19 @@ fn open(url: &str, on_state: Rc<dyn Fn(String)>) {
} }
pub async fn get_json<T: DeserializeOwned>(url: &str) -> Option<T> { pub async fn get_json<T: DeserializeOwned>(url: &str) -> Option<T> {
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<String> { pub async fn get_text(url: &str) -> Option<String> {
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). /// Returns (success, response body).
pub async fn post(url: &str, body: String) -> Option<(bool, String)> { 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()?)) Some((r.ok(), r.text().await.ok()?))
} }