feat(sidecar): protocol 1 — the transport

The rust-link sidecar: it owns the loopback listener the Oxide bridge plugin
dials into, and serves the website a WebSocket feed plus store-backed reads.

Protocol 1 is deliberately three frames — server.hello, ping/pong, and one
correlated server.status — because phase 1's job is to get every seam working at
once with almost nothing in them.

What is load-bearing rather than incidental:

* The plugin is the TCP client and this process owns the listener, so a Rust
  server opens no extra port. Loopback is the trust boundary on that link and
  there is no token on it; the website-facing surface is the opposite, with auth
  always on and a token generated and persisted on first start.
* Inbound lines are capped at 1 MiB from the start rather than after the first
  large frame arrives. An over-long line is discarded and the connection stays
  up: one malformed frame is not a reason to drop a link live events flow over.
* Store-backed reads answer while the game is off, which is what lets a website
  render a server list during a wipe. /status is the one route that fails when
  the game is down, and /server answers 204 rather than a null when the game has
  never connected -- those are different answers and a client that cannot tell
  them apart renders a server that does not exist.
* The two RPC failures get distinct codes. 503 means the game is down; 504 means
  it is up and did not answer. Different fixes.
* rpc::REPLY_TIMEOUT is a ceiling every later command budget sits under: core
  classifies a budget overrun as retryable unconditionally, so an action whose
  budgetMs does not exceed it can never report retry:false.

One defect found while building, which no unit test would have caught: a
four-connection SQLite pool over :memory: hands out four separate empty
databases, because an in-memory database is per connection. It presents as
'no such table' from a random subset of queries. The pool is now capped at one
connection for an in-memory path, which is the only coherent reading of
:memory: and is what makes it usable at all.

Exercised end to end against a live Rust server: a server.hello travelled game
-> sidecar -> module -> the public website API, and killing this process left
the game untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-15 19:52:55 -05:00
parent d6a93506e8
commit e2a58f3455
12 changed files with 4651 additions and 0 deletions

423
sidecar/src/config.rs Normal file
View File

@@ -0,0 +1,423 @@
//! 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 logs the disagreement loudly and keeps the plugin's.
//! Two servers pointed at one sidecar by a copy-pasted config is the mistake this catches, and it
//! is silent in every other design.
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,
}
/// 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()
}
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(),
}
}
}
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) {
if let Ok(v) = env::var("RUSTLINK_GAME_BIND") {
self.game.bind = v;
}
if let Ok(v) = env::var("RUSTLINK_SERVER_ID") {
self.game.server_id = v;
}
if let Ok(v) = env::var("RUSTLINK_WEB_BIND") {
self.web.bind = v;
}
if let Ok(v) = env::var("RUSTLINK_WEB_TOKEN") {
self.web.auth_token = v;
}
if let Ok(v) = env::var("RUSTLINK_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: 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"
"#
)
}
#[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, "");
}
}