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

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;