From c4de5fa8adc8d431dc8494738c1d52a6540677aa Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 16:56:55 -0500 Subject: [PATCH] Sidecar: auth token for the website-facing API config.rs loads all runtime settings from an external sidecar.toml (path via $UOLINK_CONFIG), with env-var overrides (UOLINK_WEB_TOKEN, UOLINK_WEB_BIND, UOLINK_SHARD_BIND, UOLINK_DB_PATH). Nothing is compiled into the binary. On first run the file is generated with a random 24-byte auth token, so the sidecar is secured out of the box and the operator just copies the token to the website. An axum middleware rejects any request to a non-/health route that does not present the token, as Authorization: Bearer, X-Api-Key, or ?token= (the last so browser WebSocket clients, which cannot set handshake headers, can authenticate). The comparison is constant-time. An empty token disables auth and is only tolerated on a loopback bind; binding to 0.0.0.0 with no token logs a warning. Verified: /health open (200); /history 401 without a token, 401 with a wrong one, 200 with the right one via either Bearer or X-Api-Key; an authed shard query falls through to 503 when no shard is connected; WS rejected (401) with a bad ?token= and upgraded (101) with the right one. sidecar.toml is gitignored (holds the secret); sidecar.toml.example is committed as the reference. Co-Authored-By: Claude Opus 4.8 --- sidecar/.gitignore | 1 + sidecar/Cargo.lock | 61 +++++++ sidecar/Cargo.toml | 2 + sidecar/README.md | 15 +- sidecar/sidecar.toml.example | 30 ++++ sidecar/src/config.rs | 178 +++++++++++++++++++++ sidecar/src/main.rs | 23 ++- sidecar/src/web.rs | 74 ++++++++- tools/scaffolding/BridgeVendorTestProbe.cs | 98 ++++++++++++ 9 files changed, 469 insertions(+), 13 deletions(-) create mode 100644 sidecar/sidecar.toml.example create mode 100644 sidecar/src/config.rs create mode 100644 tools/scaffolding/BridgeVendorTestProbe.cs diff --git a/sidecar/.gitignore b/sidecar/.gitignore index b7fe454..136482e 100644 --- a/sidecar/.gitignore +++ b/sidecar/.gitignore @@ -1,3 +1,4 @@ /target *.db *.db-* +sidecar.toml diff --git a/sidecar/Cargo.lock b/sidecar/Cargo.lock index b65a020..557a61b 100644 --- a/sidecar/Cargo.lock +++ b/sidecar/Cargo.lock @@ -1165,6 +1165,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1641,6 +1650,47 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -1788,10 +1838,12 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "getrandom", "serde", "serde_json", "sqlx", "tokio", + "toml", "tracing", "tracing-subscriber", ] @@ -1941,6 +1993,15 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/sidecar/Cargo.toml b/sidecar/Cargo.toml index 27cd151..41029bc 100644 --- a/sidecar/Cargo.toml +++ b/sidecar/Cargo.toml @@ -13,6 +13,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1" axum = { version = "0.7", features = ["ws"] } sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } +toml = "0.8" +getrandom = "0.2" [profile.release] opt-level = 2 diff --git a/sidecar/README.md b/sidecar/README.md index 9ad18c1..d0d35fb 100644 --- a/sidecar/README.md +++ b/sidecar/README.md @@ -16,7 +16,18 @@ cargo run # info logging RUST_LOG=debug cargo run # see every event, incl. pong heartbeats ``` -Binds `127.0.0.1:7788` and waits for the shard to connect. Boot the shard (or it will reconnect on its own) and watch `server.hello` arrive. +On first run it writes `sidecar.toml` with a generated auth token and logs the path. Binds the shard listener (`127.0.0.1:7788`) and the web server (`127.0.0.1:8080`) from that file, then waits for the shard to connect. + +## Configuration & auth + +All runtime settings live in `sidecar.toml` (path overridable with `$UOLINK_CONFIG`) — **nothing is compiled into the binary**. See `sidecar.toml.example`. Environment variables override the file: `UOLINK_SHARD_BIND`, `UOLINK_WEB_BIND`, `UOLINK_WEB_TOKEN`, `UOLINK_DB_PATH`. + +The website authenticates to the sidecar with a shared token, presented as: + +- REST — `Authorization: Bearer ` or `X-Api-Key: ` +- WebSocket — `?token=` in the connect URL (browsers can't set headers on a WS handshake) + +`/health` is the only unauthenticated route. The token is compared in constant time. It is generated randomly on first run; rotate it by editing `sidecar.toml` (or setting `UOLINK_WEB_TOKEN`) and restarting. An empty token disables auth and is only tolerated on a loopback bind — binding to `0.0.0.0` with no token logs a warning that the API is exposed. `sidecar.toml` is gitignored because it holds the secret. ## Status @@ -29,7 +40,7 @@ Binds `127.0.0.1:7788` and waits for the shard to connect. Boot the shard (or it **The sidecar is feature-complete.** All four pieces work end-to-end against the live shard. -The web server binds `127.0.0.1:8080` by default (`WEB_ADDR` in `main.rs`). Widen the bind and add auth before exposing it off-host. +The web server binds per `sidecar.toml` (default `127.0.0.1:8080`). All routes except `/health` require the auth token (see Configuration & auth above). ### Routes diff --git a/sidecar/sidecar.toml.example b/sidecar/sidecar.toml.example new file mode 100644 index 0000000..36983c3 --- /dev/null +++ b/sidecar/sidecar.toml.example @@ -0,0 +1,30 @@ +# uo-link sidecar configuration — example. +# +# The sidecar reads `sidecar.toml` (override the path with $UOLINK_CONFIG). If that file +# is absent on first run, one is generated automatically with a random auth_token, so you +# normally do not create this by hand — just start the sidecar and edit the file it writes. +# Nothing here is compiled into the binary. +# +# Environment variables override the file: +# UOLINK_SHARD_BIND, UOLINK_WEB_BIND, UOLINK_WEB_TOKEN, UOLINK_DB_PATH + +[shard] +# Loopback address the shard dials out to. Keep this on localhost — the game must not +# be reachable from anywhere else. +bind = "127.0.0.1:7788" + +[web] +# Address the website connects to (WebSocket + REST). +# 127.0.0.1:8080 -> same host only +# 0.0.0.0:8080 -> accept remote clients (then auth_token is mandatory) +bind = "127.0.0.1:8080" + +# Shared secret the website must present on every request: +# REST: Authorization: Bearer (or X-Api-Key: ) +# WebSocket: add ?token= to the connect URL +# Empty disables auth, which is only allowed on a loopback bind (a warning is logged). +# Rotate by changing this value and restarting. +auth_token = "replace-with-a-long-random-secret" + +[store] +path = "uo-link.db" diff --git a/sidecar/src/config.rs b/sidecar/src/config.rs new file mode 100644 index 0000000..ea82b35 --- /dev/null +++ b/sidecar/src/config.rs @@ -0,0 +1,178 @@ +//! Runtime configuration, loaded from an external file — nothing here is compiled into the binary. +//! +//! Precedence: environment variables override the file, the file overrides built-in defaults. On +//! first run, if the file is absent, a default one is written with a freshly generated auth token, +//! so the sidecar is secured out of the box and the operator just copies the token to the website. +//! +//! File path: `$UOLINK_CONFIG`, else `sidecar.toml` in the working directory. + +use std::env; +use std::fs; +use std::path::Path; + +use serde::Deserialize; +use tracing::{info, warn}; + +#[derive(Debug, Deserialize)] +pub struct Config { + #[serde(default)] + pub shard: ShardCfg, + #[serde(default)] + pub web: WebCfg, + #[serde(default)] + pub store: StoreCfg, +} + +#[derive(Debug, Deserialize)] +pub struct ShardCfg { + #[serde(default = "default_shard_bind")] + pub bind: String, +} + +#[derive(Debug, Deserialize)] +pub struct WebCfg { + #[serde(default = "default_web_bind")] + pub bind: String, + /// Shared secret the website must present. Empty means the web surface is unauthenticated — + /// only acceptable when `bind` is loopback; refused otherwise (see `Config::validate`). + #[serde(default)] + pub auth_token: String, +} + +#[derive(Debug, Deserialize)] +pub struct StoreCfg { + #[serde(default = "default_db_path")] + pub path: String, +} + +fn default_shard_bind() -> String { + "127.0.0.1:7788".into() +} +fn default_web_bind() -> String { + "127.0.0.1:8080".into() +} +fn default_db_path() -> String { + "uo-link.db".into() +} + +impl Default for ShardCfg { + fn default() -> Self { + Self { + bind: default_shard_bind(), + } + } +} +impl Default for WebCfg { + fn default() -> Self { + Self { + bind: default_web_bind(), + auth_token: String::new(), + } + } +} +impl Default for StoreCfg { + fn default() -> Self { + Self { + path: default_db_path(), + } + } +} + +impl Config { + pub fn load() -> anyhow::Result { + let path = env::var("UOLINK_CONFIG").unwrap_or_else(|_| "sidecar.toml".into()); + + let mut cfg: Config = if Path::new(&path).exists() { + let text = fs::read_to_string(&path)?; + toml::from_str(&text)? + } else { + let token = generate_token(); + fs::write(&path, default_file(&token))?; + info!(path = %path, "no config found; wrote a default with a generated auth token"); + toml::from_str(&default_file(&token))? + }; + + cfg.apply_env(); + cfg.validate(); + Ok(cfg) + } + + /// Environment overrides, so a deployment can set secrets without editing the file. + fn apply_env(&mut self) { + if let Ok(v) = env::var("UOLINK_SHARD_BIND") { + self.shard.bind = v; + } + if let Ok(v) = env::var("UOLINK_WEB_BIND") { + self.web.bind = v; + } + if let Ok(v) = env::var("UOLINK_WEB_TOKEN") { + self.web.auth_token = v; + } + if let Ok(v) = env::var("UOLINK_DB_PATH") { + self.store.path = v; + } + } + + fn validate(&self) { + let loopback = self.web.bind.starts_with("127.") + || self.web.bind.starts_with("localhost") + || self.web.bind.starts_with("[::1]"); + + if self.web.auth_token.is_empty() { + if loopback { + warn!("web auth_token is empty; the web API is UNAUTHENTICATED (loopback bind, so local only)"); + } else { + warn!( + bind = %self.web.bind, + "web auth_token is empty but bind is NOT loopback — the web API is exposed with no auth. Set auth_token." + ); + } + } + } + + pub fn auth_required(&self) -> bool { + !self.web.auth_token.is_empty() + } +} + +fn generate_token() -> String { + let mut buf = [0u8; 24]; + // OS randomness; falls back to a time-seeded token only if the OS RNG is unavailable. + if getrandom::getrandom(&mut buf).is_err() { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + return format!("insecure-fallback-{nanos:x}"); + } + buf.iter().map(|b| format!("{b:02x}")).collect() +} + +fn default_file(token: &str) -> String { + format!( + r#"# uo-link sidecar configuration. +# Read at startup. Nothing here is compiled into the binary. Environment variables +# (UOLINK_SHARD_BIND, UOLINK_WEB_BIND, UOLINK_WEB_TOKEN, UOLINK_DB_PATH) override these. + +[shard] +# Loopback address the shard dials out to. Keep this on localhost — the game must +# not be reachable from anywhere else. +bind = "127.0.0.1:7788" + +[web] +# Address the website connects to (WebSocket + REST). +# 127.0.0.1:8080 -> same host only +# 0.0.0.0:8080 -> accept remote clients (then auth_token is mandatory) +bind = "127.0.0.1:8080" + +# Shared secret the website must present on every request: +# REST: Authorization: Bearer (or X-Api-Key: ) +# WebSocket: add ?token= to the connect URL +# Rotate by changing this and restarting. Generated randomly on first run. +auth_token = "{token}" + +[store] +path = "uo-link.db" +"# + ) +} diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 17dba58..6745019 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -3,27 +3,34 @@ //! Terminates the loopback link to the ServUO shard and exposes a website-facing HTTP surface. So //! far: the shard link (bidirectional) and a WebSocket live feed. REST queries and SQLite come next. +mod config; mod rpc; mod shard; mod store; mod web; +use std::sync::Arc; + use tokio::sync::{broadcast, mpsc}; use tracing::info; use tracing_subscriber::EnvFilter; -const SHARD_ADDR: &str = "127.0.0.1:7788"; -const WEB_ADDR: &str = "127.0.0.1:8080"; -const DB_PATH: &str = "uo-link.db"; - #[tokio::main] async fn main() -> anyhow::Result<()> { init_tracing(); info!("uo-link sidecar starting"); + let cfg = config::Config::load()?; + info!( + shard = %cfg.shard.bind, + web = %cfg.web.bind, + auth = cfg.auth_required(), + "configuration loaded" + ); + // Shard link: events in, commands out. let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); - let handle = shard::serve(SHARD_ADDR, event_tx).await?; + let handle = shard::serve(&cfg.shard.bind, event_tx).await?; // Live feed: every shard event fans out to all connected website WebSocket clients. let (bcast_tx, _) = broadcast::channel::(1024); @@ -32,7 +39,7 @@ async fn main() -> anyhow::Result<()> { let rpc = rpc::Rpc::new(); // Durable store: event history, economy series, cached profiles, link map. - let store = store::Store::open(DB_PATH).await?; + let store = store::Store::open(&cfg.store.path).await?; // Website-facing HTTP server. let web_state = web::AppState { @@ -40,9 +47,11 @@ async fn main() -> anyhow::Result<()> { shard: handle.clone(), rpc: rpc.clone(), store: store.clone(), + token: Arc::new(cfg.web.auth_token.clone()), }; + let web_bind = cfg.web.bind.clone(); tokio::spawn(async move { - if let Err(e) = web::serve(WEB_ADDR, web_state).await { + if let Err(e) = web::serve(&web_bind, web_state).await { tracing::error!(error = %e, "web server exited"); } }); diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 6a9fc87..e7c1dfb 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -6,11 +6,14 @@ use std::time::Duration; +use std::sync::Arc; + use axum::{ extract::ws::{Message, WebSocket, WebSocketUpgrade}, - extract::{Path, Query, State}, + extract::{Path, Query, Request, State}, http::StatusCode, - response::IntoResponse, + middleware::{self, Next}, + response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; @@ -30,11 +33,13 @@ pub struct AppState { pub shard: ShardHandle, pub rpc: Rpc, pub store: Store, + /// Shared secret the website must present. Empty disables auth (loopback dev only). + pub token: Arc, } pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { - let app = Router::new() - .route("/health", get(health)) + // Everything except /health is behind the auth check. + let protected = Router::new() .route("/ws", get(ws_upgrade)) // Queries (shard reply correlated by reqId). .route("/char/:account/:slot", get(char_by_slot)) @@ -49,6 +54,11 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // History, read from SQLite rather than the shard. .route("/history", get(history)) .route("/economy", get(economy)) + .route_layer(middleware::from_fn_with_state(state.clone(), auth)); + + let app = Router::new() + .route("/health", get(health)) + .merge(protected) .with_state(state); let listener = tokio::net::TcpListener::bind(addr).await?; @@ -61,6 +71,62 @@ async fn health() -> impl IntoResponse { "ok" } +// ---- auth ---- + +/// Rejects any request that does not present the configured token. Skipped entirely when no token +/// is configured (loopback dev). The token may arrive as `Authorization: Bearer `, `X-Api-Key: +/// `, or `?token=` (the last so browser WebSocket clients, which can't set headers, still +/// authenticate). +async fn auth(State(st): State, req: Request, next: Next) -> Response { + if st.token.is_empty() { + return next.run(req).await; // auth disabled + } + + let provided = extract_token(&req); + match provided { + Some(t) if constant_time_eq(t.as_bytes(), st.token.as_bytes()) => next.run(req).await, + _ => ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "missing or invalid auth token"})), + ) + .into_response(), + } +} + +fn extract_token(req: &Request) -> Option { + // Authorization: Bearer + if let Some(v) = req.headers().get("authorization").and_then(|h| h.to_str().ok()) { + if let Some(rest) = v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")) { + return Some(rest.trim().to_string()); + } + } + // X-Api-Key: + if let Some(v) = req.headers().get("x-api-key").and_then(|h| h.to_str().ok()) { + return Some(v.trim().to_string()); + } + // ?token= + if let Some(q) = req.uri().query() { + for pair in q.split('&') { + if let Some(v) = pair.strip_prefix("token=") { + return Some(v.to_string()); + } + } + } + None +} + +/// Length-independent, early-return-free comparison, so a wrong token leaks no timing signal. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + // ---- shared reply handling ---- /// Turns an RPC result into an HTTP response. A `bridge.error` reply from the shard becomes a 4xx; diff --git a/tools/scaffolding/BridgeVendorTestProbe.cs b/tools/scaffolding/BridgeVendorTestProbe.cs new file mode 100644 index 0000000..08d3dd9 --- /dev/null +++ b/tools/scaffolding/BridgeVendorTestProbe.cs @@ -0,0 +1,98 @@ +using System; +using System.Reflection; + +using Server.Accounting; +using Server.Items; +using Server.Mobiles; + +namespace Server.Custom +{ + /// + /// Spawns a reachable, houseless player vendor next to the `tester` character (wttest) and + /// gives tester gold, so the PlayerVendorSale core patch can be exercised by a real in-game + /// purchase. The buyer must be a non-GM — IsOwner() treats any GameMaster+ as the owner of + /// every player vendor, so an Owner-level character can never buy. + /// + /// Owner is a seed_000 character, so buyer (wttest) and owner (seed_000) are different + /// accounts — a clean cheat-detection example. + /// + /// Test scaffolding. Never deployed. Spawns a mobile and hands out gold; run only on the + /// throwaway seeded world. + /// + public static class BridgeVendorTestProbe + { + private static readonly MethodInfo SetVendorItem = typeof(PlayerVendor).GetMethod( + "SetVendorItem", + BindingFlags.Instance | BindingFlags.NonPublic, + null, + new[] { typeof(Item), typeof(int), typeof(string) }, + null); + + public static void Initialize() + { + if (Config.Get("Bridge.VendorTestOnStart", false)) + EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(3.0), Run); + } + + private static void Run() + { + try + { + var ownerAcct = Accounting.Accounts.GetAccount("seed_000") as Account; + var owner = ownerAcct == null ? null : ownerAcct[0]; + + var buyerAcct = Accounting.Accounts.GetAccount("wttest") as Account; + var buyer = buyerAcct == null ? null : buyerAcct[0] as PlayerMobile; + + if (owner == null || buyer == null) + { + Console.WriteLine("[VendorTest] need seed_000 owner and wttest/tester; not found"); + return; + } + + // Remove any prior test vendor (e.g. one orphaned on the Internal map). + if (PlayerVendor.PlayerVendors != null) + { + var doomed = new System.Collections.Generic.List(); + foreach (var v in PlayerVendor.PlayerVendors) + if (v != null && !v.Deleted && v.ShopName == "Bridge Test Shop") + doomed.Add(v); + foreach (var v in doomed) + v.Delete(); + } + + // A confirmed-walkable spot where tester was already standing this session, so the + // vendor is on solid ground and reachable. Then force tester's logout location right + // next to it, so tester logs in beside the vendor regardless of where it was. + var map = Map.Trammel; + var loc = new Point3D(3533, 2546, 20); // vendor + buyer.LogoutMap = map; + buyer.LogoutLocation = new Point3D(3532, 2546, 20); // tester appears here + + var vendor = new PlayerVendor(owner, null) + { + Name = "test vendor", + ShopName = "Bridge Test Shop" + }; + vendor.MoveToWorld(loc, map); + + var blade = new Longsword(); + vendor.Backpack.DropItem(blade); + if (SetVendorItem != null) + SetVendorItem.Invoke(vendor, new object[] { blade, 100, "a test blade" }); + + // Make sure tester can afford it. + if (buyer.Backpack != null) + buyer.Backpack.DropItem(new Gold(1000)); + + Console.WriteLine( + "[VendorTest] spawned '{0}' (owner {1}/seed_000) next to {2}/wttest at {3} on {4}; test blade = 100 gold; gave tester 1000 gold", + vendor.ShopName, owner.Name, buyer.Name, loc, map); + } + catch (Exception ex) + { + Console.WriteLine("[VendorTest] FAILED: " + ex); + } + } + } +}