Files
link/sidecar/src/config.rs
Claude 359bb937b2 style(sidecar): apply rustfmt
Format the sidecar source with `cargo fmt` so the new `cargo fmt --check`
CI gate passes on the first run. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-13 14:40:05 -05:00

223 lines
6.5 KiB
Rust

//! 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;
#[derive(Debug, Default, 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 existed = Path::new(&path).exists();
let mut cfg: Config = if existed {
let text = fs::read_to_string(&path)?;
toml::from_str(&text)?
} else {
Config::default()
};
cfg.apply_env();
// Authentication is always on. A blank token is never allowed — if none is set (fresh
// install, or someone cleared it), generate one, save it, and continue. This keeps setup
// effortless while making it impossible to accidentally run with auth off.
if cfg.web.auth_token.trim().is_empty() {
let token = generate_token();
if existed {
persist_token(&path, &token)?;
} else {
fs::write(&path, default_file(&token))?;
}
cfg.web.auth_token = token.clone();
info!("No auth token configured.");
info!("Generated new token: {}", token);
info!("Saved to {}. Authentication is on.", path);
}
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;
}
}
pub fn auth_required(&self) -> bool {
// Always true now — load() guarantees a non-empty token.
!self.web.auth_token.is_empty()
}
}
/// Rewrites the `auth_token` line in an existing config file, preserving everything else. Falls
/// back to inserting it under `[web]`, or appending a `[web]` section, if the key is absent.
fn persist_token(path: &str, token: &str) -> anyhow::Result<()> {
let text = fs::read_to_string(path)?;
let line = format!("auth_token = \"{token}\"");
if text
.lines()
.any(|l| l.trim_start().starts_with("auth_token"))
{
let out: String = text
.lines()
.map(|l| {
if l.trim_start().starts_with("auth_token") {
line.clone()
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
fs::write(path, out + "\n")?;
} else if text.lines().any(|l| l.trim() == "[web]") {
let out: String = text
.lines()
.flat_map(|l| {
if l.trim() == "[web]" {
vec![l.to_string(), line.clone()]
} else {
vec![l.to_string()]
}
})
.collect::<Vec<_>>()
.join("\n");
fs::write(path, out + "\n")?;
} else {
fs::write(path, format!("{text}\n[web]\n{line}\n"))?;
}
Ok(())
}
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
# Authentication is always on: if this is left blank, the sidecar generates a new
# token here on startup. Rotate by changing this value and restarting.
auth_token = "{token}"
[store]
path = "uo-link.db"
"#
)
}