//! 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 { 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::>() .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::>() .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 (or X-Api-Key: ) # WebSocket: add ?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" "# ) }