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:
423
sidecar/src/config.rs
Normal file
423
sidecar/src/config.rs
Normal 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, "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user