//! 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: `--config `, else `$UOLINK_CONFIG`, else `sidecar.toml` in the working //! directory. //! //! **Paths are anchored to the config file, not the working directory.** A relative //! `[store].path` resolves against the directory holding `sidecar.toml`. A service started with //! `UOLINK_CONFIG=/etc/runicgateway/sidecar.toml` therefore keeps its database beside its config //! instead of wherever the service manager happened to set the working directory — which on //! Windows can be `%SystemRoot%\System32` or, under `C:\Program Files\`, a silently redirected //! VirtualStore copy. The values reported by `--print-config` are the resolved absolute ones. use std::env; use std::fs; use std::path::{Component, Path, PathBuf}; use serde::Deserialize; use serde_json::json; use tracing::info; use crate::PROTOCOL_VERSION; #[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. Never empty in practice — `Config::load` generates /// and persists one when it finds none, so the web surface is authenticated from first boot. #[serde(default)] pub auth_token: String, } #[derive(Debug, Deserialize)] pub struct StoreCfg { #[serde(default = "default_db_path")] pub path: String, } /// A loaded configuration plus what loading it *did* — an installer re-running the binary needs to /// distinguish "read an existing install" from "provisioned a new one", and it cannot tell from the /// values alone. #[derive(Debug)] pub struct Loaded { pub cfg: Config, /// Absolute path of the config file that was read or written. pub path: PathBuf, /// The config file did not exist and was created by this run. pub config_created: bool, /// No usable token was configured, so one was generated and saved. pub token_generated: bool, } 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 { /// Which config file this invocation will use: `--config`, else `$UOLINK_CONFIG`, else /// `sidecar.toml` beside the working directory. Always returned absolute, so every later /// message names a path the operator can act on. pub fn resolve_path(cli_override: Option<&str>) -> PathBuf { let raw = cli_override .map(str::to_string) .or_else(|| env::var("UOLINK_CONFIG").ok()) .unwrap_or_else(|| "sidecar.toml".into()); absolutize(PathBuf::from(raw)) } pub fn load(cli_override: Option<&str>) -> anyhow::Result { let path = Self::resolve_path(cli_override); let existed = 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. let token_generated = cfg.web.auth_token.trim().is_empty(); if token_generated { let token = generate_token(); if existed { persist_token(&path, &token)?; } else { // The parent may not exist yet when an installer points at a fresh // /etc/runicgateway; failing here would mean "run me again after mkdir". create_parent_dir(&path)?; 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.display()); } cfg.anchor_store_path(&path); Ok(Loaded { cfg, path, config_created: !existed, token_generated, }) } /// 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; } } /// Resolves `[store].path` against the config file's directory (see the module docs). Absolute /// paths and SQLite's non-filesystem spellings are left exactly as written. fn anchor_store_path(&mut self, config_path: &Path) { if is_sqlite_special(&self.store.path) { return; } let raw = PathBuf::from(&self.store.path); let anchored = if raw.is_absolute() { raw } else { config_dir(config_path).join(raw) }; self.store.path = absolutize(anchored).to_string_lossy().into_owned(); } pub fn auth_required(&self) -> bool { // Always true now — load() guarantees a non-empty token. !self.web.auth_token.is_empty() } } /// The `--print-config` document: everything an installer needs to register this sidecar with a /// website, in one non-interactive read. /// /// **This includes the auth token in clear text**, which is the point — §2.4 of the installer plan /// calls the manual token hunt the largest "I installed it and nothing happened" failure mode. The /// caller prints it to stdout and starts no log subscriber, so the document is the whole output. pub fn describe(loaded: &Loaded) -> serde_json::Value { json!({ "component": "uo-link-sidecar", "version": env!("CARGO_PKG_VERSION"), "protocol": PROTOCOL_VERSION, "config_path": loaded.path.to_string_lossy(), "config_created": loaded.config_created, "token_generated": loaded.token_generated, "shard": { "bind": loaded.cfg.shard.bind }, "web": { "bind": loaded.cfg.web.bind, "ws_path": crate::web::WS_PATH, "auth_required": loaded.cfg.auth_required(), "auth_token": loaded.cfg.web.auth_token, }, "store": { "path": loaded.cfg.store.path }, }) } /// Directory holding the config file. A bare `sidecar.toml` has no parent component, which would /// join into an empty base — treat it as the current directory. fn config_dir(config_path: &Path) -> PathBuf { match config_path.parent() { Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(), _ => PathBuf::from("."), } } /// Prefixes the working directory onto a relative path, then drops the `.` components that /// joining leaves behind — cosmetic, but these paths are printed and pasted into service units. fn absolutize(p: PathBuf) -> PathBuf { let joined = if p.is_absolute() { p } else { match env::current_dir() { Ok(cwd) => cwd.join(p), Err(_) => p, } }; let cleaned: PathBuf = joined .components() .filter(|c| !matches!(c, Component::CurDir)) .collect(); if cleaned.as_os_str().is_empty() { joined } else { cleaned } } /// `:memory:` and `file:` URIs are instructions to SQLite, not paths on disk. Anchoring them to a /// directory would turn a working in-memory store into an attempt to create a file called /// `:memory:` — which Windows cannot even name. fn is_sqlite_special(path: &str) -> bool { path == ":memory:" || path.starts_with("file:") } fn create_parent_dir(path: &Path) -> anyhow::Result<()> { if let Some(dir) = path.parent() { if !dir.as_os_str().is_empty() && !dir.exists() { fs::create_dir_all(dir)?; } } Ok(()) } /// 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: &Path, 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. # Read it back without starting the sidecar: uo-link-sidecar --print-config auth_token = "{token}" [store] # Relative paths resolve against the directory holding THIS FILE, not the working # directory of the process. path = "uo-link.db" "# ) } #[cfg(test)] mod tests { use super::*; /// A unique scratch directory. `std::env::temp_dir()` plus the test name keeps the cases /// independent under the default parallel test runner. fn scratch(name: &str) -> PathBuf { let dir = env::temp_dir().join(format!("uo-link-cfg-test-{name}")); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("create scratch dir"); dir } /// `Config::load` consults the process environment, and a developer may have `UOLINK_*` set /// for a local shard. Mutating shared env state from a test thread is worse than skipping, so /// the two cases that exercise the full load path bail out instead of failing spuriously. fn env_overrides_present() -> bool { [ "UOLINK_SHARD_BIND", "UOLINK_WEB_BIND", "UOLINK_WEB_TOKEN", "UOLINK_DB_PATH", ] .iter() .any(|k| env::var_os(k).is_some()) } fn cfg_with_store(path: &str) -> Config { Config { store: StoreCfg { path: path.into() }, ..Default::default() } } #[test] fn relative_store_path_anchors_to_the_config_directory() { // The working-directory trap: the service pins UOLINK_CONFIG but the service manager // decides the CWD, so a relative db path must not follow the CWD. let mut cfg = cfg_with_store("uo-link.db"); let config_path = if cfg!(windows) { PathBuf::from(r"C:\ProgramData\RunicGateway\sidecar.toml") } else { PathBuf::from("/etc/runicgateway/sidecar.toml") }; cfg.anchor_store_path(&config_path); let expected = config_path.parent().unwrap().join("uo-link.db"); assert_eq!(Path::new(&cfg.store.path), expected); } #[test] fn absolute_store_path_is_left_alone() { let absolute = if cfg!(windows) { r"C:\ProgramData\RunicGateway\uo-link.db" } else { "/var/lib/runicgateway/uo-link.db" }; let mut cfg = cfg_with_store(absolute); cfg.anchor_store_path(Path::new("/etc/runicgateway/sidecar.toml")); assert_eq!(cfg.store.path, absolute); } #[test] fn a_bare_config_filename_anchors_to_the_working_directory() { // `cargo run` in the crate root: config dir and CWD are the same, so the historical // behavior (db beside the binary's CWD) is preserved exactly. let mut cfg = cfg_with_store("uo-link.db"); cfg.anchor_store_path(Path::new("sidecar.toml")); assert_eq!( Path::new(&cfg.store.path), env::current_dir().unwrap().join("uo-link.db") ); } #[test] fn sqlite_special_paths_are_not_anchored() { for special in [":memory:", "file:cache?mode=memory"] { let mut cfg = cfg_with_store(special); cfg.anchor_store_path(Path::new("/etc/runicgateway/sidecar.toml")); assert_eq!(cfg.store.path, special); } } #[test] fn resolve_path_prefers_the_cli_override() { // Absolute in, absolute out — and unchanged, so the operator sees the path they passed. let explicit = if cfg!(windows) { r"C:\tmp\custom.toml" } else { "/tmp/custom.toml" }; assert_eq!( Config::resolve_path(Some(explicit)), PathBuf::from(explicit) ); } #[test] fn resolve_path_makes_a_relative_override_absolute() { let resolved = Config::resolve_path(Some("./conf/sidecar.toml")); assert!(resolved.is_absolute(), "{}", resolved.display()); assert_eq!( resolved, env::current_dir() .unwrap() .join("conf") .join("sidecar.toml") ); } #[test] fn persist_token_replaces_an_existing_key() { let dir = scratch("replace"); let path = dir.join("sidecar.toml"); fs::write( &path, "[web]\nbind = \"127.0.0.1:8080\"\nauth_token = \"\"\n\n[store]\npath = \"x.db\"\n", ) .unwrap(); persist_token(&path, "deadbeef").unwrap(); let out = fs::read_to_string(&path).unwrap(); assert!(out.contains("auth_token = \"deadbeef\""), "{out}"); assert_eq!(out.matches("auth_token").count(), 1, "{out}"); // Everything else survives — the file is the operator's, not ours to rewrite. assert!(out.contains("bind = \"127.0.0.1:8080\""), "{out}"); assert!(out.contains("path = \"x.db\""), "{out}"); } #[test] fn persist_token_inserts_under_an_existing_web_section() { let dir = scratch("insert"); let path = dir.join("sidecar.toml"); fs::write(&path, "[web]\nbind = \"127.0.0.1:8080\"\n").unwrap(); persist_token(&path, "deadbeef").unwrap(); let out = fs::read_to_string(&path).unwrap(); let web = out.find("[web]").unwrap(); let token = out.find("auth_token").unwrap(); assert!(token > web, "token must land inside [web]: {out}"); assert!(out.contains("auth_token = \"deadbeef\""), "{out}"); } #[test] fn persist_token_appends_a_web_section_when_there_is_none() { let dir = scratch("append"); let path = dir.join("sidecar.toml"); fs::write(&path, "[shard]\nbind = \"127.0.0.1:7788\"\n").unwrap(); persist_token(&path, "deadbeef").unwrap(); let out = fs::read_to_string(&path).unwrap(); assert!(out.contains("[shard]"), "{out}"); assert!(out.contains("[web]\nauth_token = \"deadbeef\""), "{out}"); } #[test] fn a_generated_config_round_trips_through_the_parser() { // The template is a format! string, so a stray brace or a bad key would only ever surface // on someone's first run. let cfg: Config = toml::from_str(&default_file("deadbeef")).expect("template parses"); assert_eq!(cfg.web.auth_token, "deadbeef"); assert_eq!(cfg.shard.bind, "127.0.0.1:7788"); assert_eq!(cfg.web.bind, "127.0.0.1:8080"); assert_eq!(cfg.store.path, "uo-link.db"); } #[test] fn generated_tokens_are_random_and_hex() { let (a, b) = (generate_token(), generate_token()); assert_ne!(a, b); assert_eq!(a.len(), 48); assert!(a.chars().all(|c| c.is_ascii_hexdigit()), "{a}"); } #[test] fn describe_reports_the_resolved_configuration() { let loaded = Loaded { cfg: Config { shard: ShardCfg { bind: "127.0.0.1:7788".into(), }, web: WebCfg { bind: "0.0.0.0:8080".into(), auth_token: "deadbeef".into(), }, store: StoreCfg { path: "/var/lib/runicgateway/uo-link.db".into(), }, }, path: PathBuf::from("/etc/runicgateway/sidecar.toml"), config_created: true, token_generated: true, }; let doc = describe(&loaded); assert_eq!(doc["component"], "uo-link-sidecar"); assert_eq!(doc["version"], env!("CARGO_PKG_VERSION")); assert_eq!(doc["protocol"], PROTOCOL_VERSION); assert_eq!(doc["config_path"], "/etc/runicgateway/sidecar.toml"); assert_eq!(doc["config_created"], true); assert_eq!(doc["token_generated"], true); assert_eq!(doc["shard"]["bind"], "127.0.0.1:7788"); assert_eq!(doc["web"]["bind"], "0.0.0.0:8080"); assert_eq!(doc["web"]["ws_path"], "/ws"); assert_eq!(doc["web"]["auth_required"], true); assert_eq!(doc["web"]["auth_token"], "deadbeef"); assert_eq!(doc["store"]["path"], "/var/lib/runicgateway/uo-link.db"); } #[test] fn load_provisions_a_missing_config_and_reports_it() { if env_overrides_present() { return; } let dir = scratch("provision"); let path = dir.join("sidecar.toml"); let loaded = Config::load(Some(path.to_str().unwrap())).unwrap(); assert!(loaded.config_created); assert!(loaded.token_generated); assert!( path.exists(), "the config file must be written, not just held in memory" ); assert!(!loaded.cfg.web.auth_token.is_empty()); // The db lands beside the config, whatever the working directory is. assert_eq!(Path::new(&loaded.cfg.store.path), dir.join("uo-link.db")); // Second run: same token, and nothing reported as new. let again = Config::load(Some(path.to_str().unwrap())).unwrap(); assert!(!again.config_created); assert!(!again.token_generated); assert_eq!(again.cfg.web.auth_token, loaded.cfg.web.auth_token); } #[test] fn load_creates_the_config_directory() { if env_overrides_present() { return; } // An installer pointing at a fresh /etc/runicgateway should not have to mkdir first. let dir = scratch("mkdir").join("nested").join("deeper"); let path = dir.join("sidecar.toml"); let loaded = Config::load(Some(path.to_str().unwrap())).unwrap(); assert!(path.exists(), "{}", path.display()); assert!(loaded.config_created); } }