//! 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 `$RUSTLINK_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`, so a service started with //! `RUSTLINK_CONFIG=/etc/runicgateway/rust-main.toml` keeps its database beside its config instead //! of wherever the service manager happened to set the working directory. //! //! # `server_id`, and why it is here rather than only in the plugin //! //! R8 makes the platform multi-server: one sidecar per game server, and every row the module //! stores carries the server it came from. The plugin declares its own `serverId` in `server.hello` //! and that is the authority. This setting is a **cross-check**, not a second source of truth: when //! both are set and they disagree, the sidecar **refuses the plugin** — it closes the connection //! before the frame is filed, and logs both ids at ERROR (D155). Two servers dialling one sidecar is //! the mistake this catches, and it is silent in every other design. It was a warning that kept the //! plugin's id until the phase 18 walk showed what that costs: one server's history filed under //! another's name, visible on the website. Left blank, nothing is checked. 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 game: GameCfg, #[serde(default)] pub web: WebCfg, #[serde(default)] pub store: StoreCfg, } #[derive(Debug, Deserialize)] pub struct GameCfg { #[serde(default = "default_game_bind")] pub bind: String, /// Optional cross-check against the `serverId` the plugin announces. See the module docs. #[serde(default)] pub server_id: 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, /// How many days of event history to keep. `0` keeps everything. /// /// The store sits on a game host, and protocol 2 gave it a catalogue that produces real volume /// — every death, every chat line, every connect. The *permanent* record is the website's: /// per-wipe rollups in the module's own tables (R12). So this bounds the sidecar's copy, and /// the default is generous enough that nobody needs to think about it and small enough that a /// busy month is not a wipe-day outage. #[serde(default = "default_retain_days")] pub retain_days: i64, } /// 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_game_bind() -> String { "127.0.0.1:7799".into() } fn default_web_bind() -> String { "127.0.0.1:8090".into() } fn default_db_path() -> String { "rust-link.db".into() } fn default_retain_days() -> i64 { 14 } impl Default for GameCfg { fn default() -> Self { Self { bind: default_game_bind(), server_id: String::new(), } } } 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(), retain_days: default_retain_days(), } } } impl Config { /// Which config file this invocation will use: `--config`, else `$RUSTLINK_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("RUSTLINK_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) { self.apply_env_from(|key| env::var(key).ok()); } /// [`Self::apply_env`] against any lookup, so the rules below are testable without mutating the /// process environment (which the test harness shares across threads). /// /// **An empty or blank value is the same as an unset one.** A Pterodactyl egg exports every /// variable it declares, so an operator who leaves `RUSTLINK_WEB_TOKEN` blank arrives here as /// `RUSTLINK_WEB_TOKEN=""`. Honouring that as an override would blank the token saved in /// `sidecar.toml` on every boot, and a fresh one would be generated and persisted each time: /// the website's copy would go stale at every restart (PLAN.md §34, D152). No variable here has /// a meaningful empty value — an empty bind or database path can only fail later, less clearly. fn apply_env_from(&mut self, get: impl Fn(&str) -> Option) { let get = |key: &str| get(key).filter(|v| !v.trim().is_empty()); if let Some(v) = get("RUSTLINK_GAME_BIND") { self.game.bind = v; } if let Some(v) = get("RUSTLINK_SERVER_ID") { self.game.server_id = v; } if let Some(v) = get("RUSTLINK_WEB_BIND") { self.web.bind = v; } if let Some(v) = get("RUSTLINK_WEB_TOKEN") { self.web.auth_token = v; } if let Some(v) = get("RUSTLINK_DB_PATH") { self.store.path = v; } if let Some(v) = get("RUSTLINK_RETAIN_DAYS") { // A malformed value is ignored rather than fatal: this reaches the process as a panel // variable somebody typed (R22), and refusing to start over a stray character would // take the bridge down for a setting that has a perfectly good default. match v.trim().parse::() { Ok(days) if days >= 0 => self.store.retain_days = days, _ => tracing::warn!(value = %v, "ignoring an unreadable RUSTLINK_RETAIN_DAYS"), } } } /// 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: the manual token hunt is 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": "rust-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, "game": { "bind": loaded.cfg.game.bind, "server_id": loaded.cfg.game.server_id, }, "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#"# rust-link sidecar configuration. # # One sidecar serves one Rust game server. A community running six servers runs # six of these, each with its own port, its own database and its own token. # # Environment variables override every value here. [game] # Where the Oxide bridge plugin dials in. The plugin is the client; this is the # listener, which is why the game server itself opens no extra port. bind = "127.0.0.1:7799" # Optional. If set, it is cross-checked against the serverId the plugin # announces in server.hello; a disagreement is logged and the plugin wins. server_id = "" [web] # Where the website reaches this sidecar. Bind to a LAN or public address only # behind TLS and a firewall — the token below is the only thing guarding it. bind = "127.0.0.1:8090" # Generated on first run. Paste it into the website's Rust server form. It is # write-only there: the site never shows it back. auth_token = "{token}" [store] # Relative paths resolve against the directory holding THIS FILE, not the # working directory of whatever started the process. path = "rust-link.db" # How many days of event history to keep. 0 keeps everything. # # The permanent record is the website's — it holds per-wipe rollups that survive # a wipe. This database is the recent copy the site reads to catch up, and it # lives on the game host, so it is bounded. retain_days = 14 "# ) } #[cfg(test)] mod tests { use super::*; #[test] fn a_generated_token_is_48_hex_characters() { let t = generate_token(); assert!(!t.starts_with("insecure-fallback-"), "OS RNG unavailable"); assert_eq!(t.len(), 48); assert!(t.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn sqlite_special_paths_are_not_anchored() { let mut cfg = Config::default(); cfg.store.path = ":memory:".into(); cfg.anchor_store_path(Path::new("/etc/runicgateway/sidecar.toml")); assert_eq!(cfg.store.path, ":memory:"); } /// The whole point of anchoring: a service manager's working directory must not decide where /// the database lands. #[test] fn a_relative_store_path_anchors_to_the_config_directory() { let mut cfg = Config::default(); cfg.store.path = "rust-link.db".into(); let config_path = absolutize(PathBuf::from("cfgdir/sidecar.toml")); cfg.anchor_store_path(&config_path); let expected = config_path.parent().unwrap().join("rust-link.db"); assert_eq!(PathBuf::from(&cfg.store.path), expected); } /// The written default must be loadable by the loader that wrote it — a template with a typo /// in it fails on the second start, not the first. #[test] fn the_default_file_round_trips() { let cfg: Config = toml::from_str(&default_file("deadbeef")).unwrap(); assert_eq!(cfg.web.auth_token, "deadbeef"); assert_eq!(cfg.game.bind, default_game_bind()); assert_eq!(cfg.store.path, default_db_path()); assert_eq!(cfg.game.server_id, ""); } /// The egg's case (D152): a blank panel variable must not erase the token already saved in the /// file, or a new one would be generated on every boot. #[test] fn an_empty_variable_does_not_override_the_file() { let mut cfg: Config = toml::from_str(&default_file("saved-token")).unwrap(); cfg.apply_env_from(|key| match key { "RUSTLINK_WEB_TOKEN" => Some(String::new()), "RUSTLINK_WEB_BIND" => Some(" ".into()), "RUSTLINK_SERVER_ID" => Some(String::new()), _ => None, }); assert_eq!(cfg.web.auth_token, "saved-token"); assert_eq!(cfg.web.bind, default_web_bind()); assert_eq!(cfg.game.server_id, ""); } #[test] fn a_set_variable_still_overrides_the_file() { let mut cfg: Config = toml::from_str(&default_file("saved-token")).unwrap(); cfg.apply_env_from(|key| match key { "RUSTLINK_WEB_TOKEN" => Some("from-env".into()), "RUSTLINK_WEB_BIND" => Some("0.0.0.0:21009".into()), "RUSTLINK_SERVER_ID" => Some("alpha".into()), _ => None, }); assert_eq!(cfg.web.auth_token, "from-env"); assert_eq!(cfg.web.bind, "0.0.0.0:21009"); assert_eq!(cfg.game.server_id, "alpha"); } }