diff --git a/Cargo.lock b/Cargo.lock index 0b7b0d2..2ab9a00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -402,6 +402,7 @@ name = "runicgateway-installer" version = "0.1.0" dependencies = [ "anyhow", + "base64", "chrono", "flate2", "serde", diff --git a/Cargo.toml b/Cargo.toml index 183bbe9..6cd5616 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,10 @@ sha2 = "0.11" # host, which is the whole point of shipping the plugin as a release tarball. sha1 = "0.11" +# Schema-2 bundles are read through Gitea's contents API, which returns the file +# base64-encoded — the /raw/ route is CDN-cached for hours (bundle.rs). Pure Rust. +base64 = "0.22" + serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index 3b7d04a..a6de937 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,33 @@ passed 13/13. | `update` | Resolve the current bundle manifest, then update the sidecar (replace + restart) and the overlay (re-sync + tell the operator to restart ServUO). | | `uninstall` | Remove only what the installer exclusively owns. It **never edits the ServUO tree** — it prints the overlay files to delete and the patch hunks to revert, and leaves that call to the operator. | +### Rust: `--game rust` + +The same four verbs set up **Rust servers** (Oxide or Carbon) from a schema-2 Rust bundle — a +Rust-Link sidecar and a Rust-Plugins plugin, checked by CI to speak one protocol. A Rust host +commonly runs several servers, so each is a **named instance** with its own service, config, +database and ports; every instance shares one sidecar binary, and `update` moves them together. + +```bash +sudo ./runicgateway-installer-linux-x86_64 install --game rust --rust /srv/rust --server-id main +``` + +- The framework is **detected**, never asked, and the plugin goes to `oxide/plugins/` or + `carbon/plugins/`. A tree with both, or neither, is refused. +- `--server-id` is the id the website knows the server by. Where the plugin has no config yet the + installer writes one holding just `ServerId` and `Port`; an existing one is never rewritten, and + one naming another server refuses the run. +- Services: `runicgateway-rust@.service` (a systemd template) or `RunicGatewayRust-`. + Records: `rust/install.json`, beside and separate from ServUO's. +- `doctor` checks the framework, the plugin file, the plugin config's `ServerId`, the required uMod + plugins (reported, never installed), the service, and `/health` through to *plugin connected*. +- `uninstall` removes the plugin file and keeps its config — that file is the website's. + +A Pterodactyl panel uses the **egg** instead, published with each +[Rust-Link release](https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases). The Rust +operator guide is +[`rust-link/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/INSTALL.md). + ## Design constraints worth knowing up front - **Releases are unsigned.** `SHA256SUMS` is the trust anchor; SmartScreen and diff --git a/src/bundle.rs b/src/bundle.rs index 28fea89..8486b23 100644 --- a/src/bundle.rs +++ b/src/bundle.rs @@ -14,6 +14,7 @@ //! half-understood. use anyhow::{bail, Context, Result}; +use base64::Engine as _; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -27,9 +28,6 @@ use std::collections::BTreeMap; const BUNDLE_BASE: &str = "https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles"; -/// The only `schema` this build understands. -const SUPPORTED_SCHEMA: u32 = 1; - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Bundle { pub schema: u32, @@ -126,7 +124,10 @@ pub fn platform_key() -> Result<&'static str> { } } -/// URL of the current bundle, or of a specific one when `--bundle ` pins it. +/// URL of a schema-1 bundle: the current one, or a specific one when `--bundle ` pins it. +/// +/// Schema 1 is ServUO only and stops being composed on 2027-01-01 (docs/modules/rust/PLAN.md +/// §34.4). This build reads it only as a fallback — see [`fetch`]. pub fn url_for(tag: Option<&str>) -> String { match tag { Some(tag) => format!("{BUNDLE_BASE}/bundle-{tag}.json"), @@ -134,33 +135,137 @@ pub fn url_for(tag: Option<&str>) -> String { } } -/// Fetches and validates a bundle. +/// Which game a schema-2 bundle describes, and so which stream it is read from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Game { + ServUo, + Rust, +} + +impl Game { + /// The `game` discriminant in the document, and the directory under `v2/`. + pub fn as_str(self) -> &'static str { + match self { + Self::ServUo => "servuo", + Self::Rust => "rust", + } + } +} + +/// The Gitea **contents** API for the `bundles` branch. +/// +/// Not the `/raw/` route [`BUNDLE_BASE`] names: that one is answered by the CDN with +/// `Cache-Control: public, max-age=21600`, so a bundle read through it can be hours behind the +/// branch — an `install` run right after a release would install the release before it. The +/// contents API is `private, must-revalidate` and always answers with the branch as it is. It +/// returns the file base64-encoded inside a JSON envelope, which [`fetch_v2`] unwraps. +const CONTENTS_BASE: &str = + "https://gitea.whitlocktech.com/api/v1/repos/RunicGateway/installer/contents"; + +/// The file name of a bundle in its stream. +fn file_name(tag: Option<&str>) -> String { + match tag { + Some(tag) => format!("bundle-{tag}.json"), + None => "current.json".to_string(), + } +} + +/// The address a person opens to read a schema-2 bundle — what `install.json` records. The same +/// document [`fetch_v2`] reads; a support question about "which bundle?" wants a link that opens. +pub fn v2_url(game: Game, tag: Option<&str>) -> String { + format!("{BUNDLE_BASE}/v2/{}/{}", game.as_str(), file_name(tag)) +} + +/// Fetches a schema-2 document's text from its game's stream. +fn fetch_v2(game: Game, tag: Option<&str>) -> Result { + let url = format!( + "{CONTENTS_BASE}/v2/{}/{}?ref=bundles", + game.as_str(), + file_name(tag) + ); + let envelope = crate::net::get_text(&url)?; + #[derive(Deserialize)] + struct Contents { + content: String, + } + let contents: Contents = serde_json::from_str(&envelope) + .context("the bundles branch answered with something that is not a file")?; + // Gitea wraps the base64 at 76 columns; the decoder wants it whole. + let packed: String = contents + .content + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(packed) + .context("the bundle file is not valid base64")?; + String::from_utf8(bytes).context("the bundle file is not UTF-8") +} + +/// Fetches and validates a ServUO bundle. +/// +/// **Schema 2 first, schema 1 as the fallback** (PLAN.md §34.2.2, D147). The current bundle and any +/// pin published since schema 2 are read from `v2/servuo/`; a `--bundle ` from before it exists +/// only at schema 1, and is read from the root and lifted into the same model, so a pin somebody +/// wrote down still reproduces. The fallback also covers the current bundle, which costs nothing +/// while both are composed and means this build never depends on the new stream alone. +/// +/// The returned URL is the document that was actually used. pub fn fetch(tag: Option<&str>) -> Result<(Bundle, String)> { - let url = url_for(tag); - let body = crate::net::get_text(&url).with_context(|| match tag { - Some(tag) => format!( - "cannot read bundle {tag}. Every published bundle is kept forever, so check the tag \ - against {BUNDLE_BASE}/" - ), - None => "cannot read the current bundle manifest".to_string(), - })?; + let v2 = fetch_v2(Game::ServUo, tag); + let (body, url) = match v2 { + Ok(body) => (body, v2_url(Game::ServUo, tag)), + Err(v2_error) => { + let url = url_for(tag); + let body = crate::net::get_text(&url) + .map_err(|_| v2_error) + .with_context(|| match tag { + Some(tag) => format!( + "cannot read bundle {tag}. Every published bundle is kept forever, so \ + check the tag against {BUNDLE_BASE}/v2/servuo/" + ), + None => "cannot read the current bundle manifest".to_string(), + })?; + (body, url) + } + }; let bundle = parse(&body)?; Ok((bundle, url)) } -/// Parses a bundle document and applies the checks that must hold before anything is downloaded. +/// Parses a ServUO bundle, schema 1 or 2, and applies the checks that must hold before anything is +/// downloaded. +/// +/// Schema 2 is **lowered** into the schema-1 model rather than the other way round: every ServUO +/// code path, and `install.json`, already speaks it, and the two documents carry exactly the same +/// facts (`sidecar` is `link`, the `overlay` payload's `compat` is its `servuo` block). A ServUO +/// deployment therefore records the same thing whichever schema it was resolved from. pub fn parse(body: &str) -> Result { - let bundle: Bundle = serde_json::from_str(body) + let head: SchemaHead = serde_json::from_str(body) .context("the bundle manifest is not in the shape this installer understands")?; - - if bundle.schema != SUPPORTED_SCHEMA { - bail!( - "bundle {} declares schema {} and this installer understands {SUPPORTED_SCHEMA}. \ + let bundle: Bundle = match head.schema { + 1 => serde_json::from_str(body) + .context("the bundle manifest is not in the shape this installer understands")?, + 2 => { + if head.game.as_deref() != Some(Game::ServUo.as_str()) { + bail!( + "bundle {} describes game {:?}, not ServUO. Pass --game rust to install a \ + Rust bundle.", + head.bundle, + head.game.unwrap_or_default() + ); + } + let v2: ServUoBundleV2 = serde_json::from_str(body).context( + "the schema-2 ServUO bundle is not in the shape this installer understands", + )?; + v2.lower() + } + other => bail!( + "bundle {} declares schema {other} and this installer understands 1 and 2. \ Update the installer — the bundle format changed.", - bundle.bundle, - bundle.schema - ); - } + head.bundle, + ), + }; // Gate 1 already ran in CI (§7.1), where a mismatch stops a bundle from being published at all. // Re-checking here costs nothing and covers the case CI cannot: a hand-edited or truncated @@ -187,6 +292,202 @@ pub fn parse(body: &str) -> Result { Ok(bundle) } +/// Just enough of any bundle to decide how to read the rest of it. +#[derive(Deserialize)] +struct SchemaHead { + schema: u32, + #[serde(default)] + game: Option, + #[serde(default)] + bundle: String, +} + +/// A schema-2 ServUO bundle as published. Read only to be [lowered](ServUoBundleV2::lower). +#[derive(Deserialize)] +struct ServUoBundleV2 { + bundle: String, + generated: String, + protocol: u32, + sidecar: LinkComponent, + payload: OverlayPayloadV2, +} + +#[derive(Deserialize)] +struct OverlayPayloadV2 { + kind: String, + repo: String, + tag: String, + version: String, + commit: String, + protocol: u32, + compat: ServUoCompat, + asset: Asset, +} + +impl ServUoBundleV2 { + fn lower(self) -> Bundle { + // `kind` is informational here: `game` already said ServUO, and a ServUO payload is an + // overlay by definition. Kept in the struct so a document missing it fails to parse. + let _ = self.payload.kind; + Bundle { + schema: 2, + bundle: self.bundle, + generated: self.generated, + protocol: self.protocol, + link: self.sidecar, + overlay: OverlayComponent { + repo: self.payload.repo, + tag: self.payload.tag, + version: self.payload.version, + commit: self.payload.commit, + protocol: self.payload.protocol, + servuo: self.payload.compat, + asset: self.payload.asset, + }, + } + } +} + +// ── Rust ───────────────────────────────────────────────────────────────────── + +/// A schema-2 Rust bundle: one exact Rust-Link release and one Rust-Plugins release, checked by CI +/// to speak the same protocol (docs/modules/rust/PLAN.md §34.2.2). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RustBundle { + pub schema: u32, + pub game: String, + pub bundle: String, + pub generated: String, + pub protocol: u32, + pub sidecar: RustSidecar, + pub payload: PluginPayload, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RustSidecar { + pub repo: String, + pub tag: String, + pub version: String, + pub protocol: u32, + /// `linux-x86_64` and `windows-x86_64`. No arm64: RustDedicated has no arm64 build (D149). + pub assets: BTreeMap, + /// The egg's launcher. The installer does not use it — a host runs the sidecar as a service — + /// but it is part of the pair CI checked, so it is carried rather than dropped. + pub launcher: Asset, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PluginPayload { + pub kind: String, + pub repo: String, + pub tag: String, + pub version: String, + pub commit: String, + pub protocol: u32, + pub compat: PluginCompat, + /// One tarball: `runicgateway-rust-plugin/{RunicGateway.cs, manifest.json}`. + pub asset: Asset, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PluginCompat { + /// `oxide` and `carbon`, each with the oldest build the plugin is known good on. Printed by + /// `doctor` beside a failure; never measured from a DLL (§34.2.3). + pub frameworks: BTreeMap, + /// Third-party plugins the features expect. Reported, never installed (D153). + pub requires_plugins: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FrameworkFloor { + pub min_version: String, +} + +impl RustBundle { + /// The sidecar binary for this host. Resolved before anything is written, like ServUO's. + pub fn sidecar_asset(&self) -> Result<&Asset> { + let key = platform_key()?; + self.sidecar.assets.get(key).ok_or_else(|| { + anyhow::anyhow!( + "Rust bundle {} has no rust-link binary for {key} (it has: {}). RustDedicated \ + itself runs on linux-x86_64 and windows-x86_64 only.", + self.bundle, + self.sidecar + .assets + .keys() + .cloned() + .collect::>() + .join(", ") + ) + }) + } +} + +/// Fetches and validates a Rust bundle: the current one, or `--bundle `. +pub fn fetch_rust(tag: Option<&str>) -> Result<(RustBundle, String)> { + let body = fetch_v2(Game::Rust, tag).with_context(|| match tag { + Some(tag) => format!( + "cannot read Rust bundle {tag}. Every published bundle is kept forever, so check the \ + tag against {BUNDLE_BASE}/v2/rust/" + ), + None => "cannot read the current Rust bundle. If Rust-Link or Rust-Plugins has never \ + released, there is none yet." + .to_string(), + })?; + Ok((parse_rust(&body)?, v2_url(Game::Rust, tag))) +} + +/// Parses a Rust bundle and applies the checks that must hold before anything is downloaded. +pub fn parse_rust(body: &str) -> Result { + let head: SchemaHead = serde_json::from_str(body) + .context("the Rust bundle is not in the shape this installer understands")?; + if head.schema != 2 { + bail!( + "Rust bundle {} declares schema {} and this installer reads Rust bundles at schema 2", + head.bundle, + head.schema + ); + } + if head.game.as_deref() != Some(Game::Rust.as_str()) { + bail!( + "bundle {} describes game {:?}, not Rust", + head.bundle, + head.game.unwrap_or_default() + ); + } + let bundle: RustBundle = serde_json::from_str(body) + .context("the Rust bundle is not in the shape this installer understands")?; + + // Gate 1 again, for a document that never went through CI. It matters more here than for + // ServUO: the Rust game link has no 409, so a mismatched plugin mis-parses instead of being + // refused. + if bundle.sidecar.protocol != bundle.payload.protocol + || bundle.protocol != bundle.sidecar.protocol + { + bail!( + "Rust bundle {} is internally inconsistent: bundle protocol {}, sidecar {}, plugin {}", + bundle.bundle, + bundle.protocol, + bundle.sidecar.protocol, + bundle.payload.protocol + ); + } + if bundle.payload.kind != "plugin" { + bail!( + "Rust bundle {} carries a {:?} payload; this installer deploys a plugin", + bundle.bundle, + bundle.payload.kind + ); + } + if bundle.payload.asset.url.is_empty() || bundle.payload.asset.sha256.is_empty() { + bail!( + "Rust bundle {} names a plugin asset with no URL or checksum", + bundle.bundle + ); + } + Ok(bundle) +} + #[cfg(test)] mod tests { use super::*; @@ -262,9 +563,78 @@ mod tests { #[test] fn a_newer_schema_is_refused_rather_than_guessed_at() { - let body = CURRENT.replace("\"schema\": 1", "\"schema\": 2"); + let body = CURRENT.replace("\"schema\": 1", "\"schema\": 3"); let err = parse(&body).unwrap_err().to_string(); - assert!(err.contains("schema 2"), "{err}"); + assert!(err.contains("schema 3"), "{err}"); + } + + /// Published on the bundles branch as `v2/servuo/bundle-2026.09.15.json` and at the root as + /// `bundle-2026.09.15.json` — the same matrix at both schemas, under the same tag. + const V2_SERVUO: &str = include_str!("../tests/fixtures/published-bundle-v2-servuo.json"); + const V1_SAME: &str = include_str!("../tests/fixtures/published-bundle-2026.09.15.json"); + /// What the compose job wrote for Rust from real Rust-Link and Rust-Plugins artifacts, with + /// the mock host's URLs pointed back at Gitea. + const RUST: &str = include_str!("../tests/fixtures/rust-bundle-v2.json"); + + #[test] + fn a_schema_2_servuo_bundle_lowers_to_what_schema_1_says() { + // The lowering is the whole claim that a ServUO host records the same deployment whichever + // stream it read. Only `schema` and `generated` describe the document rather than the pair. + let mut v2 = parse(V2_SERVUO).unwrap(); + let mut v1 = parse(V1_SAME).unwrap(); + assert_eq!(v2.schema, 2); + v2.schema = 1; + v2.generated.clear(); + v1.generated.clear(); + assert_eq!(v2, v1); + } + + #[test] + fn a_rust_bundle_is_refused_by_the_servuo_reader() { + let err = parse(RUST).unwrap_err().to_string(); + assert!(err.contains("--game rust"), "{err}"); + } + + #[test] + fn the_rust_bundle_parses() { + let bundle = parse_rust(RUST).unwrap(); + assert_eq!(bundle.game, "rust"); + assert_eq!(bundle.protocol, 12); + assert_eq!(bundle.payload.kind, "plugin"); + assert_eq!( + bundle.payload.compat.frameworks["carbon"].min_version, + "2.0.259" + ); + assert_eq!( + bundle.payload.compat.requires_plugins, + ["Kits", "ZoneManager"] + ); + assert!(!bundle.sidecar.assets.contains_key("linux-aarch64")); + assert_eq!(bundle.sidecar.launcher.name, "with-sidecar.sh"); + } + + #[test] + fn a_servuo_bundle_is_refused_by_the_rust_reader() { + let err = parse_rust(V2_SERVUO).unwrap_err().to_string(); + assert!(err.contains("not Rust"), "{err}"); + let err = parse_rust(V1_SAME).unwrap_err().to_string(); + assert!(err.contains("schema 1"), "{err}"); + } + + #[test] + fn a_protocol_disagreement_inside_a_rust_bundle_is_refused() { + let mut doc: serde_json::Value = serde_json::from_str(RUST).unwrap(); + doc["payload"]["protocol"] = 11.into(); + let err = parse_rust(&doc.to_string()).unwrap_err().to_string(); + assert!(err.contains("internally inconsistent"), "{err}"); + } + + #[test] + fn schema_2_urls_name_their_game() { + assert!(v2_url(Game::Rust, None).ends_with("/v2/rust/current.json")); + assert!( + v2_url(Game::ServUo, Some("2026.09.15")).ends_with("/v2/servuo/bundle-2026.09.15.json") + ); } #[test] diff --git a/src/cli.rs b/src/cli.rs index 7e9cc48..77d03d7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -56,6 +56,14 @@ pub enum PatchChoice { No, } +/// Which game's shard side this run is about (docs/modules/rust/PLAN.md §34.2.3). ServUO is the +/// default, so every command line written before Rust existed means exactly what it meant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Game { + ServUo, + Rust, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { Run(Command), @@ -66,6 +74,15 @@ pub enum Mode { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Cli { pub mode: Mode, + /// `--game servuo|rust`. + pub game: Game, + /// `--rust `: a Rust server root (the directory holding `RustDedicated`). + pub rust: Option, + /// `--server-id `: names one Rust instance — its service, config, database and ports — and + /// is the plugin's `ServerId` (D148). + pub server_id: Option, + /// `--web-port `: the port the website reaches a Rust instance's sidecar on. + pub web_port: Option, /// `--verify`: report every change that would be made, write nothing. pub verify: bool, /// `--servuo `: name the ServUO root instead of detecting or prompting. @@ -92,6 +109,10 @@ impl Default for Cli { fn default() -> Self { Self { mode: Mode::Help, + game: Game::ServUo, + rust: None, + server_id: None, + web_port: None, verify: false, servuo: None, bundle: None, @@ -107,7 +128,8 @@ impl Default for Cli { } pub const USAGE: &str = "\ -Runic Gateway installer — connects a ServUO shard to a Runic Gateway website. +Runic Gateway installer — connects a ServUO shard, or Rust servers, to a Runic +Gateway website. Usage: runicgateway-installer [OPTIONS] @@ -121,6 +143,7 @@ Commands: ServUO tree — it prints what to remove there. Options: + --game Which game's shard side. Default servuo. --verify install, update. Dry run: report every change that would be made, write nothing. --servuo install, doctor, update. The ServUO root, @@ -149,6 +172,16 @@ Options: --purge uninstall. Also delete sidecar.toml, uo-link.db, the cached patch set and every backup, all of which are otherwise kept. +Rust (with --game rust): + --rust install. The Rust server root — the + directory holding RustDedicated. Oxide or + Carbon is detected, never asked. + --server-id install: required. doctor, uninstall: + one instance instead of all. The id the + website knows this server by, and the + name of its sidecar's service. + --web-port install. The sidecar's website port. + Default: the first free from 8090. -V, --version Print the installer version and exit. -h, --help Print this help and exit. @@ -199,6 +232,30 @@ pub fn parse>(args: I) -> Result { "--bundle" => cli.bundle = Some(take_value(&name, inline, &mut it)?), "--host" => cli.host = Some(take_value(&name, inline, &mut it)?), "--site-url" => cli.site_url = Some(take_value(&name, inline, &mut it)?), + "--game" => { + cli.game = match take_value(&name, inline, &mut it)?.as_str() { + "servuo" => Game::ServUo, + "rust" => Game::Rust, + other => return Err(format!("--game must be servuo or rust, not {other:?}")), + } + } + "--rust" => cli.rust = Some(take_value(&name, inline, &mut it)?), + "--server-id" => { + let id = take_value(&name, inline, &mut it)?; + if !valid_server_id(&id) { + return Err(format!( + "--server-id {id:?} is not a valid server id: lowercase letters, digits \ + and '-', 1 to 64 characters, not starting with '-' (the website's rule)" + )); + } + cli.server_id = Some(id); + } + "--web-port" => { + let raw = take_value(&name, inline, &mut it)?; + cli.web_port = Some(raw.parse::().ok().filter(|p| *p >= 1024).ok_or_else( + || format!("--web-port must be a port from 1024 to 65535, not {raw:?}"), + )?); + } other if other.starts_with('-') => { return Err(format!("unrecognized argument: {other}")) } @@ -212,6 +269,20 @@ pub fn parse>(args: I) -> Result { } } + // The Rust flags belong to `--game rust` only. Silently ignoring one on a ServUO run would leave + // an operator believing they had named a server they had not. + if cli.game == Game::ServUo { + for (flag, set) in [ + ("--rust", cli.rust.is_some()), + ("--server-id", cli.server_id.is_some()), + ("--web-port", cli.web_port.is_some()), + ] { + if set { + return Err(format!("{flag} needs --game rust")); + } + } + } + match command { Some(c) => cli.mode = Mode::Run(c), // No verb is not an error worth an exit code — it is someone typing the binary's name to @@ -221,6 +292,17 @@ pub fn parse>(args: I) -> Result { Ok(cli) } +/// The website's rule for a server id, `^[a-z0-9][a-z0-9-]{0,63}$`. The same rule the plugin and the +/// egg apply, so an id that passes here is one all three accept. +pub fn valid_server_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 64 + && !id.starts_with('-') + && id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + /// Pulls a flag's value, from `--flag=value` or from the next token. /// /// A missing value is an error rather than a default: `--servuo` with nothing after it would @@ -339,6 +421,50 @@ mod tests { assert!(parse_str(&["install", "update"]).is_err()); } + #[test] + fn servuo_stays_the_default_game() { + assert_eq!(parse_str(&["install"]).unwrap().game, Game::ServUo); + let rust = parse_str(&[ + "install", + "--game", + "rust", + "--rust", + "/srv/rust", + "--server-id", + "alpha", + "--web-port=8091", + ]) + .unwrap(); + assert_eq!(rust.game, Game::Rust); + assert_eq!(rust.server_id.as_deref(), Some("alpha")); + assert_eq!(rust.web_port, Some(8091)); + } + + #[test] + fn a_rust_flag_without_game_rust_is_refused() { + for args in [ + vec!["install", "--rust", "/srv/rust"], + vec!["doctor", "--server-id", "alpha"], + vec!["install", "--web-port", "8091"], + ] { + let err = parse_str(&args).unwrap_err(); + assert!(err.contains("--game rust"), "{args:?}: {err}"); + } + } + + #[test] + fn a_server_id_follows_the_websites_rule() { + for good in ["main", "alpha", "eu-2", "0", &"a".repeat(64)] { + assert!(valid_server_id(good), "{good}"); + } + for bad in ["", "-a", "Alpha", "a_b", "a.b", &"a".repeat(65)] { + assert!(!valid_server_id(bad), "{bad}"); + } + assert!(parse_str(&["install", "--game", "rust", "--server-id", "Bad"]).is_err()); + assert!(parse_str(&["install", "--game", "rust", "--web-port", "80"]).is_err()); + assert!(parse_str(&["install", "--game", "minecraft"]).is_err()); + } + #[test] fn order_does_not_matter() { let a = parse_str(&["--verify", "install", "--yes"]).unwrap(); diff --git a/src/doctor.rs b/src/doctor.rs index c309197..dd5c34f 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -68,7 +68,7 @@ pub struct Row { } impl Row { - fn new(mark: Mark, label: &str, detail: impl Into) -> Self { + pub(crate) fn new(mark: Mark, label: &str, detail: impl Into) -> Self { Self { mark, label: label.to_string(), @@ -77,24 +77,24 @@ impl Row { } } - fn ok(label: &str, detail: impl Into) -> Self { + pub(crate) fn ok(label: &str, detail: impl Into) -> Self { Self::new(Mark::Ok, label, detail) } - fn warn(label: &str, detail: impl Into) -> Self { + pub(crate) fn warn(label: &str, detail: impl Into) -> Self { Self::new(Mark::Warn, label, detail) } - fn fail(label: &str, detail: impl Into) -> Self { + pub(crate) fn fail(label: &str, detail: impl Into) -> Self { Self::new(Mark::Fail, label, detail) } - fn note(mut self, note: impl Into) -> Self { + pub(crate) fn note(mut self, note: impl Into) -> Self { self.notes.push(note.into()); self } - fn notes_from(mut self, notes: impl IntoIterator) -> Self { + pub(crate) fn notes_from(mut self, notes: impl IntoIterator) -> Self { self.notes.extend(notes); self } @@ -166,8 +166,14 @@ pub fn run(cli: &Cli) -> Result { rows.push(backup_row(&layout)); // ── Report ─────────────────────────────────────────────────────────────── + Ok(report(&rows)) +} + +/// Prints the rows and the one-line verdict, and returns the exit code: `1` when anything failed. +/// Shared with `doctor --game rust`, so both games' reports read the same way. +pub(crate) fn report(rows: &[Row]) -> i32 { println!(); - for row in &rows { + for row in rows { println!("{} {:<24} {}", row.mark.glyph(), row.label, row.detail); for note in &row.notes { println!(" {note}"); @@ -185,7 +191,11 @@ pub fn run(cli: &Cli) -> Result { INSTALL.md's Troubleshooting table is keyed to these symptoms." ), } - Ok(if failed > 0 { 1 } else { 0 }) + if failed > 0 { + 1 + } else { + 0 + } } /// The ServUO root to inspect: `--servuo` if given, else the one the record names. @@ -548,7 +558,7 @@ fn live_config(link: &LinkRecord) -> Option { /// /// Always over loopback, never over the configured bind: `[web] bind` is regularly `0.0.0.0`, and /// this check is about whether the process on *this* host is answering. -fn health_of(bind: &str) -> Option { +pub(crate) fn health_of(bind: &str) -> Option { let port = bind.rsplit_once(':').map(|(_, p)| p).unwrap_or(bind); let url = format!("http://127.0.0.1:{port}/health"); let body = net::get_text_within(&url, HEALTH_TIMEOUT).ok()?; diff --git a/src/install.rs b/src/install.rs index 254f746..4b489e9 100644 --- a/src/install.rs +++ b/src/install.rs @@ -694,7 +694,7 @@ fn preflight_writable(layout: &paths::Layout) -> Result<()> { /// Only ever printed, never connected to — which is why a non-interactive run falls back to the /// detected name instead of failing. Getting it wrong costs the operator one edit in Admin → Shard; /// aborting a completed install over an unanswerable prompt costs them the whole run. -fn resolve_host(cli: &Cli) -> String { +pub(crate) fn resolve_host(cli: &Cli) -> String { if let Some(host) = &cli.host { return host.clone(); } diff --git a/src/lib.rs b/src/lib.rs index a832142..7a02b4b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,12 @@ //! `install.json`, the uo-link sidecar and its service, the token handoff, and the day-two //! commands `doctor`, `update` and `uninstall`. //! +//! **`--game rust`** (module-rust phase 18, docs/modules/rust/PLAN.md §34) runs the same four verbs +//! for Rust servers, in [`rustgame`]: schema-2 Rust bundles, named instances, a Rust-Link sidecar +//! and service per instance, and the plugin placed for Oxide or Carbon. ServUO's pipeline, files +//! and record are untouched by it; ServUO bundles are now read at schema 2 with schema 1 as the +//! fallback ([`bundle::fetch`]). +//! //! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same //! convention as the sidecar's CLI. `doctor` additionally uses `1` for a *completed* run that //! found something broken, so it can be read by a monitoring script; a `⚠` row never does that. @@ -39,6 +45,7 @@ pub mod overlay; pub mod patch; pub mod paths; pub mod record; +pub mod rustgame; pub mod service; pub mod servuo; pub mod sidecar; @@ -76,6 +83,7 @@ pub fn run() -> i32 { println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION")); Ok(0) } + Mode::Run(command) if parsed.game == cli::Game::Rust => rustgame::run(&parsed, command), Mode::Run(Command::Install) => install::run(&parsed).map(|()| 0), Mode::Run(Command::Update) => update::run(&parsed).map(|()| 0), Mode::Run(Command::Doctor) => doctor::run(&parsed), diff --git a/src/paths.rs b/src/paths.rs index ed73b5c..0c56ccb 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -29,6 +29,9 @@ pub struct Layout { pub data_dir: PathBuf, /// `/usr/bin/runicgateway-link` — the installed sidecar binary. pub sidecar_bin: PathBuf, + /// `/usr/bin/runicgateway-rust-link` — the rust-link sidecar, one binary for every Rust + /// instance on the host (docs/modules/rust/PLAN.md §34.2.3). + pub rust_sidecar_bin: PathBuf, /// This layout came from [`STATE_DIR_ENV`], so it describes a test run rather than a real /// deployment. Service registration is skipped when it is set — see [`layout`]. pub relocated: bool, @@ -83,6 +86,53 @@ impl Layout { pub fn systemd_unit(&self) -> PathBuf { PathBuf::from("/etc/systemd/system").join(crate::service::SYSTEMD_UNIT) } + + // ── Rust ───────────────────────────────────────────────────────────────── + // + // Rust's files live under a `rust/` of their own, beside ServUO's rather than among them, so a + // host running both games has two records that cannot corrupt each other (§34.4) and an + // uninstall of one never reaches the other. + + /// `/etc/runicgateway/rust/install.json` — the Rust record: the bundle and every instance. + pub fn rust_record(&self) -> PathBuf { + self.state_dir.join("rust").join("install.json") + } + + /// Where instance configs live on Linux — the directory the template unit names with `%i`. + pub fn rust_config_dir(&self) -> PathBuf { + self.state_dir.join("rust") + } + + /// One instance's `sidecar.toml`. + /// + /// **Linux:** `/etc/runicgateway/rust/.toml`, beside its siblings, because the template + /// unit derives the path from the instance name. **Windows:** inside the instance's own + /// directory, because a Windows service logs beside its config — two instances sharing a + /// directory would share, and fight over, one log file. + pub fn rust_config(&self, server_id: &str) -> PathBuf { + if cfg!(windows) { + self.rust_data_dir(server_id).join("sidecar.toml") + } else { + self.rust_config_dir().join(format!("{server_id}.toml")) + } + } + + /// `/var/lib/runicgateway/rust/` — one instance's store (and, on Windows, its config and + /// logs). + pub fn rust_data_dir(&self, server_id: &str) -> PathBuf { + self.data_dir.join("rust").join(server_id) + } + + /// One instance's SQLite store. Written into its config as an absolute path, so the store never + /// depends on anybody's working directory or on a shared default name. + pub fn rust_db(&self, server_id: &str) -> PathBuf { + self.rust_data_dir(server_id).join("rust-link.db") + } + + /// The shared systemd template for every Rust instance. + pub fn rust_template_unit(&self) -> PathBuf { + PathBuf::from("/etc/systemd/system").join(crate::service::RUST_TEMPLATE_UNIT) + } } /// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`]. @@ -107,8 +157,14 @@ pub fn layout() -> Layout { .file_name() .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("uo-link-sidecar")); + let rust_bin_name = layout + .rust_sidecar_bin + .file_name() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("rust-link-sidecar")); layout.data_dir = root.join("data"); layout.sidecar_bin = root.join("bin").join(bin_name); + layout.rust_sidecar_bin = root.join("bin").join(rust_bin_name); layout.state_dir = root; layout.relocated = true; } @@ -134,6 +190,9 @@ fn platform_layout() -> Layout { sidecar_bin: program_files .join("RunicGateway") .join("uo-link-sidecar.exe"), + rust_sidecar_bin: program_files + .join("RunicGateway") + .join("rust-link-sidecar.exe"), relocated: false, } } @@ -144,6 +203,7 @@ fn platform_layout() -> Layout { state_dir: PathBuf::from("/etc/runicgateway"), data_dir: PathBuf::from("/var/lib/runicgateway"), sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"), + rust_sidecar_bin: PathBuf::from("/usr/bin/runicgateway-rust-link"), relocated: false, } } @@ -185,6 +245,25 @@ mod tests { } } + #[test] + fn two_rust_instances_share_nothing_but_the_binary() { + // The failure this prevents: two instances on one database, one token file, or (on + // Windows) one log file. + let l = platform_layout(); + assert_ne!(l.rust_config("alpha"), l.rust_config("beta")); + assert_ne!(l.rust_db("alpha"), l.rust_db("beta")); + // A Windows service logs beside its config, so there each instance needs its own directory. + if cfg!(windows) { + assert_ne!( + l.rust_config("alpha").parent(), + l.rust_config("beta").parent() + ); + } + assert_ne!(l.rust_record(), l.install_record()); + assert_ne!(l.rust_sidecar_bin, l.sidecar_bin); + assert!(l.rust_db("alpha").is_absolute()); + } + #[test] fn a_relocated_layout_moves_the_binary_too() { // The failure this prevents: a test run that writes its config and database under the diff --git a/src/rustgame/doctor.rs b/src/rustgame/doctor.rs new file mode 100644 index 0000000..f6c5635 --- /dev/null +++ b/src/rustgame/doctor.rs @@ -0,0 +1,297 @@ +//! `doctor --game rust` — every recorded instance, or one with `--server-id`. +//! +//! The same rule as ServUO's doctor: every row asks the host what is true now, and the record only +//! says where to look. Per instance (docs/modules/rust/PLAN.md §34.2.3): +//! +//! - the framework, and whether the plugin file is still the one deployed; +//! - that the plugin's config names this instance; +//! - `requires_plugins` present, as a warning (D153); +//! - the service registered and running; +//! - `/health`: reachable, the protocol, and whether the plugin is connected. +//! +//! **The framework version floor is not measured from files.** A plugin that compiled and connected +//! on this framework is the fact a floor only approximates, so a connected plugin is the pass, and +//! the floors are printed beside a plugin that is not. + +use std::path::Path; + +use anyhow::Result; + +use super::plugin; +use super::record::{Instance, RustRecord}; +use super::server; +use crate::cli::Cli; +use crate::doctor::{health_of, report, Row}; +use crate::{bundle, paths, service, ui}; + +pub fn run(cli: &Cli) -> Result { + let layout = paths::layout(); + let record_path = layout.rust_record(); + println!( + "\nRunic Gateway installer {} — doctor (Rust)", + env!("CARGO_PKG_VERSION") + ); + + let Some(record) = RustRecord::load(&record_path)? else { + println!(); + ui::warn(&format!( + "No Rust deployment is recorded on this host.\n Looked for {}\n Run `install --game \ + rust` first. If you installed with {} set, set it again for this run.", + record_path.display(), + paths::STATE_DIR_ENV + )); + return Ok(1); + }; + + let mut rows = vec![Row::ok( + "Rust record", + format!( + "{} (bundle {}, {} instance(s), installer {})", + record_path.display(), + record.bundle.tag, + record.instances.len(), + record.installer.version + ), + )]; + + let selected: Vec<(&String, &Instance)> = match &cli.server_id { + Some(id) => match record.instances.get_key_value(id) { + Some(pair) => vec![pair], + None => { + rows.push(Row::fail( + "Instance", + format!( + "{id:?} is not recorded (recorded: {})", + record + .instances + .keys() + .cloned() + .collect::>() + .join(", ") + ), + )); + return Ok(report(&rows)); + } + }, + None => record.instances.iter().collect(), + }; + + // The floors, fetched once, for the note beside a plugin that has not connected. Read from the + // installed bundle rather than the current one: they describe the plugin that is installed. + let installed_bundle = bundle::fetch_rust(Some(&record.bundle.tag)).ok(); + + for (id, instance) in selected { + rows.extend(instance_rows( + id, + instance, + &record, + installed_bundle.as_ref().map(|b| &b.0), + )); + } + rows.push(bundle_row(&record)); + Ok(report(&rows)) +} + +fn instance_rows( + id: &str, + instance: &Instance, + record: &RustRecord, + bundle: Option<&bundle::RustBundle>, +) -> Vec { + let mut rows = Vec::new(); + let label = |what: &str| format!("{id}: {what}"); + + // ── The server and its framework ───────────────────────────────────────── + let server = server::open(Path::new(&instance.server_root)); + match &server { + Ok(s) if s.framework.as_str() == instance.framework => rows.push(Row::ok( + &label("server"), + format!("{} ({})", s.path.display(), instance.framework), + )), + Ok(s) => rows.push( + Row::fail( + &label("server"), + format!( + "{} now runs {}, but the plugin was installed for {}", + s.path.display(), + s.framework.as_str(), + instance.framework + ), + ) + .note(format!( + "reinstall it: install --game rust --server-id {id} --rust {}", + s.path.display() + )), + ), + Err(e) => rows.push(Row::fail(&label("server"), e.to_string())), + } + + // ── The plugin file: ours ──────────────────────────────────────────────── + let plugin_path = Path::new(&instance.plugin_path); + match std::fs::read(plugin_path) { + Ok(bytes) if crate::util::sha256_bytes(&bytes) == instance.plugin_sha256 => { + rows.push(Row::ok( + &label("plugin"), + format!( + "{} (Rust-Plugins {})", + instance.plugin_path, record.plugin.tag + ), + )) + } + Ok(_) => rows.push( + Row::fail( + &label("plugin"), + format!("{} is not the file that was deployed", instance.plugin_path), + ) + .note("edited or replaced by hand; `update --game rust` puts the released one back"), + ), + Err(_) => rows.push(Row::fail( + &label("plugin"), + format!("{} is missing", instance.plugin_path), + )), + } + + // ── The plugin's config: the website's ─────────────────────────────────── + match plugin::read_config(Path::new(&instance.plugin_config)) { + Ok(Some(view)) if view.server_id.as_deref() == Some(id) => rows.push(Row::ok( + &label("plugin config"), + format!( + "ServerId {id}, Port {}", + view.port + .map(|p| p.to_string()) + .unwrap_or_else(|| "default".into()) + ), + )), + Ok(Some(view)) => rows.push(Row::fail( + &label("plugin config"), + format!( + "{} says ServerId {:?}; this instance is {id:?}", + instance.plugin_config, + view.server_id.unwrap_or_else(|| "main (default)".into()) + ), + )), + Ok(None) => rows.push(Row::warn( + &label("plugin config"), + format!( + "{} is gone — the plugin will write one with ServerId \"main\" on its next load", + instance.plugin_config + ), + )), + Err(e) => rows.push(Row::fail(&label("plugin config"), e.to_string())), + } + + // ── Third-party plugins: reported, never installed (D153) ──────────────── + if let (Ok(s), Some(b)) = (&server, bundle) { + let missing = plugin::missing_plugins(&s.plugins_dir(), &b.payload.compat.requires_plugins); + rows.push(if missing.is_empty() { + Row::ok( + &label("required plugins"), + b.payload.compat.requires_plugins.join(", "), + ) + } else { + Row::warn( + &label("required plugins"), + format!( + "missing {} in {}", + missing.join(", "), + s.plugins_dir().display() + ), + ) + .note("install from uMod; the features that use them stay off until then") + }); + } + + // ── The service ────────────────────────────────────────────────────────── + match &instance.service { + Some(svc) => { + let status = service::observe(&svc.kind, &svc.name); + let detail = format!("{} {}", svc.name, status.detail); + rows.push(if status.running && status.enabled { + Row::ok(&label("service"), detail) + } else { + Row::fail(&label("service"), detail) + }); + } + None => rows.push(Row::fail( + &label("service"), + "none registered — the install printed the steps to register it by hand", + )), + } + + // ── The sidecar, and the plugin through it ─────────────────────────────── + let health = health_of(&format!("127.0.0.1:{}", instance.web_port)); + let Some(health) = health else { + rows.push(Row::fail( + &label("sidecar"), + format!( + "nothing answered http://127.0.0.1:{}/health", + instance.web_port + ), + )); + return rows; + }; + rows.push(Row::ok( + &label("sidecar"), + format!( + "127.0.0.1:{} /health {}, database {}", + instance.web_port, + health.status.as_deref().unwrap_or("ok"), + health.database.as_deref().unwrap_or("unknown") + ), + )); + match health.protocol { + Some(p) if p == record.bundle.protocol => { + rows.push(Row::ok(&label("protocol"), p.to_string())) + } + Some(p) => rows.push(Row::fail( + &label("protocol"), + format!( + "the sidecar speaks {p}; bundle {} is {}", + record.bundle.tag, record.bundle.protocol + ), + )), + None => rows.push(Row::warn(&label("protocol"), "the sidecar did not say")), + } + let floors = bundle + .map(|b| { + b.payload + .compat + .frameworks + .iter() + .map(|(f, v)| format!("{f} {}", v.min_version)) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + let running = server.as_ref().is_ok_and(|s| server::is_running(&s.path)); + rows.push(match (health.plugin_connected, running) { + (Some(true), _) => Row::ok(&label("plugin connected"), "yes"), + (_, false) => Row::warn(&label("plugin connected"), "no — the server is not running"), + (_, true) => { + Row::fail(&label("plugin connected"), "no, and the server is running").note(format!( + "the plugin has not dialled 127.0.0.1:{}. Check the server console for a compile \ + error; the plugin is known good on {floors}", + instance.game_port + )) + } + }); + rows +} + +/// Whether a newer Rust bundle than the installed one is published. +fn bundle_row(record: &RustRecord) -> Row { + match bundle::fetch_rust(None) { + Ok((current, _)) if current.bundle == record.bundle.tag => { + Row::ok("Bundle", format!("{} is current", record.bundle.tag)) + } + Ok((current, _)) => Row::warn( + "Bundle", + format!( + "{} is installed; {} is published", + record.bundle.tag, current.bundle + ), + ) + .note("update --game rust moves every instance to it"), + Err(e) => Row::warn("Bundle", format!("could not check for a newer one: {e}")), + } +} diff --git a/src/rustgame/install.rs b/src/rustgame/install.rs new file mode 100644 index 0000000..7daa25c --- /dev/null +++ b/src/rustgame/install.rs @@ -0,0 +1,750 @@ +//! `install --game rust` and `update --game rust` — one pipeline, as for ServUO. +//! +//! docs/modules/rust/PLAN.md §34.2.3. What an instance is: one Rust server root, its plugin, and a +//! sidecar of its own — service, config, database, game port and web port — named by `--server-id` +//! (D148). What instances share: the one sidecar binary and the one bundle, so `update` moves every +//! instance on the host together (§34.4). +//! +//! The order follows ServUO's, for the same reasons: +//! +//! 1. **Resolve everything that can fail cheaply first** — the bundle, this host's binary, the +//! server root and its framework, the plugin's config and the ports — so a run that cannot +//! finish ends before anything is written. +//! 2. **The sidecar before the plugin.** Both frameworks load a plugin file the moment it lands, +//! even on a running server (§34.2.3), so the sidecar it dials is registered first. +//! 3. **Provision the config before registering the service**, record last, handoff after that. + +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; + +use super::plugin::{self, ConfigView}; +use super::record::{ComponentRecord, Instance, RustRecord, SCHEMA}; +use super::server::{self, RustServer}; +use super::sidecar; +use crate::bundle::{self, RustBundle}; +use crate::cli::Cli; +use crate::install::Mode; +use crate::record::{now_rfc3339, BinaryRef, BundleRef, InstallerInfo, ServiceRecord}; +use crate::sidecar::BinaryAction; +use crate::util::{write_atomic, TempDir}; +use crate::{paths, service, ui}; + +/// The plugin's own default game port, and where the search for a free one starts. +const GAME_PORT_START: u16 = 7799; +/// Where the search for a free web port starts. Clear of ServUO's uo-link default (8080). +const WEB_PORT_START: u16 = 8090; + +/// One instance this run is about, with every decision made and nothing written. +struct Planned { + id: String, + server: RustServer, + running: bool, + plugin_config: Option, + plugin_action: BinaryAction, + game_port: u16, + web_port: u16, + config_path: PathBuf, + db_path: PathBuf, + config_exists: bool, +} + +pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { + let layout = paths::layout(); + let record_path = layout.rust_record(); + let prior = RustRecord::load(&record_path)?; + + // ── Which instances ────────────────────────────────────────────────────── + let targets: Vec<(String, PathBuf)> = match mode { + Mode::Install => { + let id = cli.server_id.clone().ok_or_else(|| { + anyhow::anyhow!( + "install --game rust needs --server-id : the id the website will know this \ + server by, which also names its sidecar. For example:\n \ + runicgateway-installer install --game rust --rust /srv/rust --server-id main" + ) + })?; + let recorded = prior + .as_ref() + .and_then(|p| p.instances.get(&id)) + .map(|i| PathBuf::from(&i.server_root)); + let root = match (&cli.rust, recorded) { + (Some(path), _) => PathBuf::from(path), + (None, Some(root)) => root, + (None, None) => bail!( + "install --game rust needs --rust : the Rust server root, the directory \ + holding RustDedicated" + ), + }; + vec![(id, root)] + } + Mode::Update => { + if cli.server_id.is_some() || cli.rust.is_some() { + bail!( + "update --game rust moves every Rust instance on this host together — they \ + share one sidecar binary, and a host whose instances spoke two protocols is one \ + no bundle describes. Run it without --server-id or --rust." + ); + } + let Some(prior) = prior.as_ref() else { + bail!( + "nothing to update — no Rust deployment is recorded on this host (looked for {}). \ + Run `install --game rust` first.", + record_path.display() + ); + }; + prior + .instances + .iter() + .map(|(id, i)| (id.clone(), PathBuf::from(&i.server_root))) + .collect() + } + }; + + // ── What to install ────────────────────────────────────────────────────── + let (bundle, bundle_url) = bundle::fetch_rust(cli.bundle.as_deref())?; + let asset = bundle.sidecar_asset()?.clone(); + println!( + "\nRunic Gateway installer {} — Rust {} to bundle {} (protocol {}){}", + env!("CARGO_PKG_VERSION"), + if mode.is_update() { + "update" + } else { + "install" + }, + bundle.bundle, + bundle.protocol, + if cli.verify { + " [--verify: nothing will be written]" + } else { + "" + } + ); + println!(); + ui::row( + "Sidecar", + &format!( + "{:<24} protocol {}", + format!("rust-link {}", bundle.sidecar.tag), + bundle.sidecar.protocol + ), + ); + ui::row( + "Plugin", + &format!( + "{:<24} protocol {}", + format!("Rust-Plugins {}", bundle.payload.tag), + bundle.payload.protocol + ), + ); + + // The plugin is fetched before anything is planned: its manifest is the last statement of the + // protocol to check, and a pair that disagrees must stop the run before any file moves. + let scratch = TempDir::new("runicgateway-rust")?; + let tarball = scratch.path().join(&bundle.payload.asset.name); + crate::net::download_verified( + &bundle.payload.asset.url, + &tarball, + &bundle.payload.asset.sha256, + )?; + let released = plugin::read_tarball(&tarball)?; + if released.manifest.version != bundle.payload.version { + ui::warn(&format!( + "the plugin tarball says version {} but bundle {} names {}. The checksum matched, so \ + this is a labelling mismatch in the release rather than a wrong download.", + released.manifest.version, bundle.bundle, bundle.payload.version + )); + } + if released.manifest.protocol != bundle.protocol { + bail!( + "the plugin in bundle {} declares protocol {}, and the bundle {}. The Rust game link \ + has no version handshake, so a mismatched plugin mis-parses rather than being \ + refused — refusing the pair here.", + bundle.bundle, + released.manifest.protocol, + bundle.protocol + ); + } + + // ── Plan every instance ────────────────────────────────────────────────── + let mut planned = Vec::new(); + for (id, root) in &targets { + planned.push(plan_instance( + cli, + &layout, + prior.as_ref(), + id, + root, + &released, + )?); + } + let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?; + print_plan(&layout, &planned, binary_action, &bundle); + + if cli.verify { + println!( + "\nNothing was written. Re-run without --verify to {}.", + if mode.is_update() { "update" } else { "deploy" } + ); + return Ok(()); + } + + preflight_writable(&layout)?; + + // ── The sidecar binary, shared by every instance ───────────────────────── + let prepared = service::prepare(layout.relocated); + let binary_sha256 = if binary_action.writes() { + // Every instance runs this one file: on Windows it is locked while any of them runs, and on + // Linux replacing it leaves each one serving old code until restarted. + let mut running: Vec = prior + .as_ref() + .map(|p| p.instances.keys().cloned().collect()) + .unwrap_or_default(); + running.extend(planned.iter().map(|p| p.id.clone())); + running.sort(); + running.dedup(); + service::stop_rust_instances(&prepared.manager, &running)?; + let sha = crate::sidecar::place(&asset, &layout.rust_sidecar_bin, scratch.path())?; + ui::ok(&format!( + "rust-link binary verified sha256 {}…", + &asset.sha256[..8.min(asset.sha256.len())] + )); + sha + } else { + asset.sha256.trim().to_ascii_lowercase() + }; + + // ── Each instance ──────────────────────────────────────────────────────── + let mut record = prior + .clone() + .unwrap_or_else(|| empty_record(&bundle, &bundle_url)); + let mut handoffs = Vec::new(); + for plan in &planned { + let (instance, doc, registered) = deploy_instance( + &layout, + &prepared, + &bundle, + &released, + plan, + binary_action.writes(), + prior + .as_ref() + .and_then(|p| p.instances.get(&plan.id)) + .map(|i| i.plugin_config_written) + .unwrap_or(false), + )?; + record.instances.insert(plan.id.clone(), instance); + handoffs.push((plan.id.clone(), doc, registered)); + } + + // Instances this run did not touch still run the binary it just replaced. + if binary_action.writes() { + if let Some(prior) = &prior { + for (id, instance) in &prior.instances { + if planned.iter().any(|p| &p.id == id) || instance.service.is_none() { + continue; + } + match service::restart_rust_instance(id) { + Ok(()) => ui::ok(&format!( + "restarted {} on the new binary", + service::rust_service_name(id) + )), + Err(e) => ui::warn(&format!("could not restart {id} on the new binary: {e}")), + } + } + } + } + + // ── Record ─────────────────────────────────────────────────────────────── + record.schema = SCHEMA; + record.installer = InstallerInfo { + version: env!("CARGO_PKG_VERSION").to_string(), + }; + record.updated = now_rfc3339(); + record.bundle = BundleRef { + tag: bundle.bundle.clone(), + protocol: bundle.protocol, + url: bundle_url.clone(), + }; + record.sidecar = component( + &bundle.sidecar.repo, + &bundle.sidecar.tag, + &bundle.sidecar.version, + "", + bundle.sidecar.protocol, + ); + record.binary = BinaryRef { + path: layout.rust_sidecar_bin.display().to_string(), + sha256: binary_sha256, + }; + record.plugin = component( + &bundle.payload.repo, + &bundle.payload.tag, + &bundle.payload.version, + &bundle.payload.commit, + bundle.payload.protocol, + ); + record.service_user_created |= prepared.user_created; + + match prior.as_ref() { + Some(previous) if previous.same_deployment_as(&record) => { + println!("\n {} unchanged", record_path.display()); + } + _ => { + record.save(&record_path).with_context(|| { + format!( + "cannot write {} — run as root/Administrator, or set {} for a test run", + record_path.display(), + paths::STATE_DIR_ENV + ) + })?; + println!("\n Recorded {}", record_path.display()); + } + } + + // ── What is left for the operator ──────────────────────────────────────── + match mode { + Mode::Install => { + let host = crate::install::resolve_host(cli); + for (id, doc, registered) in &handoffs { + println!( + "{}", + sidecar::handoff(id, doc, &host, cli.site_url.as_deref(), *registered) + ); + } + } + Mode::Update => { + let before = prior.as_ref().map(|p| p.bundle.clone()); + match before { + Some(b) if b.tag == bundle.bundle => { + println!("\nAlready on bundle {} — nothing moved.", bundle.bundle) + } + Some(b) => { + println!( + "\nMoved {} instance(s) from bundle {} to {}.", + planned.len(), + b.tag, + bundle.bundle + ); + if b.protocol != bundle.protocol { + ui::warn(&format!( + "The protocol moved from {} to {}. The website's Rust module must speak \ + {} too, or it will be refused with 409 — update the site first.", + b.protocol, bundle.protocol, bundle.protocol + )); + } + } + None => {} + } + } + } + Ok(()) +} + +/// Every decision for one instance, made without writing anything. +fn plan_instance( + cli: &Cli, + layout: &paths::Layout, + prior: Option<&RustRecord>, + id: &str, + root: &Path, + released: &plugin::Released, +) -> Result { + let server = server::open(root) + .with_context(|| format!("cannot use {} as a Rust server root", root.display()))?; + let recorded = prior.and_then(|p| p.instances.get(id)); + + if let Some(instance) = recorded { + if Path::new(&instance.server_root) != server.path { + bail!( + "instance {id:?} is recorded for {}, not {}. An id names one server; uninstall it \ + first (`uninstall --game rust --server-id {id}`) to move it.", + instance.server_root, + server.path.display() + ); + } + } + if let Some((other, _)) = prior.and_then(|p| { + p.instances + .iter() + .find(|(other, i)| other.as_str() != id && Path::new(&i.server_root) == server.path) + }) { + bail!( + "{} is already installed as instance {other:?}. One server root has one plugin and one \ + ServerId; run with --server-id {other}.", + server.path.display() + ); + } + + let plugin_config = plugin::read_config(&server.plugin_config_path())?; + let held_game = prior.map(|p| p.game_ports_except(id)).unwrap_or_default(); + let game_port = match &plugin_config { + Some(view) => { + plugin::check_server_id(view, id, &server.plugin_config_path())?; + // A config without a Port gets the plugin's default. + let port = view.port.unwrap_or(GAME_PORT_START); + if held_game.contains(&port) { + bail!( + "{} says the plugin dials port {port}, which another Rust instance on this host \ + already listens on. Give it a free Port and run again.", + server.plugin_config_path().display() + ); + } + port + } + None => sidecar::choose_port(GAME_PORT_START, &held_game, recorded.map(|i| i.game_port))?, + }; + + let held_web = prior.map(|p| p.web_ports_except(id)).unwrap_or_default(); + if let Some(port) = cli.web_port { + if held_web.contains(&port) { + bail!("--web-port {port} is already another Rust instance's web port"); + } + } + let web_port = match (cli.web_port, recorded) { + (Some(port), _) => port, + (None, Some(instance)) => instance.web_port, + (None, None) => sidecar::choose_port(WEB_PORT_START, &held_web, None)?, + }; + + let plugin_action = match std::fs::read(server.plugin_path()) { + Err(_) => BinaryAction::Install, + Ok(bytes) if crate::util::sha256_bytes(&bytes) == released.sha256 => { + BinaryAction::Unchanged + } + Ok(_) => BinaryAction::Replace, + }; + let config_path = layout.rust_config(id); + Ok(Planned { + id: id.to_string(), + running: server::is_running(&server.path), + config_exists: config_path.exists(), + config_path, + db_path: layout.rust_db(id), + server, + plugin_config, + plugin_action, + game_port, + web_port, + }) +} + +fn print_plan( + layout: &paths::Layout, + planned: &[Planned], + binary: BinaryAction, + bundle: &RustBundle, +) { + ui::row( + "binary", + &format!("{} {}", layout.rust_sidecar_bin.display(), binary.label()), + ); + for p in planned { + ui::heading(&format!("Instance {}", p.id)); + ui::row( + "server", + &format!( + "{} ({}, {})", + p.server.path.display(), + p.server.framework.as_str(), + if p.running { + "running — the plugin loads at once" + } else { + "not running — the plugin loads at next boot" + } + ), + ); + ui::row( + "plugin", + &format!( + "{} {}", + p.server.plugin_path().display(), + p.plugin_action.label() + ), + ); + ui::row( + "plugin config", + &match &p.plugin_config { + Some(_) => format!( + "{} kept (the website's)", + p.server.plugin_config_path().display() + ), + None => format!( + "{} written: ServerId {}, Port {}", + p.server.plugin_config_path().display(), + p.id, + p.game_port + ), + }, + ); + ui::row( + "sidecar config", + &format!( + "{} {}", + p.config_path.display(), + if p.config_exists { "kept" } else { "written" } + ), + ); + ui::row( + "ports", + &format!( + "game 127.0.0.1:{} web 127.0.0.1:{}", + p.game_port, p.web_port + ), + ); + let missing = plugin::missing_plugins( + &p.server.plugins_dir(), + &bundle.payload.compat.requires_plugins, + ); + if !missing.is_empty() { + ui::warn(&format!( + "{} not in {} — the features that use {} stay off until you install {} from uMod. \ + The installer does not fetch third-party plugins (D153).", + missing.join(", "), + p.server.plugins_dir().display(), + if missing.len() == 1 { "it" } else { "them" }, + if missing.len() == 1 { "it" } else { "them" }, + )); + } + } +} + +/// Writes one instance: plugin config, sidecar config, service, plugin — in that order. +fn deploy_instance( + layout: &paths::Layout, + prepared: &service::Prepared, + bundle: &RustBundle, + released: &plugin::Released, + plan: &Planned, + binary_changed: bool, + plugin_config_written_before: bool, +) -> Result<(Instance, sidecar::ConfigDoc, bool)> { + ui::heading(&format!("Instance {}", plan.id)); + let data_dir = layout.rust_data_dir(&plan.id); + std::fs::create_dir_all(&data_dir) + .with_context(|| format!("cannot create {}", data_dir.display()))?; + + // The plugin's config, once, and only if it does not exist (§34.2.3). + let plugin_config_path = plan.server.plugin_config_path(); + let wrote_plugin_config = plan.plugin_config.is_none(); + if wrote_plugin_config { + if let Some(parent) = plugin_config_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("cannot create {}", parent.display()))?; + } + write_atomic( + &plugin_config_path, + plugin::initial_config(&plan.id, plan.game_port).as_bytes(), + )?; + ui::ok(&format!("wrote {}", plugin_config_path.display())); + } + + // The sidecar's config, once; the token is the sidecar's to generate. + if !plan.config_exists { + if let Some(parent) = plan.config_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("cannot create {}", parent.display()))?; + } + write_atomic( + &plan.config_path, + sidecar::instance_config(&plan.id, plan.game_port, plan.web_port, &plan.db_path) + .as_bytes(), + )?; + } + let doc = sidecar::print_config(&layout.rust_sidecar_bin, &plan.config_path)?; + if doc.protocol != bundle.protocol { + bail!( + "the installed rust-link sidecar reports protocol {} but bundle {} was composed at {}. \ + Refusing to register a service for a pair that was never checked together.", + doc.protocol, + bundle.bundle, + bundle.protocol + ); + } + if doc.version != bundle.sidecar.version { + ui::warn(&format!( + "the installed binary reports version {} but bundle {} names {}. The checksum matched, \ + so this is a labelling mismatch in the release rather than a wrong download.", + doc.version, bundle.bundle, bundle.sidecar.version + )); + } + // The sidecar's server_id is a cross-check against the plugin's hello. An edited config that + // names another server makes every hello log a disagreement, so it is said here, once. + if !doc.game.server_id.is_empty() && doc.game.server_id != plan.id { + ui::warn(&format!( + "{} cross-checks server id {:?}, but this instance is {:?}; the sidecar will log a \ + disagreement on every connect.", + doc.config_path, doc.game.server_id, plan.id + )); + } + // An existing sidecar config is the operator's; what it says is what runs. Say so when it + // disagrees with the plugin, because that pair never meets. + if let Some(port) = sidecar::port_of(&doc.game.bind) { + if port != plan.game_port { + ui::warn(&format!( + "{} listens for the plugin on {port}, but the plugin dials {}. Make them agree, or \ + the plugin never connects.", + doc.config_path, plan.game_port + )); + } + } + ui::row( + "sidecar config", + &format!( + "{} {}", + doc.config_path, + if doc.token_generated { + "token generated" + } else { + "token kept" + } + ), + ); + + service::protect_config( + &plan.config_path, + &data_dir, + prepared.user.as_deref(), + layout.relocated, + )?; + let outcome = service::register_rust( + prepared, + layout, + &plan.id, + &plan.config_path, + binary_changed, + )?; + service::grant_service_access(&plan.config_path, &data_dir, &outcome)?; + let service_record = match &outcome { + service::Outcome::Registered { + kind, + name, + user, + state, + .. + } => { + ui::row("service", &format!("{name} {state}")); + Some(ServiceRecord { + kind: (*kind).to_string(), + name: name.clone(), + unit_path: None, + user: user.clone(), + // The account is shared with every instance and possibly with ServUO's sidecar, so + // no single instance's removal may delete it (see `RustRecord`). + user_created: false, + }) + } + service::Outcome::Skipped { reason, manual } => { + ui::warn(&format!( + "service NOT REGISTERED — {reason}.\n The binary and config are in place; nothing is \ + running them. Do this by hand:" + )); + print!("{manual}"); + None + } + }; + + // The plugin last: it loads the moment it lands, and its sidecar is now there to dial. + let plugin_path = plan.server.plugin_path(); + if plan.plugin_action.writes() { + std::fs::create_dir_all(plan.server.plugins_dir()) + .with_context(|| format!("cannot create {}", plan.server.plugins_dir().display()))?; + write_atomic(&plugin_path, &released.source)?; + ui::ok(&format!( + "plugin {} {}", + if plan.plugin_action == BinaryAction::Replace { + "replaced" + } else { + "installed" + }, + plugin_path.display() + )); + } + + Ok(( + Instance { + server_root: plan.server.path.display().to_string(), + framework: plan.server.framework.as_str().to_string(), + plugin_path: plugin_path.display().to_string(), + plugin_sha256: released.sha256.clone(), + plugin_config: plugin_config_path.display().to_string(), + plugin_config_written: wrote_plugin_config || plugin_config_written_before, + game_port: plan.game_port, + web_port: sidecar::port_of(&doc.web.bind).unwrap_or(plan.web_port), + config_path: doc.config_path.clone(), + db_path: doc.store.path.clone(), + service: service_record, + }, + doc, + outcome.registered(), + )) +} + +fn component(repo: &str, tag: &str, version: &str, commit: &str, protocol: u32) -> ComponentRecord { + ComponentRecord { + repo: repo.to_string(), + tag: tag.to_string(), + version: version.to_string(), + commit: commit.to_string(), + protocol, + } +} + +fn empty_record(bundle: &RustBundle, url: &str) -> RustRecord { + RustRecord { + schema: SCHEMA, + installer: InstallerInfo { + version: env!("CARGO_PKG_VERSION").to_string(), + }, + updated: String::new(), + bundle: BundleRef { + tag: bundle.bundle.clone(), + protocol: bundle.protocol, + url: url.to_string(), + }, + sidecar: component("", "", "", "", 0), + binary: BinaryRef { + path: String::new(), + sha256: String::new(), + }, + plugin: component("", "", "", "", 0), + service_user_created: false, + instances: Default::default(), + extra: Default::default(), + } +} + +/// Fails before anything is written if this process cannot write where it must. +fn preflight_writable(layout: &paths::Layout) -> Result<()> { + let bin_dir = layout + .rust_sidecar_bin + .parent() + .unwrap_or(&layout.rust_sidecar_bin) + .to_path_buf(); + for dir in [ + layout.rust_config_dir(), + layout.data_dir.join("rust"), + bin_dir, + ] { + std::fs::create_dir_all(&dir) + .and_then(|_| { + let probe = dir.join(".runicgateway-write-test"); + std::fs::write(&probe, b"")?; + std::fs::remove_file(&probe) + }) + .with_context(|| { + format!( + "cannot write to {}. Run as {}, or set {} for a test run (no service is \ + registered then).", + dir.display(), + if cfg!(windows) { + "Administrator" + } else { + "root (sudo)" + }, + paths::STATE_DIR_ENV + ) + })?; + } + Ok(()) +} diff --git a/src/rustgame/mod.rs b/src/rustgame/mod.rs new file mode 100644 index 0000000..c7b51d1 --- /dev/null +++ b/src/rustgame/mod.rs @@ -0,0 +1,34 @@ +//! `--game rust`: the installer's second game (docs/modules/rust/PLAN.md §34, R4). +//! +//! The same four verbs as ServUO and the same bundle discipline — an exact, protocol-checked pair +//! from CI, never "latest of each" — over a different shape of host. A Rust host commonly runs +//! several servers, and R8 gives each its own sidecar, so where ServUO has one deployment this has +//! **named instances** (D148): one server root, one plugin, and one sidecar service per +//! `--server-id`, all sharing one binary and one bundle. +//! +//! Kept in its own module rather than threaded through the ServUO pipeline: the two share the +//! bundle reader, the download and checksum code, the service machinery and the report format, and +//! share nothing about what is deployed or where. ServUO's files and record are untouched by any +//! of this. + +mod doctor; +mod install; +mod plugin; +mod record; +mod server; +mod sidecar; +mod uninstall; + +use anyhow::Result; + +use crate::cli::{Cli, Command}; + +/// Runs one verb for Rust. The `i32` is the exit code, as for ServUO. +pub fn run(cli: &Cli, command: Command) -> Result { + match command { + Command::Install => install::deploy(cli, crate::install::Mode::Install).map(|()| 0), + Command::Update => install::deploy(cli, crate::install::Mode::Update).map(|()| 0), + Command::Doctor => doctor::run(cli), + Command::Uninstall => uninstall::run(cli), + } +} diff --git a/src/rustgame/plugin.rs b/src/rustgame/plugin.rs new file mode 100644 index 0000000..6ef6b1e --- /dev/null +++ b/src/rustgame/plugin.rs @@ -0,0 +1,238 @@ +//! The plugin: its released tarball, and its config in the server root. +//! +//! Two files, two owners (docs/modules/rust/PLAN.md §34.2.3): +//! +//! - **`RunicGateway.cs` is the installer's.** It comes from the bundle, is replaced when the bundle +//! moves, and is removed by `uninstall`. +//! - **`RunicGateway.json` is the website's.** The plugin writes it, the site edits it through the +//! plugin, and it locks `ServerId`. The installer writes it exactly once — when it does not exist +//! yet, holding just `ServerId` and `Port` — and never rewrites it. An existing one whose +//! `ServerId` is not this instance's refuses the run, naming both. That is the same rule the +//! ServUO overlay follows for `Bridge.cfg`. + +use std::collections::BTreeMap; +use std::io::Read; +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; + +use crate::util::sha256_bytes; + +/// The fixed top directory inside the plugin tarball (Rust-Plugins' release.yml). +const PREFIX: &str = "runicgateway-rust-plugin"; + +/// `runicgateway-rust-plugin/manifest.json`, which the release folds from `overlay.toml`. +#[derive(Debug, Clone, Deserialize)] +pub struct Manifest { + pub version: String, + pub protocol: u32, + #[serde(default)] + pub files: BTreeMap, +} + +/// The released plugin, read out of its tarball. +#[derive(Debug, Clone)] +pub struct Released { + pub manifest: Manifest, + pub source: Vec, + pub sha256: String, +} + +/// Reads the plugin and its manifest out of a downloaded tarball, and checks that the two agree. +pub fn read_tarball(path: &Path) -> Result { + let file = + std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?; + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file)); + let mut manifest: Option> = None; + let mut source: Option> = None; + for entry in archive + .entries() + .context("the plugin tarball is not a tar.gz")? + { + let mut entry = entry.context("the plugin tarball is truncated")?; + let name = entry.path()?.to_string_lossy().replace('\\', "/"); + let slot = if name == format!("{PREFIX}/manifest.json") { + &mut manifest + } else if name == format!("{PREFIX}/{}", super::server::PLUGIN_FILE) { + &mut source + } else { + continue; + }; + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes)?; + *slot = Some(bytes); + } + + let manifest = manifest + .ok_or_else(|| anyhow::anyhow!("the plugin tarball has no {PREFIX}/manifest.json"))?; + let source = source.ok_or_else(|| { + anyhow::anyhow!( + "the plugin tarball has no {PREFIX}/{}", + super::server::PLUGIN_FILE + ) + })?; + let manifest: Manifest = + serde_json::from_slice(&manifest).context("the plugin's manifest.json is unreadable")?; + let sha256 = sha256_bytes(&source); + if let Some(declared) = manifest.files.get(super::server::PLUGIN_FILE) { + if !declared.eq_ignore_ascii_case(&sha256) { + bail!( + "the plugin in the tarball does not match its own manifest (sha256 {sha256}, \ + manifest says {declared}). The tarball matched the bundle's checksum, so the \ + release itself is inconsistent — refusing it." + ); + } + } + Ok(Released { + manifest, + source, + sha256, + }) +} + +/// What the plugin's config says, as far as the installer cares. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigView { + pub server_id: Option, + pub port: Option, +} + +/// Reads `RunicGateway.json`, or `None` when the plugin has never written one. +pub fn read_config(path: &Path) -> Result> { + if !path.exists() { + return Ok(None); + } + let text = + std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?; + let value: serde_json::Value = serde_json::from_str(&text).with_context(|| { + format!( + "{} is not valid JSON. The plugin will not load it either; fix or remove it.", + path.display() + ) + })?; + Ok(Some(ConfigView { + server_id: value + .get("ServerId") + .and_then(|v| v.as_str()) + .map(str::to_string), + port: value + .get("Port") + .and_then(|v| v.as_u64()) + .and_then(|p| u16::try_from(p).ok()), + })) +} + +/// The config the installer writes when there is none: just the two keys it decides. The plugin +/// fills in every other key on its first load, with its own defaults (§34.2.3). +pub fn initial_config(server_id: &str, port: u16) -> String { + let doc = serde_json::json!({ "ServerId": server_id, "Port": port }); + let mut text = serde_json::to_string_pretty(&doc).unwrap_or_default(); + text.push('\n'); + text +} + +/// Refuses an existing config that names another server. +pub fn check_server_id(view: &ConfigView, server_id: &str, path: &Path) -> Result<()> { + match view.server_id.as_deref() { + Some(id) if id == server_id => Ok(()), + Some(id) => bail!( + "{} already names this server {id:?}, and this run was asked to install {server_id:?}.\n \ + The website locks a server's id once it has seen it, and this installer never rewrites \ + the plugin's config. Run again with --server-id {id}, or — if this server really is \ + new to the site — remove that file and run again.", + path.display() + ), + // A config with no ServerId gets the plugin's default ("main") on load. Treat that as what + // it will become rather than as agreement. + None => { + if server_id == "main" { + Ok(()) + } else { + bail!( + "{} has no ServerId, so the plugin will call this server \"main\"; this run was \ + asked to install {server_id:?}. Add \"ServerId\": \"{server_id}\" to it, or run \ + with --server-id main.", + path.display() + ) + } + } + } +} + +/// Which of `required` are missing from a plugins directory. Reported, never installed (D153). +pub fn missing_plugins(plugins_dir: &Path, required: &[String]) -> Vec { + required + .iter() + .filter(|name| !plugins_dir.join(format!("{name}.cs")).is_file()) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::TempDir; + + #[test] + fn the_first_config_holds_only_what_the_installer_decides() { + let text = initial_config("alpha", 7800); + let value: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(value.as_object().unwrap().len(), 2); + assert_eq!(value["ServerId"], "alpha"); + assert_eq!(value["Port"], 7800); + } + + #[test] + fn an_existing_config_for_another_server_refuses_the_run() { + let path = Path::new("oxide/config/RunicGateway.json"); + let view = ConfigView { + server_id: Some("rust-oxide".into()), + port: Some(7799), + }; + assert!(check_server_id(&view, "rust-oxide", path).is_ok()); + let err = check_server_id(&view, "alpha", path) + .unwrap_err() + .to_string(); + assert!( + err.contains("\"rust-oxide\"") && err.contains("\"alpha\""), + "{err}" + ); + } + + #[test] + fn a_config_without_an_id_is_main() { + let path = Path::new("x.json"); + let view = ConfigView { + server_id: None, + port: None, + }; + assert!(check_server_id(&view, "main", path).is_ok()); + assert!(check_server_id(&view, "alpha", path).is_err()); + } + + #[test] + fn the_plugins_config_is_read_back_without_the_rest_of_its_keys() { + let dir = TempDir::new("rg-rust-cfg").unwrap(); + let path = dir.path().join("RunicGateway.json"); + assert_eq!(read_config(&path).unwrap(), None); + // What the rig's plugin actually wrote, 2026-09-26. + std::fs::write( + &path, + r#"{ "Host": "127.0.0.1", "Port": 7799, "QueueCap": 5000, "ServerId": "rust-oxide", + "EventsEnabled": true, "MapMaxBases": 2000 }"#, + ) + .unwrap(); + let view = read_config(&path).unwrap().unwrap(); + assert_eq!(view.server_id.as_deref(), Some("rust-oxide")); + assert_eq!(view.port, Some(7799)); + } + + #[test] + fn a_missing_required_plugin_is_named() { + let dir = TempDir::new("rg-rust-req").unwrap(); + std::fs::write(dir.path().join("Kits.cs"), b"").unwrap(); + let missing = missing_plugins(dir.path(), &["Kits".into(), "ZoneManager".into()]); + assert_eq!(missing, ["ZoneManager"]); + } +} diff --git a/src/rustgame/record.rs b/src/rustgame/record.rs new file mode 100644 index 0000000..bc195cc --- /dev/null +++ b/src/rustgame/record.rs @@ -0,0 +1,208 @@ +//! `rust/install.json` — what this host has deployed for Rust. +//! +//! Its own file rather than a list added to ServUO's `install.json` (docs/modules/rust/PLAN.md +//! §34.4): a host running both games has two records that cannot corrupt each other, and an +//! installer that predates Rust never sees this one at all. +//! +//! One bundle and one sidecar binary for the host, and a map of instances. `update` moves every +//! instance together because they share the binary (§34.4), which is why the bundle is recorded once +//! rather than per instance. Like `install.json`, **the auth tokens are not here** — they live in +//! each instance's `sidecar.toml` — and fields this build has no name for are carried through. + +use std::collections::BTreeMap; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::record::{BinaryRef, BundleRef, InstallerInfo, ServiceRecord}; +use crate::util::write_atomic; + +/// The shape of this document. +pub const SCHEMA: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RustRecord { + pub schema: u32, + pub installer: InstallerInfo, + pub updated: String, + pub bundle: BundleRef, + pub sidecar: ComponentRecord, + pub binary: BinaryRef, + pub plugin: ComponentRecord, + /// This installer created the shared `runicgateway` service user. Kept here rather than on each + /// instance: the account outlives any one instance and may also run ServUO's sidecar, so only + /// the last Rust instance's removal — on a host with no ServUO record — may delete it. + #[serde(default)] + pub service_user_created: bool, + pub instances: BTreeMap, + #[serde(flatten)] + pub extra: BTreeMap, +} + +/// A released component, as installed. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ComponentRecord { + pub repo: String, + pub tag: String, + pub version: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub commit: String, + pub protocol: u32, +} + +/// One Rust server and its sidecar. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Instance { + pub server_root: String, + /// `oxide` or `carbon`, as detected when this was recorded. + pub framework: String, + pub plugin_path: String, + /// What was deployed, so `doctor` can tell an edited plugin file from the release's. + pub plugin_sha256: String, + /// The plugin's config — the website's file, recorded so `doctor` knows where to look. + pub plugin_config: String, + /// The installer wrote that config (it did not exist). Informational; it is kept either way. + #[serde(default)] + pub plugin_config_written: bool, + /// The loopback port the plugin dials — the plugin config's `Port`. + pub game_port: u16, + /// The port the website reaches this instance's sidecar on. + pub web_port: u16, + pub config_path: String, + pub db_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub service: Option, +} + +impl RustRecord { + /// Everything but the timestamp, so a second run with nothing to do writes nothing. + pub fn same_deployment_as(&self, other: &Self) -> bool { + let mut a = self.clone(); + let mut b = other.clone(); + a.updated.clear(); + b.updated.clear(); + a == b + } + + pub fn load(path: &Path) -> Result> { + if !path.exists() { + return Ok(None); + } + let body = std::fs::read_to_string(path) + .with_context(|| format!("cannot read {}", path.display()))?; + let record = serde_json::from_str(&body).with_context(|| { + format!( + "{} exists but is not a record this installer understands. \ + Move it aside to start over, or install a newer installer.", + path.display() + ) + })?; + Ok(Some(record)) + } + + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("cannot create {}", parent.display()))?; + } + let mut body = + serde_json::to_string_pretty(self).context("cannot serialize rust/install.json")?; + body.push('\n'); + write_atomic(path, body.as_bytes()) + } + + /// Game ports held by instances other than `except`. + pub fn game_ports_except(&self, except: &str) -> Vec { + self.instances + .iter() + .filter(|(id, _)| id.as_str() != except) + .map(|(_, i)| i.game_port) + .collect() + } + + /// Web ports held by instances other than `except`. + pub fn web_ports_except(&self, except: &str) -> Vec { + self.instances + .iter() + .filter(|(id, _)| id.as_str() != except) + .map(|(_, i)| i.web_port) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::TempDir; + + pub fn sample() -> RustRecord { + let instance = |root: &str, game: u16, web: u16| Instance { + server_root: root.into(), + framework: "oxide".into(), + plugin_path: format!("{root}/oxide/plugins/RunicGateway.cs"), + plugin_sha256: "ab".repeat(32), + plugin_config: format!("{root}/oxide/config/RunicGateway.json"), + plugin_config_written: true, + game_port: game, + web_port: web, + config_path: "/etc/runicgateway/rust/x.toml".into(), + db_path: "/var/lib/runicgateway/rust/x/rust-link.db".into(), + service: None, + }; + RustRecord { + schema: SCHEMA, + installer: InstallerInfo { + version: "0.3.0".into(), + }, + updated: "2026-09-26T00:00:00Z".into(), + bundle: BundleRef { + tag: "2026.09.26".into(), + protocol: 12, + url: "https://example/v2/rust/current.json".into(), + }, + sidecar: ComponentRecord { + repo: "RunicGateway/Rust-Link".into(), + tag: "v0.1.0".into(), + version: "0.1.0".into(), + commit: String::new(), + protocol: 12, + }, + binary: BinaryRef { + path: "/usr/bin/runicgateway-rust-link".into(), + sha256: "cd".repeat(32), + }, + plugin: ComponentRecord { + repo: "RunicGateway/Rust-Plugins".into(), + tag: "v0.1.0".into(), + version: "0.1.0".into(), + commit: "abc".into(), + protocol: 12, + }, + service_user_created: true, + instances: BTreeMap::from([ + ("alpha".to_string(), instance("/srv/a", 7799, 8090)), + ("beta".to_string(), instance("/srv/b", 7800, 8091)), + ]), + extra: BTreeMap::new(), + } + } + + #[test] + fn a_record_round_trips_and_holds_no_token() { + let dir = TempDir::new("rg-rust-record").unwrap(); + let path = dir.path().join("rust").join("install.json"); + let record = sample(); + record.save(&path).unwrap(); + assert_eq!(RustRecord::load(&path).unwrap().unwrap(), record); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(!text.contains("auth_token") && !text.contains("token\"")); + } + + #[test] + fn ports_held_by_other_instances_exclude_the_one_being_installed() { + let record = sample(); + assert_eq!(record.game_ports_except("alpha"), [7800]); + assert_eq!(record.web_ports_except("beta"), [8090]); + } +} diff --git a/src/rustgame/server.rs b/src/rustgame/server.rs new file mode 100644 index 0000000..f594393 --- /dev/null +++ b/src/rustgame/server.rs @@ -0,0 +1,219 @@ +//! A Rust server root: that it is one, and which modding framework it runs. +//! +//! **The framework is detected, never asked** (docs/modules/rust/PLAN.md §34.2.3). The plugin is one +//! file that runs unchanged on Oxide and on Carbon (R19); what differs is only the directory it goes +//! in, and the server itself already says which framework it has. Asking would be one more answer an +//! operator could get wrong. +//! +//! The marker files were read off the two rigs (2026-09-26), one per framework, before this code +//! relied on them: +//! +//! - **Oxide:** `RustDedicated_Data/Managed/Oxide.Rust.dll`. +//! - **Carbon:** `carbon/managed/Carbon.dll`. +//! +//! Both present is refused: Oxide and Carbon cannot run in one install (R21), so a tree carrying both +//! is one mid-migration, and guessing would put the plugin where one of them never looks. +//! +//! **Carbon's directories are its defaults.** The plan expected a moved plugins directory to be +//! readable from `carbon/config.json`; on 2.0.259, the build R19 was proven on, that file has no +//! folder keys at all — Carbon's directories move only by a launch argument, which an installer +//! cannot see. `carbon/plugins/` and `carbon/configs/` are what every stock install uses. + +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; + +/// Which framework loads the plugin. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Framework { + Oxide, + Carbon, +} + +impl Framework { + pub fn as_str(self) -> &'static str { + match self { + Self::Oxide => "oxide", + Self::Carbon => "carbon", + } + } + + /// The file whose presence says this framework is installed. + fn marker(self, root: &Path) -> PathBuf { + match self { + Self::Oxide => root + .join("RustDedicated_Data") + .join("Managed") + .join("Oxide.Rust.dll"), + Self::Carbon => root.join("carbon").join("managed").join("Carbon.dll"), + } + } + + /// Where the framework loads plugins from. + pub fn plugins_dir(self, root: &Path) -> PathBuf { + match self { + Self::Oxide => root.join("oxide").join("plugins"), + Self::Carbon => root.join("carbon").join("plugins"), + } + } + + /// Where the framework keeps each plugin's config. Oxide says `config`, Carbon `configs`. + pub fn config_dir(self, root: &Path) -> PathBuf { + match self { + Self::Oxide => root.join("oxide").join("config"), + Self::Carbon => root.join("carbon").join("configs"), + } + } +} + +/// A validated Rust server root. +#[derive(Debug, Clone)] +pub struct RustServer { + pub path: PathBuf, + pub framework: Framework, +} + +/// The file this installer owns in a server root: the plugin. +pub const PLUGIN_FILE: &str = "RunicGateway.cs"; +/// The plugin's config, which is the website's once written (§34.2.3). +pub const PLUGIN_CONFIG: &str = "RunicGateway.json"; + +impl RustServer { + pub fn plugins_dir(&self) -> PathBuf { + self.framework.plugins_dir(&self.path) + } + + pub fn plugin_path(&self) -> PathBuf { + self.plugins_dir().join(PLUGIN_FILE) + } + + pub fn plugin_config_path(&self) -> PathBuf { + self.framework.config_dir(&self.path).join(PLUGIN_CONFIG) + } +} + +/// Validates `path` as a Rust server root and detects its framework. +pub fn open(path: &Path) -> Result { + let path = std::fs::canonicalize(path) + .map(crate::servuo::strip_extended_prefix) + .with_context(|| format!("{} does not exist", path.display()))?; + if !path.is_dir() { + bail!("{} is not a directory", path.display()); + } + if !path.join("RustDedicated").is_file() && !path.join("RustDedicated.exe").is_file() { + bail!( + "{} is not a Rust server root: it has no RustDedicated or RustDedicated.exe", + path.display() + ); + } + + let oxide = Framework::Oxide.marker(&path).is_file(); + let carbon = Framework::Carbon.marker(&path).is_file(); + let framework = match (oxide, carbon) { + (true, false) => Framework::Oxide, + (false, true) => Framework::Carbon, + (true, true) => bail!( + "{} has both Oxide ({}) and Carbon ({}). They cannot run in one install, so this tree \ + is part-way through a migration. Remove the one you are leaving, then run again.", + path.display(), + Framework::Oxide.marker(&path).display(), + Framework::Carbon.marker(&path).display() + ), + (false, false) => bail!( + "{} has neither Oxide nor Carbon installed, and the bridge is a plugin for one of \ + them. Install one (looked for {} and {}), start the server once so it lays out its \ + directories, and run this again.", + path.display(), + Framework::Oxide.marker(&path).display(), + Framework::Carbon.marker(&path).display() + ), + }; + Ok(RustServer { path, framework }) +} + +/// Whether a RustDedicated is running out of this root. +/// +/// Informational, unlike ServUO's refusal: both frameworks load and unload a plugin file while the +/// server runs, so an install under a live server is simply one that takes effect at once (§34.2.3). +/// `doctor` also uses it, to tell a server that is down from one whose plugin is silent. +pub fn is_running(root: &Path) -> bool { + use sysinfo::{ProcessRefreshKind, RefreshKind, System}; + + let system = System::new_with_specifics( + RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()), + ); + let root = normalize(&root.to_string_lossy()); + system.processes().values().any(|process| { + let exe = process + .exe() + .map(|p| normalize(&p.to_string_lossy())) + .unwrap_or_default(); + exe.starts_with(&root) && exe.contains("rustdedicated") + }) +} + +fn normalize(s: &str) -> String { + s.to_lowercase().replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::TempDir; + + fn server(dir: &Path, frameworks: &[Framework]) { + std::fs::write(dir.join("RustDedicated"), b"").unwrap(); + for f in frameworks { + let marker = f.marker(dir); + std::fs::create_dir_all(marker.parent().unwrap()).unwrap(); + std::fs::write(marker, b"").unwrap(); + } + } + + #[test] + fn each_framework_is_detected_and_places_the_plugin_its_own_way() { + for (f, plugins, config) in [ + (Framework::Oxide, "oxide/plugins", "oxide/config"), + (Framework::Carbon, "carbon/plugins", "carbon/configs"), + ] { + let dir = TempDir::new("rg-rust-root").unwrap(); + server(dir.path(), &[f]); + let root = open(dir.path()).unwrap(); + assert_eq!(root.framework, f); + let rel = |p: PathBuf| { + p.strip_prefix(&root.path) + .unwrap() + .to_string_lossy() + .replace('\\', "/") + }; + assert_eq!(rel(root.plugins_dir()), plugins); + assert_eq!( + rel(root.plugin_config_path()), + format!("{config}/RunicGateway.json") + ); + } + } + + #[test] + fn both_frameworks_are_refused_and_so_is_neither() { + let dir = TempDir::new("rg-rust-both").unwrap(); + server(dir.path(), &[Framework::Oxide, Framework::Carbon]); + let err = open(dir.path()).unwrap_err().to_string(); + assert!( + err.contains("both Oxide") && err.contains("Carbon"), + "{err}" + ); + + let dir = TempDir::new("rg-rust-none").unwrap(); + server(dir.path(), &[]); + let err = open(dir.path()).unwrap_err().to_string(); + assert!(err.contains("neither Oxide nor Carbon"), "{err}"); + } + + #[test] + fn a_directory_without_the_game_is_not_a_server_root() { + let dir = TempDir::new("rg-rust-empty").unwrap(); + let err = open(dir.path()).unwrap_err().to_string(); + assert!(err.contains("RustDedicated"), "{err}"); + } +} diff --git a/src/rustgame/sidecar.rs b/src/rustgame/sidecar.rs new file mode 100644 index 0000000..216e6fe --- /dev/null +++ b/src/rustgame/sidecar.rs @@ -0,0 +1,290 @@ +//! 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. +pub fn handoff( + server_id: &str, + doc: &ConfigDoc, + host: &str, + site_url: Option<&str>, + registered: 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 connected to its sidecar.\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, + 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); + 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}"); + } + + #[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) + } + } +} diff --git a/src/rustgame/uninstall.rs b/src/rustgame/uninstall.rs new file mode 100644 index 0000000..b11c541 --- /dev/null +++ b/src/rustgame/uninstall.rs @@ -0,0 +1,199 @@ +//! `uninstall --game rust [--server-id ] [--purge]`. +//! +//! What it removes, per instance (docs/modules/rust/PLAN.md §34.2.3, §34.4): +//! +//! - **the service**, and **our plugin file** — `RunicGateway.cs` is the installer's; +//! - **never the plugin's config** — `RunicGateway.json` is the website's, and it holds the +//! server's id; a reinstall must find it; +//! - with `--purge`, the instance's sidecar config (its token) and database. +//! +//! With the last instance gone: the shared binary, the template unit, the record, and — only if +//! this installer created it and no ServUO deployment uses it — the service account. + +use std::path::Path; + +use anyhow::Result; + +use super::record::RustRecord; +use crate::cli::Cli; +use crate::{paths, service, ui}; + +pub fn run(cli: &Cli) -> Result { + let layout = paths::layout(); + let record_path = layout.rust_record(); + println!( + "\nRunic Gateway installer {} — uninstall (Rust)", + env!("CARGO_PKG_VERSION") + ); + + let Some(mut record) = RustRecord::load(&record_path)? else { + println!(); + ui::warn(&format!( + "Nothing to uninstall — no Rust deployment is recorded on this host (looked for {}).", + record_path.display() + )); + return Ok(0); + }; + + let ids: Vec = match &cli.server_id { + Some(id) if record.instances.contains_key(id) => vec![id.clone()], + Some(id) => anyhow::bail!( + "{id:?} is not a recorded Rust instance (recorded: {})", + record + .instances + .keys() + .cloned() + .collect::>() + .join(", ") + ), + None => record.instances.keys().cloned().collect(), + }; + let last = ids.len() == record.instances.len(); + + // ── Say what will happen, then ask ─────────────────────────────────────── + println!(); + for id in &ids { + let i = &record.instances[id]; + ui::heading(&format!("Instance {id}")); + if let Some(svc) = &i.service { + println!(" · remove the service {}", svc.name); + } + println!(" · remove {}", i.plugin_path); + println!( + " · keep {} (the website's; it names this server)", + i.plugin_config + ); + if cli.purge { + println!( + " · remove {} and the database {}", + i.config_path, i.db_path + ); + } else { + println!( + " · keep {} and {} (--purge removes them)", + i.config_path, i.db_path + ); + } + } + if last { + println!( + "\n and, with no instance left: {} and {}", + record.binary.path, + record_path.display() + ); + } + println!(); + let proceed = if cli.assume_yes { + println!("Remove the components listed above? [y/N] (--yes)"); + true + } else { + ui::confirm("Remove the components listed above?", false, false)? + }; + if !proceed { + println!("\nNothing was removed."); + return Ok(0); + } + + // ── Remove ─────────────────────────────────────────────────────────────── + let mut done = Vec::new(); + let mut problems = Vec::new(); + for id in &ids { + let Some(instance) = record.instances.remove(id) else { + continue; + }; + if let Some(svc) = &instance.service { + let removal = service::remove(svc); + done.extend(removal.done); + problems.extend(removal.problems); + } + remove_file(Path::new(&instance.plugin_path), &mut done, &mut problems); + if cli.purge { + remove_file(Path::new(&instance.config_path), &mut done, &mut problems); + let data_dir = layout.rust_data_dir(id); + if data_dir.exists() { + match std::fs::remove_dir_all(&data_dir) { + Ok(()) => done.push(format!("removed {}", data_dir.display())), + Err(e) => problems.push(format!("cannot remove {}: {e}", data_dir.display())), + } + } + } + } + + if record.instances.is_empty() { + remove_file(Path::new(&record.binary.path), &mut done, &mut problems); + done.extend(service::remove_rust_template(&layout)); + remove_shared_user(&record, &layout, &mut done, &mut problems); + remove_file(&record_path, &mut done, &mut problems); + } else { + match record.save(&record_path) { + Ok(()) => done.push(format!( + "{} keeps {} instance(s): {}", + record_path.display(), + record.instances.len(), + record + .instances + .keys() + .cloned() + .collect::>() + .join(", ") + )), + Err(e) => problems.push(format!("cannot update {}: {e}", record_path.display())), + } + } + + for line in &done { + println!(" · {line}"); + } + for problem in &problems { + println!(); + ui::warn(problem); + } + println!( + "\nRemove each server from Admin -> Rust -> Servers on the website too; the installer never \ + contacts it." + ); + Ok(if problems.is_empty() { 0 } else { 1 }) +} + +/// The `runicgateway` account is shared: every Rust instance runs as it, and so does ServUO's +/// sidecar. It goes only when this installer created it for Rust and no ServUO deployment remains. +fn remove_shared_user( + record: &RustRecord, + layout: &paths::Layout, + done: &mut Vec, + problems: &mut Vec, +) { + if !record.service_user_created || cfg!(windows) { + return; + } + if layout.install_record().exists() { + done.push(format!( + "left the {} account — the ServUO deployment on this host runs as it", + service::SERVICE_USER + )); + return; + } + let removed = crate::util::run_ok("userdel", &[service::SERVICE_USER]) + .or_else(|_| crate::util::run_ok("deluser", &[service::SERVICE_USER])); + match removed { + Ok(_) => done.push(format!( + "removed the {} service user", + service::SERVICE_USER + )), + Err(e) => problems.push(format!( + "cannot remove the {} service user ({}); remove it by hand if you want it gone", + service::SERVICE_USER, + e.to_string().replace('\n', " ") + )), + } +} + +fn remove_file(path: &Path, done: &mut Vec, problems: &mut Vec) { + match std::fs::remove_file(path) { + Ok(()) => done.push(format!("removed {}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + done.push(format!("{} was already gone", path.display())) + } + Err(e) => problems.push(format!("cannot remove {}: {e}", path.display())), + } +} diff --git a/src/service.rs b/src/service.rs index 75f072a..f3c0913 100644 --- a/src/service.rs +++ b/src/service.rs @@ -231,7 +231,21 @@ fn register_systemd( ) -> Result { let user = prepared.user.clone().unwrap_or_else(|| "root".into()); let text = systemd_unit_text(binary, config, db, &user); + register_systemd_unit(prepared, SYSTEMD_UNIT, unit_path, &text, restart) +} +/// Writes a unit file (when its content changed), then enables and starts `name`. +/// +/// `name` and the file differ for a template: the file is `runicgateway-rust@.service` and what is +/// enabled is `runicgateway-rust@alpha.service`, one per Rust instance (D148). +#[cfg(unix)] +fn register_systemd_unit( + prepared: &Prepared, + name: &str, + unit_path: &Path, + text: &str, + restart: bool, +) -> Result { // An unchanged unit is not rewritten: daemon-reload is not free, and an mtime that moves on // every run is a change an operator watching /etc has to investigate and then dismiss. let current = std::fs::read_to_string(unit_path).unwrap_or_default(); @@ -241,31 +255,31 @@ fn register_systemd( run_ok("systemctl", &["daemon-reload"])?; } - run_ok("systemctl", &["enable", SYSTEMD_UNIT])?; + run_ok("systemctl", &["enable", name])?; if restart { // The binary underneath a running service has just been replaced; `start` on an already // active unit is a no-op and would leave the old code running. - run_ok("systemctl", &["restart", SYSTEMD_UNIT])?; + run_ok("systemctl", &["restart", name])?; } else { - run_ok("systemctl", &["start", SYSTEMD_UNIT])?; + run_ok("systemctl", &["start", name])?; } Ok(Outcome::Registered { kind: "systemd", - name: SYSTEMD_UNIT.to_string(), + name: name.to_string(), unit_path: Some(unit_path.to_path_buf()), user: prepared.user.clone(), user_created: prepared.user_created, - state: systemd_state(), + state: systemd_state(name), }) } /// Reads the unit's state back rather than inferring it from the exit codes above. `systemctl /// start` succeeding and the service still being up a second later are different claims. #[cfg(unix)] -fn systemd_state() -> String { - let active = one_word(run("systemctl", &["is-active", SYSTEMD_UNIT])); - let enabled = one_word(run("systemctl", &["is-enabled", SYSTEMD_UNIT])); +fn systemd_state(name: &str) -> String { + let active = one_word(run("systemctl", &["is-active", name])); + let enabled = one_word(run("systemctl", &["is-enabled", name])); format!("{active}, {enabled}") } @@ -300,7 +314,14 @@ fn prepare_platform() -> Prepared { /// SERVICE\` form is a well-known prefix, unlike `BUILTIN\Administrators`, whose display name /// is translated. pub fn windows_service_account() -> String { - format!("NT SERVICE\\{WINDOWS_SERVICE}") + windows_account_for(WINDOWS_SERVICE) +} + +/// The virtual account for any service this installer registers — `NT SERVICE\`. A Rust +/// instance's is `NT SERVICE\RunicGatewayRust-`, so two instances cannot read each other's +/// token. +pub fn windows_account_for(name: &str) -> String { + format!("NT SERVICE\\{name}") } /// The `binPath=` value: the executable and the `--config` it must always be started with. @@ -325,7 +346,19 @@ pub fn windows_bin_path(binary: &Path, config: &Path) -> String { /// older than the one that speaks the SCM protocol produces this *every time*, on a perfectly good /// config — so the config is the last thing to look at, not the first. pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String { - let command = crate::util::command_line("sc.exe", &["start", WINDOWS_SERVICE]); + windows_start_failure_for(WINDOWS_SERVICE, MIN_SERVICE_SIDECAR, code, binary, config) +} + +/// [`windows_start_failure`] for any service: `min_sidecar` is the first release of that sidecar +/// that speaks the SCM handshake. +pub fn windows_start_failure_for( + name: &str, + min_sidecar: &str, + code: i32, + binary: &Path, + config: &Path, +) -> String { + let command = crate::util::command_line("sc.exe", &["start", name]); match code { 1053 => format!( "`{command}` failed with 1053 — the service did not respond to the start request in \ @@ -339,6 +372,7 @@ pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String \"{config}\"", binary = binary.display(), config = config.display(), + MIN_SERVICE_SIDECAR = min_sidecar, ), // ERROR_SERVICE_LOGON_FAILED. The account is the virtual one the SCM makes itself, so this // is a policy that forbids virtual service accounts rather than a wrong password. @@ -348,11 +382,11 @@ pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String password, so this is a local policy forbidding them rather than a bad credential. \ Register the service by hand against an account this host allows — INSTALL.md \ Appendix A4.", - account = windows_service_account(), + account = windows_account_for(name), ), _ => format!( "`{command}` failed with exit code {code}.\n\n Check the Windows event log \ - (System, source \"Service Control Manager\"), and `sc query {WINDOWS_SERVICE}` for \ + (System, source \"Service Control Manager\"), and `sc query {name}` for \ the service's own exit code. A sidecar that exits immediately usually cannot read its \ config: {}\n\n Running it in the foreground prints the reason:\n\n \ \"{}\" --config \"{}\"", @@ -365,23 +399,36 @@ pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String #[cfg(windows)] fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result { - let bin_path = windows_bin_path(binary, config); - let account = windows_service_account(); + register_windows_named( + WINDOWS_SERVICE, + DISPLAY_NAME, + MIN_SERVICE_SIDECAR, + binary, + config, + restart, + ) +} - if windows_service_exists() { +/// Registers (or reconfigures), sets the restart policy of, and starts one SCM service. +#[cfg(windows)] +fn register_windows_named( + name: &str, + display: &str, + min_sidecar: &str, + binary: &Path, + config: &Path, + restart: bool, +) -> Result { + let bin_path = windows_bin_path(binary, config); + let account = windows_account_for(name); + + if windows_service_exists(name) { // `config` rather than delete-and-recreate: recreating would drop the failure actions and, // more to the point, would briefly leave a host with no service if the create half failed. run_ok( "sc.exe", &[ - "config", - WINDOWS_SERVICE, - "binPath=", - &bin_path, - "start=", - "auto", - "obj=", - &account, + "config", name, "binPath=", &bin_path, "start=", "auto", "obj=", &account, ], )?; } else { @@ -389,7 +436,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result Result Result bool { - match run("sc.exe", &["query", WINDOWS_SERVICE]) { +fn windows_service_exists(name: &str) -> bool { + match run("sc.exe", &["query", name]) { Ok(output) => output.status.code() != Some(1060), Err(_) => false, } } #[cfg(windows)] -fn windows_service_state() -> String { - let Ok(output) = run("sc.exe", &["query", WINDOWS_SERVICE]) else { +fn windows_service_state(name: &str) -> String { + let Ok(output) = run("sc.exe", &["query", name]) else { return "unknown".to_string(); }; let text = String::from_utf8_lossy(&output.stdout); @@ -474,12 +523,12 @@ fn windows_service_state() -> String { } #[cfg(windows)] -fn stop_windows_service() -> Result<()> { +fn stop_windows_service(name: &str) -> Result<()> { // 1062 is ERROR_SERVICE_NOT_ACTIVE — already the state being asked for. - let stop = run("sc.exe", &["stop", WINDOWS_SERVICE])?; + let stop = run("sc.exe", &["stop", name])?; if !stop.status.success() && stop.status.code() != Some(1062) { anyhow::bail!( - "cannot stop {WINDOWS_SERVICE} (exit code {}). The sidecar binary is locked while the \ + "cannot stop {name} (exit code {}). The sidecar binary is locked while the \ service runs, so the install cannot replace it.", stop.status.code().unwrap_or(-1) ); @@ -488,17 +537,260 @@ fn stop_windows_service() -> Result<()> { // actually exits. Polling is the only way to know, and a fixed sleep would be either too short // or a delay on every run. for _ in 0..30 { - if windows_service_state() == "STOPPED" { + if windows_service_state(name) == "STOPPED" { return Ok(()); } std::thread::sleep(std::time::Duration::from_millis(500)); } anyhow::bail!( - "{WINDOWS_SERVICE} did not stop within 15 seconds. Stop it by hand and re-run: \ - sc.exe stop {WINDOWS_SERVICE}" + "{name} did not stop within 15 seconds. Stop it by hand and re-run: \ + sc.exe stop {name}" ) } +// ── Rust instances (docs/modules/rust/PLAN.md §34.2.3, D148) ───────────────── +// +// One sidecar binary per host and one service per instance. On Linux that is a systemd TEMPLATE +// unit, so every instance runs the same definition with its own config; on Windows it is one SCM +// service per instance, each under its own virtual account. The ServUO service above is untouched: +// a host running both games has three kinds of name and none of them collide. + +/// The template unit file. Instances are `runicgateway-rust@.service`. +pub const RUST_TEMPLATE_UNIT: &str = "runicgateway-rust@.service"; +/// The prefix of every Rust instance's SCM service name, `RunicGatewayRust-`. Rust-Link's +/// `windows.rs` carries the same literal. +pub const RUST_WINDOWS_PREFIX: &str = "RunicGatewayRust-"; +/// The first Rust-Link release whose sidecar speaks the SCM handshake — its first release at all. +#[cfg(windows)] +const MIN_RUST_SERVICE_SIDECAR: &str = "v0.1.0"; + +/// The systemd instance name for a server id. +pub fn rust_systemd_instance(server_id: &str) -> String { + format!("runicgateway-rust@{server_id}.service") +} + +/// The Windows service name for a server id. +pub fn rust_windows_service(server_id: &str) -> String { + format!("{RUST_WINDOWS_PREFIX}{server_id}") +} + +/// The service name this platform uses for an instance. +pub fn rust_service_name(server_id: &str) -> String { + if cfg!(windows) { + rust_windows_service(server_id) + } else { + rust_systemd_instance(server_id) + } +} + +/// Whether a Windows service name is one this installer registers — the ServUO sidecar's, or a Rust +/// instance's. Anything else in a record is not ours to query, stop or delete. +pub fn is_ours(name: &str) -> bool { + name == WINDOWS_SERVICE || name.starts_with(RUST_WINDOWS_PREFIX) +} + +/// The template unit. `%i` is the server id, so each instance reads its own config — which names +/// its own ports and its own database (see `crate::rustgame`). Pure, so it is a test. +pub fn rust_template_unit_text(binary: &Path, config_dir: &Path, user: &str) -> String { + format!( + "# Runic Gateway rust-link sidecar, one instance per Rust server.\n\ + #\n\ + # Generated by the Runic Gateway installer {installer}. Instances are\n\ + # {instance}; each reads {config_dir}/.toml.\n\ + # Local edits belong in a drop-in: systemctl edit runicgateway-rust@.service\n\ + \n\ + [Unit]\n\ + Description=Runic Gateway rust-link sidecar (%i)\n\ + After=network.target\n\ + \n\ + [Service]\n\ + Type=simple\n\ + User={user}\n\ + Environment=RUSTLINK_CONFIG={config_dir}/%i.toml\n\ + ExecStart={binary}\n\ + Restart=on-failure\n\ + RestartSec=5\n\ + \n\ + [Install]\n\ + WantedBy=multi-user.target\n", + installer = env!("CARGO_PKG_VERSION"), + instance = rust_systemd_instance(""), + config_dir = config_dir.display(), + binary = binary.display(), + ) +} + +/// Registers, enables and starts one Rust instance — or explains why it did not. +/// +/// `restart` is true when the shared binary was just replaced: every instance runs it, so every +/// instance the run touches is restarted rather than started. +pub fn register_rust( + prepared: &Prepared, + layout: &crate::paths::Layout, + server_id: &str, + config: &Path, + restart: bool, +) -> Result { + let binary = &layout.rust_sidecar_bin; + match &prepared.manager { + Manager::Unavailable(reason) => Ok(Outcome::Skipped { + reason: reason.clone(), + manual: rust_manual_steps(layout, server_id, config, !layout.relocated), + }), + #[cfg(unix)] + Manager::Systemd => { + let user = prepared.user.clone().unwrap_or_else(|| "root".into()); + let text = rust_template_unit_text(binary, &layout.rust_config_dir(), &user); + let mut outcome = register_systemd_unit( + prepared, + &rust_systemd_instance(server_id), + &layout.rust_template_unit(), + &text, + restart, + )?; + // The template file is shared by every instance, so it is not this instance's to + // remove: `uninstall` deletes it with the last one. + if let Outcome::Registered { unit_path, .. } = &mut outcome { + *unit_path = None; + } + Ok(outcome) + } + #[cfg(windows)] + Manager::WindowsScm => register_windows_named( + &rust_windows_service(server_id), + &format!("Runic Gateway rust-link sidecar ({server_id})"), + MIN_RUST_SERVICE_SIDECAR, + binary, + config, + restart, + ), + #[allow(unreachable_patterns)] + other => Ok(Outcome::Skipped { + reason: format!("{} is not supported by this build", other.kind()), + manual: rust_manual_steps(layout, server_id, config, !layout.relocated), + }), + } +} + +/// Stops every listed Rust instance so the shared binary can be replaced under them. +pub fn stop_rust_instances(manager: &Manager, server_ids: &[String]) -> Result<()> { + let _ = server_ids; + match manager { + #[cfg(unix)] + Manager::Systemd => { + for id in server_ids { + let _ = run("systemctl", &["stop", &rust_systemd_instance(id)]); + } + Ok(()) + } + #[cfg(windows)] + Manager::WindowsScm => { + for id in server_ids { + let name = rust_windows_service(id); + if windows_service_exists(&name) { + stop_windows_service(&name)?; + } + } + Ok(()) + } + _ => Ok(()), + } +} + +/// Starts (or restarts) an instance that is already registered, without re-registering it. Used by +/// `update` for instances whose own files did not change but whose binary did. +pub fn restart_rust_instance(server_id: &str) -> Result<()> { + #[cfg(unix)] + { + run_ok("systemctl", &["restart", &rust_systemd_instance(server_id)])?; + } + #[cfg(windows)] + { + let name = rust_windows_service(server_id); + let start = run("sc.exe", &["start", &name])?; + if !start.status.success() && start.status.code() != Some(1056) { + anyhow::bail!( + "cannot start {name} (exit code {})", + start.status.code().unwrap_or(-1) + ); + } + } + Ok(()) +} + +/// Removes the shared template unit, once the last Rust instance is gone. Linux only; Windows has +/// no shared definition. +pub fn remove_rust_template(layout: &crate::paths::Layout) -> Option { + #[cfg(unix)] + { + let path = layout.rust_template_unit(); + if path.exists() && std::fs::remove_file(&path).is_ok() { + let _ = run("systemctl", &["daemon-reload"]); + return Some(format!("removed {}", path.display())); + } + } + let _ = layout; + None +} + +/// What to do by hand for one instance when no service was registered. +pub fn rust_manual_steps( + layout: &crate::paths::Layout, + server_id: &str, + config: &Path, + config_protected: bool, +) -> String { + let binary = &layout.rust_sidecar_bin; + let data_dir = layout.rust_data_dir(server_id); + #[cfg(windows)] + { + let name = rust_windows_service(server_id); + let account = windows_account_for(&name); + format!( + " From an elevated PowerShell:\n\n \ + sc.exe create {name} binPath= '{bin_path}' obj= '{account}' start= auto\n \ + sc.exe failure {name} reset= 86400 actions= restart/5000\n \ + icacls '{config}' /grant '{account}:(R)'\n \ + icacls '{data_dir}' /grant '{account}:(OI)(CI)M'\n \ + sc.exe start {name}\n\n{token_note}", + bin_path = windows_bin_path(binary, config), + config = config.display(), + data_dir = data_dir.display(), + token_note = if config_protected { + " That config file has already been restricted to Administrators and SYSTEM, \ + because it holds\n the auth token.\n" + } else { + " That config file holds the auth token, and this run did NOT restrict it. \ + Lock it down too.\n" + }, + ) + } + #[cfg(not(windows))] + { + let _ = config_protected; + format!( + " Write this to /etc/systemd/system/{RUST_TEMPLATE_UNIT}:\n\n{unit}\n \ + Then:\n\n \ + useradd --system --no-create-home --shell /usr/sbin/nologin {SERVICE_USER}\n \ + chown {SERVICE_USER} {config}\n \ + chown -R {SERVICE_USER} {data_dir}\n \ + systemctl daemon-reload\n \ + systemctl enable --now {instance}\n\n \ + Without systemd, run {binary} with RUSTLINK_CONFIG={config}\n \ + as an unprivileged user that can write {data_dir}.\n", + unit = indent(&rust_template_unit_text( + binary, + &layout.rust_config_dir(), + SERVICE_USER + )), + config = config.display(), + data_dir = data_dir.display(), + instance = rust_systemd_instance(server_id), + binary = binary.display(), + ) + } +} + // ── Shared entry points ────────────────────────────────────────────────────── /// Stops a running service so its binary can be replaced. @@ -517,8 +809,8 @@ pub fn stop_for_replacement(manager: &Manager) -> Result<()> { } #[cfg(windows)] Manager::WindowsScm => { - if windows_service_exists() { - stop_windows_service()?; + if windows_service_exists(WINDOWS_SERVICE) { + stop_windows_service(WINDOWS_SERVICE)?; } Ok(()) } @@ -585,13 +877,13 @@ fn observe_platform(kind: &str, name: &str) -> Status { if kind != "windows-scm" { return Status::absent(format!("recorded as {kind}, which this host does not run")); } - // Only the service this installer registers is queried by name; anything else would be reading + // Only services this installer registers are queried by name; anything else would be reading // another product's service out of a hand-edited record. - if name != WINDOWS_SERVICE || !windows_service_exists() { + if !is_ours(name) || !windows_service_exists(name) { return Status::absent("not registered with the service manager".to_string()); } - let state = windows_service_state(); - let start = windows_start_type(); + let state = windows_service_state(name); + let start = windows_start_type(name); Status { present: true, running: state.contains("RUNNING"), @@ -603,8 +895,8 @@ fn observe_platform(kind: &str, name: &str) -> Status { /// `sc qc` reports the start type; `sc query` does not. Read separately so a service that exists but /// was set to manual start is reported as such rather than as healthy. #[cfg(windows)] -fn windows_start_type() -> String { - let Ok(output) = run("sc.exe", &["qc", WINDOWS_SERVICE]) else { +fn windows_start_type(name: &str) -> String { + let Ok(output) = run("sc.exe", &["qc", name]) else { return "unknown".to_string(); }; let text = String::from_utf8_lossy(&output.stdout); @@ -699,7 +991,15 @@ fn remove_platform(record: &ServiceRecord) -> Removal { )); return out; } - if !windows_service_exists() { + if !is_ours(&record.name) { + out.problems.push(format!( + "the record names a service called {}, which is not one this installer registers - \ + left alone", + record.name + )); + return out; + } + if !windows_service_exists(&record.name) { out.done .push(format!("{} was already unregistered", record.name)); return out; @@ -708,7 +1008,7 @@ fn remove_platform(record: &ServiceRecord) -> Removal { // Stopping first is not politeness: `sc delete` on a running service only marks it for deletion, // and the service — and its lock on the binary this uninstall is about to remove — survives // until the process exits. - if let Err(error) = stop_windows_service() { + if let Err(error) = stop_windows_service(&record.name) { out.problems .push(error.to_string().replace('\n', " ").to_string()); } else { @@ -954,10 +1254,14 @@ fn grant_service_access_platform(config: &Path, data_dir: &Path, outcome: &Outco // Nothing to grant when nothing was registered: the account only exists because `sc create` // made it, and a skipped registration leaves the config locked to Administrators — which is the // right resting state for a host where no service is going to read it. - if !outcome.registered() { + // The account comes from what was registered: the ServUO service's, or one Rust instance's. + let Outcome::Registered { + user: Some(account), + .. + } = outcome + else { return Ok(()); - } - let account = windows_service_account(); + }; run_ok( "icacls", @@ -1155,6 +1459,7 @@ mod tests { state_dir: PathBuf::from("/etc/runicgateway"), data_dir: PathBuf::from("/var/lib/runicgateway"), sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"), + rust_sidecar_bin: PathBuf::from("/usr/bin/runicgateway-rust-link"), relocated: true, }; let outcome = register(&prepare(true), &layout, false).unwrap(); diff --git a/src/servuo.rs b/src/servuo.rs index 1b7ea64..dc42072 100644 --- a/src/servuo.rs +++ b/src/servuo.rs @@ -163,7 +163,7 @@ pub fn normalize_version(raw: &str) -> String { } #[cfg(windows)] -fn strip_extended_prefix(path: PathBuf) -> PathBuf { +pub(crate) fn strip_extended_prefix(path: PathBuf) -> PathBuf { match path.to_str().and_then(|s| s.strip_prefix(r"\\?\")) { Some(stripped) => PathBuf::from(stripped), None => path, @@ -171,7 +171,7 @@ fn strip_extended_prefix(path: PathBuf) -> PathBuf { } #[cfg(not(windows))] -fn strip_extended_prefix(path: PathBuf) -> PathBuf { +pub(crate) fn strip_extended_prefix(path: PathBuf) -> PathBuf { path } diff --git a/src/tier.rs b/src/tier.rs index 00523fa..7914e00 100644 --- a/src/tier.rs +++ b/src/tier.rs @@ -731,6 +731,7 @@ mod tests { state_dir: std::path::PathBuf::from("/etc/runicgateway"), data_dir: std::path::PathBuf::from("/var/lib/runicgateway"), sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-link"), + rust_sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-rust-link"), relocated: false, } } diff --git a/src/uninstall.rs b/src/uninstall.rs index e34951e..cc6eb36 100644 --- a/src/uninstall.rs +++ b/src/uninstall.rs @@ -623,6 +623,7 @@ mod tests { state_dir: dir.to_path_buf(), data_dir: dir.join("data"), sidecar_bin: dir.join("bin").join("uo-link-sidecar"), + rust_sidecar_bin: dir.join("bin").join("rust-link-sidecar"), relocated: true, } } diff --git a/tests/fixtures/published-bundle-2026.09.15.json b/tests/fixtures/published-bundle-2026.09.15.json new file mode 100644 index 0000000..32bc53f --- /dev/null +++ b/tests/fixtures/published-bundle-2026.09.15.json @@ -0,0 +1,45 @@ +{ + "schema": 1, + "bundle": "2026.09.15", + "generated": "2026-09-15T00:12:17Z", + "protocol": 8, + "link": { + "repo": "RunicGateway/link", + "tag": "v2.3.0", + "version": "2.3.0", + "protocol": 8, + "assets": { + "linux-aarch64": { + "name": "uo-link-sidecar-linux-aarch64", + "url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-aarch64", + "sha256": "406a69a140a36e8ac5090c2b101a28a6068b8c424fc261616e0edf4de0f4accf" + }, + "linux-x86_64": { + "name": "uo-link-sidecar-linux-x86_64", + "url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-x86_64", + "sha256": "504763855e58e75146a409786dcfa8b674b43a161bec79699c66c06a6c912e36" + }, + "windows-x86_64": { + "name": "uo-link-sidecar-windows-x86_64.exe", + "url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-windows-x86_64.exe", + "sha256": "0daeb427f2fe4256cd6a971daab1bafe151901f8102b2a14c6ab12605ab80110" + } + } + }, + "overlay": { + "repo": "RunicGateway/servuo-plugins", + "tag": "v1.3.0", + "version": "1.3.0", + "commit": "59a6c446c6c5b546c424ee8d23ac92831b3ab641", + "protocol": 8, + "servuo": { + "min_version": "57.4", + "patches_verified_against": "57.4" + }, + "asset": { + "name": "runicgateway-overlay-1.3.0.tar.gz", + "url": "https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v1.3.0/runicgateway-overlay-1.3.0.tar.gz", + "sha256": "b6d6b67f94cde9d8b3b431a89ac125c5be10bcda14d8786422a01cc61ce37dc1" + } + } +} diff --git a/tests/fixtures/published-bundle-v2-servuo.json b/tests/fixtures/published-bundle-v2-servuo.json new file mode 100644 index 0000000..f587563 --- /dev/null +++ b/tests/fixtures/published-bundle-v2-servuo.json @@ -0,0 +1,47 @@ +{ + "schema": 2, + "game": "servuo", + "bundle": "2026.09.15", + "generated": "2026-09-26T03:55:28Z", + "protocol": 8, + "sidecar": { + "repo": "RunicGateway/link", + "tag": "v2.3.0", + "version": "2.3.0", + "protocol": 8, + "assets": { + "linux-aarch64": { + "name": "uo-link-sidecar-linux-aarch64", + "url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-aarch64", + "sha256": "406a69a140a36e8ac5090c2b101a28a6068b8c424fc261616e0edf4de0f4accf" + }, + "linux-x86_64": { + "name": "uo-link-sidecar-linux-x86_64", + "url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-x86_64", + "sha256": "504763855e58e75146a409786dcfa8b674b43a161bec79699c66c06a6c912e36" + }, + "windows-x86_64": { + "name": "uo-link-sidecar-windows-x86_64.exe", + "url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-windows-x86_64.exe", + "sha256": "0daeb427f2fe4256cd6a971daab1bafe151901f8102b2a14c6ab12605ab80110" + } + } + }, + "payload": { + "kind": "overlay", + "repo": "RunicGateway/servuo-plugins", + "tag": "v1.3.0", + "version": "1.3.0", + "commit": "59a6c446c6c5b546c424ee8d23ac92831b3ab641", + "protocol": 8, + "asset": { + "name": "runicgateway-overlay-1.3.0.tar.gz", + "url": "https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v1.3.0/runicgateway-overlay-1.3.0.tar.gz", + "sha256": "b6d6b67f94cde9d8b3b431a89ac125c5be10bcda14d8786422a01cc61ce37dc1" + }, + "compat": { + "min_version": "57.4", + "patches_verified_against": "57.4" + } + } +} diff --git a/tests/fixtures/rust-bundle-v2.json b/tests/fixtures/rust-bundle-v2.json new file mode 100644 index 0000000..ec433e6 --- /dev/null +++ b/tests/fixtures/rust-bundle-v2.json @@ -0,0 +1,57 @@ +{ + "schema": 2, + "game": "rust", + "bundle": "2026.09.26", + "generated": "2026-09-26T04:06:03Z", + "protocol": 12, + "sidecar": { + "repo": "RunicGateway/Rust-Link", + "tag": "v0.1.0", + "version": "0.1.0", + "protocol": 12, + "assets": { + "linux-x86_64": { + "name": "rust-link-sidecar-linux-x86_64", + "url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases/download/v0.1.0/rust-link-sidecar-linux-x86_64", + "sha256": "3f69c56a78ea0f9a886286078744710a983b0058b61cd0be0e69a2b4db312bfe" + }, + "windows-x86_64": { + "name": "rust-link-sidecar-windows-x86_64.exe", + "url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases/download/v0.1.0/rust-link-sidecar-windows-x86_64.exe", + "sha256": "e8cc61bb5681ead44baf2dcd661c323630b53a84b834447410a0bc1e5b1b1e8c" + } + }, + "launcher": { + "name": "with-sidecar.sh", + "url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases/download/v0.1.0/with-sidecar.sh", + "sha256": "b1d785873f0528663f12cf1b008ec2581ba7bd7282395203d8d5915e19ceb712" + } + }, + "payload": { + "kind": "plugin", + "repo": "RunicGateway/Rust-Plugins", + "tag": "v0.2.3", + "version": "0.2.3", + "commit": "deadbeef", + "protocol": 12, + "compat": { + "frameworks": { + "oxide": { + "min_version": "2.0.7585" + }, + "carbon": { + "min_version": "2.0.259" + } + }, + "requires_plugins": [ + "Kits", + "ZoneManager" + ] + }, + "asset": { + "name": "runicgateway-rust-plugin-0.2.3.tar.gz", + "url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins/releases/download/v0.2.3/runicgateway-rust-plugin-0.2.3.tar.gz", + "sha256": "2ca6269dba329b06d0d36e5786f6f88c10757c38a0b1dd7efdbcd4e803b94c78" + } + } +}