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"
"#
)
}