From 7027a78a2307c06f999905f31abeda23e3a8807b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 23:40:11 -0500 Subject: [PATCH] feat(rust): --game rust, named instances, and schema-2 bundles (phase 18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module-rust phase 18, step 5 of docs/modules/rust/PLAN.md §34.2.7 (D146, D148, D149, D153). Bundles: ServUO is read at schema 2 from v2/servuo/ and lowered into the schema-1 model. Schema 1 at the root is the fallback, so a pin from before schema 2 still reproduces. Rust bundles are read from v2/rust/. v2 reads use the contents API, because /raw/ is CDN-cached for six hours. --game rust runs install, update, doctor and uninstall for Rust servers (src/rustgame/): - the framework is detected from its marker files, which were read off both rigs; both or neither is refused; - --server-id names an instance: its own service (runicgateway-rust@, or RunicGatewayRust-), config, database and ports; - the plugin config is written once, with ServerId and Port only. An existing one is never rewritten, and one naming another server refuses the run; - each instance's sidecar.toml is written once with its ports and an absolute database path, and the sidecar generates the token into it; - one binary per host. update moves every instance, and a replaced binary restarts all of them; - doctor checks the plugin file hash, the plugin config's ServerId, the required uMod plugins (a warning), the service and /health, and passes when the plugin is connected; - uninstall removes our plugin and keeps its config. --purge also removes the sidecar config and database. The last instance takes the binary, the template and the record, and the shared user only when no ServUO record remains. service.rs takes the service name as a parameter internally. The ServUO public API is unchanged. Finding: Carbon 2.0.259's config.json has no folder keys, so carbon/plugins and carbon/configs are what the installer uses. The plan expected a moved directory to be readable there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- Cargo.lock | 1 + Cargo.toml | 4 + README.md | 27 + src/bundle.rs | 420 +++++++++- src/cli.rs | 128 ++- src/doctor.rs | 28 +- src/install.rs | 2 +- src/lib.rs | 8 + src/paths.rs | 79 ++ src/rustgame/doctor.rs | 297 +++++++ src/rustgame/install.rs | 750 ++++++++++++++++++ src/rustgame/mod.rs | 34 + src/rustgame/plugin.rs | 238 ++++++ src/rustgame/record.rs | 208 +++++ src/rustgame/server.rs | 219 +++++ src/rustgame/sidecar.rs | 290 +++++++ src/rustgame/uninstall.rs | 199 +++++ src/service.rs | 417 ++++++++-- src/servuo.rs | 4 +- src/tier.rs | 1 + src/uninstall.rs | 1 + .../fixtures/published-bundle-2026.09.15.json | 45 ++ .../fixtures/published-bundle-v2-servuo.json | 47 ++ tests/fixtures/rust-bundle-v2.json | 57 ++ 24 files changed, 3410 insertions(+), 94 deletions(-) create mode 100644 src/rustgame/doctor.rs create mode 100644 src/rustgame/install.rs create mode 100644 src/rustgame/mod.rs create mode 100644 src/rustgame/plugin.rs create mode 100644 src/rustgame/record.rs create mode 100644 src/rustgame/server.rs create mode 100644 src/rustgame/sidecar.rs create mode 100644 src/rustgame/uninstall.rs create mode 100644 tests/fixtures/published-bundle-2026.09.15.json create mode 100644 tests/fixtures/published-bundle-v2-servuo.json create mode 100644 tests/fixtures/rust-bundle-v2.json 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" + } + } +}