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 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 16:56:55 -05:00
parent 946ba7027b
commit c4de5fa8ad
9 changed files with 469 additions and 13 deletions

1
sidecar/.gitignore vendored
View File

@@ -1,3 +1,4 @@
/target
*.db
*.db-*
sidecar.toml

61
sidecar/Cargo.lock generated
View File

@@ -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"

View File

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

View File

@@ -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 <token>` or `X-Api-Key: <token>`
- WebSocket — `?token=<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

View File

@@ -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 <token> (or X-Api-Key: <token>)
# WebSocket: add ?token=<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"

178
sidecar/src/config.rs Normal file
View File

@@ -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<Self> {
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 <token> (or X-Api-Key: <token>)
# WebSocket: add ?token=<token> to the connect URL
# Rotate by changing this and restarting. Generated randomly on first run.
auth_token = "{token}"
[store]
path = "uo-link.db"
"#
)
}

View File

@@ -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::<shard::ShardEvent>();
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::<String>(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");
}
});

View File

@@ -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<String>,
}
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 <t>`, `X-Api-Key:
/// <t>`, or `?token=<t>` (the last so browser WebSocket clients, which can't set headers, still
/// authenticate).
async fn auth(State(st): State<AppState>, 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<String> {
// Authorization: Bearer <token>
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: <token>
if let Some(v) = req.headers().get("x-api-key").and_then(|h| h.to_str().ok()) {
return Some(v.trim().to_string());
}
// ?token=<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;

View File

@@ -0,0 +1,98 @@
using System;
using System.Reflection;
using Server.Accounting;
using Server.Items;
using Server.Mobiles;
namespace Server.Custom
{
/// <summary>
/// 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.
/// </summary>
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<PlayerVendor>();
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);
}
}
}
}