//! One instance's rust-link sidecar: its config, and asking the installed binary about it. //! //! **The installer writes each instance's `sidecar.toml` itself, once.** ServUO's sidecar is one per //! host and its defaults are right as shipped; a Rust host runs several, and every one of them needs //! its own game port, web port and database, which no default can give it. So the first run writes //! the file with those three decided and the token blank, and `--print-config` then generates the //! token and saves it into that same file — the sidecar still owns the secret, exactly as for ServUO. //! An existing file is never rewritten: after the first run it is the operator's, and the ports it //! names are the ones recorded. //! //! The same rule as `crate::sidecar` holds for the token: it crosses one process boundary, here, //! and goes to the terminal and nowhere else. use std::path::Path; use anyhow::{bail, Context, Result}; use serde::Deserialize; /// What `rust-link-sidecar --print-config` prints (Rust-Link `sidecar/src/config.rs::describe`). #[derive(Debug, Clone, Deserialize)] pub struct ConfigDoc { pub component: String, pub version: String, pub protocol: u32, pub config_path: String, pub token_generated: bool, pub game: GameDoc, pub web: WebDoc, pub store: StoreDoc, } #[derive(Debug, Clone, Deserialize)] pub struct GameDoc { pub bind: String, #[serde(default)] pub server_id: String, } #[derive(Debug, Clone, Deserialize)] pub struct WebDoc { pub bind: String, /// **A secret.** Printed in the handoff and recorded nowhere. pub auth_token: String, } #[derive(Debug, Clone, Deserialize)] pub struct StoreDoc { pub path: String, } /// The first `sidecar.toml` for an instance. Pure, so its content is a test. /// /// Paths are TOML **literal** strings (single quotes): a Windows path is all backslashes, and a basic /// string would read each one as an escape. A literal string cannot hold a single quote, and neither /// a server id nor any path this installer builds contains one. pub fn instance_config(server_id: &str, game_port: u16, web_port: u16, db: &Path) -> String { format!( "# rust-link sidecar for Rust server {server_id:?}.\n\ #\n\ # Written once by the Runic Gateway installer, which decided the ports and the database\n\ # below; it is never rewritten, so it is yours to edit from here. The token is generated\n\ # by the sidecar on first start and saved into this file.\n\ \n\ [game]\n\ # Where the plugin dials in. Loopback: there is no token on this link.\n\ bind = '127.0.0.1:{game_port}'\n\ # Cross-checked against the serverId the plugin announces.\n\ server_id = '{server_id}'\n\ \n\ [web]\n\ # Where the website reaches this sidecar. Put a TLS proxy in front to reach it from\n\ # another machine (docs/rust-link/INSTALL.md).\n\ bind = '127.0.0.1:{web_port}'\n\ auth_token = \"\"\n\ \n\ [store]\n\ path = '{db}'\n", db = db.display(), ) } /// Runs the installed binary's `--print-config` against one instance's config, provisioning the /// token on first use. **Must not print or attach the child's stdout** — it holds the token. pub fn print_config(binary: &Path, config: &Path) -> Result { let output = std::process::Command::new(binary) .arg("--print-config") .arg("--config") .arg(config) // An operator's shell may export RUSTLINK_* while testing; an installed instance is // described by its file alone, so none of them may leak into this read. .env_remove("RUSTLINK_CONFIG") .env_remove("RUSTLINK_GAME_BIND") .env_remove("RUSTLINK_WEB_BIND") .env_remove("RUSTLINK_WEB_TOKEN") .env_remove("RUSTLINK_DB_PATH") .env_remove("RUSTLINK_SERVER_ID") .output() .with_context(|| { format!( "cannot run {} --print-config — the binary cannot execute here", binary.display() ) })?; if !output.status.success() { let reason = String::from_utf8_lossy(&output.stderr) .lines() .map(str::trim) .find(|l| !l.is_empty()) .unwrap_or("(nothing on stderr)") .to_string(); bail!( "{} --print-config --config {} failed: {reason}", binary.display(), config.display() ); } let doc: ConfigDoc = serde_json::from_slice(&output.stdout).context( "the rust-link sidecar's --print-config output is not the document this installer \ expects. It is not shown here because it contains the auth token.", )?; if doc.component != "rust-link-sidecar" { bail!( "the binary at {} identifies itself as {:?}, not rust-link-sidecar", binary.display(), doc.component ); } if doc.web.auth_token.trim().is_empty() { bail!( "the sidecar reported an empty auth token from {}; refusing to continue", doc.config_path ); } Ok(doc) } /// The port half of a bind. pub fn port_of(bind: &str) -> Option { bind.rsplit_once(':').and_then(|(_, p)| p.parse().ok()) } /// The lowest port from `start` that no recorded instance holds and nothing on this host is bound /// to. `keep`, when given, is the port this instance already has, which is always kept. pub fn choose_port(start: u16, held: &[u16], keep: Option) -> Result { if let Some(port) = keep { return Ok(port); } (start..=u16::MAX) .take(1000) .find(|p| !held.contains(p) && std::net::TcpListener::bind(("127.0.0.1", *p)).is_ok()) .ok_or_else(|| anyhow::anyhow!("no free port found in the 1000 from {start}")) } /// The end-of-run block for one instance: what `/admin/rust/servers` asks for. /// /// It says what the run did, not what it hopes: nothing here has seen the plugin connect — on a /// stopped server it has not even loaded — so the first line never claims a link. `doctor` is the /// command that checks one (D156). pub fn handoff( server_id: &str, doc: &ConfigDoc, host: &str, site_url: Option<&str>, registered: bool, running: bool, ) -> String { let port = port_of(&doc.web.bind) .map(|p| p.to_string()) .unwrap_or_else(|| doc.web.bind.clone()); let site = site_url .map(|s| s.trim_end_matches('/').to_string()) .unwrap_or_else(|| "https://".to_string()); let loopback = doc.web.bind.starts_with("127.") || doc.web.bind.starts_with("[::1]"); format!( "\nRust server {server_id:?} is set up. {when}\n\n\ One manual step remains — add it to the website:\n\n \ Server id {server_id}\n \ Sidecar URL http://{host}:{port}\n \ Auth token {token}\n \ (also in {config})\n \ Protocol {protocol}\n\n\ Add these at {site}/admin/rust/servers\n{loopback_note}{service_note}", token = doc.web.auth_token, config = doc.config_path, protocol = doc.protocol, when = if running { format!( "The plugin loads now; `doctor --game rust --server-id {server_id}` confirms it connected." ) } else { "The plugin connects when the server next starts.".to_string() }, loopback_note = if loopback { format!( "\nThe sidecar listens on {} only. If the website runs on another machine, put a \ TLS proxy in\nfront of it and give the site the proxy's URL \ (docs/rust-link/INSTALL.md).\n", doc.web.bind ) } else { String::new() }, service_note = if registered { "" } else { "\nNo service was registered, so nothing is listening yet — see the steps above.\n" }, ) } #[cfg(test)] mod tests { use super::*; #[test] fn the_instance_config_parses_and_keeps_a_windows_path_intact() { let db = Path::new(r"C:\ProgramData\RunicGateway\rust\alpha\rust-link.db"); let text = instance_config("alpha", 7800, 8091, db); let value: toml_lite::Doc = toml_lite::parse(&text); assert_eq!(value.get("game", "bind"), Some("127.0.0.1:7800")); assert_eq!(value.get("game", "server_id"), Some("alpha")); assert_eq!(value.get("web", "bind"), Some("127.0.0.1:8091")); assert_eq!(value.get("web", "auth_token"), Some("")); assert_eq!( value.get("store", "path"), Some(r"C:\ProgramData\RunicGateway\rust\alpha\rust-link.db") ); } #[test] fn the_print_config_document_parses() { // What Rust-Link v0.1.0 prints (sidecar/src/config.rs::describe). let doc: ConfigDoc = serde_json::from_str( r#"{ "component": "rust-link-sidecar", "version": "0.1.0", "protocol": 12, "config_path": "/etc/runicgateway/rust/alpha.toml", "config_created": false, "token_generated": true, "game": { "bind": "127.0.0.1:7800", "server_id": "alpha" }, "web": { "bind": "127.0.0.1:8091", "ws_path": "/ws", "auth_required": true, "auth_token": "t0k" }, "store": { "path": "/var/lib/runicgateway/rust/alpha/rust-link.db" } }"#, ) .unwrap(); assert_eq!(port_of(&doc.web.bind), Some(8091)); let text = handoff("alpha", &doc, "rust.example", None, true, false); for needle in [ "alpha", "http://rust.example:8091", "t0k", "/admin/rust/servers", "12", ] { assert!(text.contains(needle), "{needle} missing from:\n{text}"); } assert!(text.contains("TLS proxy"), "{text}"); // D156: never a claim about a link nothing has seen. assert!(!text.contains("is connected"), "{text}"); assert!( text.contains("The plugin connects when the server next starts."), "{text}" ); let running = handoff("alpha", &doc, "rust.example", None, true, true); assert!(!running.contains("is connected"), "{running}"); assert!( running.contains("doctor --game rust --server-id alpha"), "{running}" ); } #[test] fn a_held_port_is_skipped_and_a_kept_one_is_kept() { let port = choose_port(47_990, &[47_990, 47_991], None).unwrap(); assert!(port >= 47_992); assert_eq!( choose_port(47_990, &[47_990], Some(47_990)).unwrap(), 47_990 ); } /// Enough TOML to read back the file `instance_config` writes — `[section]` and /// `key = 'literal'` / `key = "basic"` lines. The installer has no TOML dependency and this /// test is the only reader it needs. mod toml_lite { pub struct Doc(Vec<(String, String, String)>); impl Doc { pub fn get(&self, section: &str, key: &str) -> Option<&str> { self.0 .iter() .find(|(s, k, _)| s == section && k == key) .map(|(_, _, v)| v.as_str()) } } pub fn parse(text: &str) -> Doc { let mut section = String::new(); let mut out = Vec::new(); for line in text.lines().map(str::trim) { if line.starts_with('#') || line.is_empty() { continue; } if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { section = name.to_string(); } else if let Some((k, v)) = line.split_once('=') { let v = v.trim(); let v = v .strip_prefix('\'') .and_then(|v| v.strip_suffix('\'')) .or_else(|| v.strip_prefix('"').and_then(|v| v.strip_suffix('"'))) .unwrap_or(v); out.push((section.clone(), k.trim().to_string(), v.to_string())); } } Doc(out) } } }