All checks were successful
PR Checks / rust-gates (pull_request) Successful in 4m1s
`[game].server_id` was a cross-check that WARNED and kept the plugin's id. The phase 18 walk showed what that costs: a second server's plugin, parked in this listener's backlog by a plugin bug (Rust-Plugins, D154), was accepted the moment the first server's plugin reloaded, and the website showed server "alpha" with beta's hostname and wipe. Now, with `server_id` set, the connection is closed on the first frame that names another server, BEFORE that frame reaches the store or the feed, and both ids are logged at ERROR. The command channel is installed only once a frame has named this server, so no website command (a grant, a world write) can reach a plugin about to be refused, and /health reports the plugin connected only from then. Blank `server_id`: nothing checked, as before. The egg's launcher now hands the sidecar the plugin config's ServerId once that file exists. The plugin reads RUSTLINK_SERVER_ID only at its first config write (D150); without this, a variable edited after the first boot would be refused instead of changing nothing, as INSTALL.md promises. Walked: a plugin aimed at another server's sidecar is refused with an ERROR naming both ids, and the site keeps the right server's identity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
499 lines
18 KiB
Rust
499 lines
18 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: `--config <PATH>`, 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<Loaded> {
|
|
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<String>) {
|
|
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::<i64>() {
|
|
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::<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#"# 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");
|
|
}
|
|
}
|