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:
178
sidecar/src/config.rs
Normal file
178
sidecar/src/config.rs
Normal 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"
|
||||
"#
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user