Merge pull request 'feat(rust): --game rust, named instances, and schema-2 bundles (phase 18)' (#28) from feat/phase-18-rust into edge

Reviewed-on: #28
This commit is contained in:
2026-09-26 05:33:51 +00:00
24 changed files with 3410 additions and 94 deletions

1
Cargo.lock generated
View File

@@ -402,6 +402,7 @@ name = "runicgateway-installer"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"chrono",
"flate2",
"serde",

View File

@@ -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"

View File

@@ -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@<id>.service` (a systemd template) or `RunicGatewayRust-<id>`.
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

View File

@@ -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 <tag>` pins it.
/// URL of a schema-1 bundle: the current one, or a specific one when `--bundle <tag>` 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<String> {
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 <tag>` 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<Bundle> {
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<Bundle> {
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<String>,
#[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<String, Asset>,
/// 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<String, FrameworkFloor>,
/// Third-party plugins the features expect. Reported, never installed (D153).
pub requires_plugins: Vec<String>,
}
#[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::<Vec<_>>()
.join(", ")
)
})
}
}
/// Fetches and validates a Rust bundle: the current one, or `--bundle <tag>`.
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<RustBundle> {
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]

View File

@@ -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 <path>`: a Rust server root (the directory holding `RustDedicated`).
pub rust: Option<String>,
/// `--server-id <id>`: names one Rust instance — its service, config, database and ports — and
/// is the plugin's `ServerId` (D148).
pub server_id: Option<String>,
/// `--web-port <port>`: the port the website reaches a Rust instance's sidecar on.
pub web_port: Option<u16>,
/// `--verify`: report every change that would be made, write nothing.
pub verify: bool,
/// `--servuo <path>`: 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 <COMMAND> [OPTIONS]
@@ -121,6 +143,7 @@ Commands:
ServUO tree — it prints what to remove there.
Options:
--game <servuo|rust> Which game's shard side. Default servuo.
--verify install, update. Dry run: report every
change that would be made, write nothing.
--servuo <PATH> 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 <PATH> install. The Rust server root — the
directory holding RustDedicated. Oxide or
Carbon is detected, never asked.
--server-id <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 <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<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
"--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::<u16>().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<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
}
}
// 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<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
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();

View File

@@ -68,7 +68,7 @@ pub struct Row {
}
impl Row {
fn new(mark: Mark, label: &str, detail: impl Into<String>) -> Self {
pub(crate) fn new(mark: Mark, label: &str, detail: impl Into<String>) -> Self {
Self {
mark,
label: label.to_string(),
@@ -77,24 +77,24 @@ impl Row {
}
}
fn ok(label: &str, detail: impl Into<String>) -> Self {
pub(crate) fn ok(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Ok, label, detail)
}
fn warn(label: &str, detail: impl Into<String>) -> Self {
pub(crate) fn warn(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Warn, label, detail)
}
fn fail(label: &str, detail: impl Into<String>) -> Self {
pub(crate) fn fail(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Fail, label, detail)
}
fn note(mut self, note: impl Into<String>) -> Self {
pub(crate) fn note(mut self, note: impl Into<String>) -> Self {
self.notes.push(note.into());
self
}
fn notes_from(mut self, notes: impl IntoIterator<Item = String>) -> Self {
pub(crate) fn notes_from(mut self, notes: impl IntoIterator<Item = String>) -> Self {
self.notes.extend(notes);
self
}
@@ -166,8 +166,14 @@ pub fn run(cli: &Cli) -> Result<i32> {
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<i32> {
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<sidecar::ConfigDoc> {
///
/// 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<Health> {
pub(crate) fn health_of(bind: &str) -> Option<Health> {
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()?;

View File

@@ -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();
}

View File

@@ -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),

View File

@@ -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/<id>.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/<id>` — 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

297
src/rustgame/doctor.rs Normal file
View File

@@ -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<i32> {
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::<Vec<_>>()
.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<Row> {
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::<Vec<_>>()
.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}")),
}
}

750
src/rustgame/install.rs Normal file
View File

@@ -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<ConfigView>,
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 <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 <path>: 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<String> = 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<Planned> {
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(())
}

34
src/rustgame/mod.rs Normal file
View File

@@ -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<i32> {
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),
}
}

238
src/rustgame/plugin.rs Normal file
View File

@@ -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<String, String>,
}
/// The released plugin, read out of its tarball.
#[derive(Debug, Clone)]
pub struct Released {
pub manifest: Manifest,
pub source: Vec<u8>,
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<Released> {
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<Vec<u8>> = None;
let mut source: Option<Vec<u8>> = 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<String>,
pub port: Option<u16>,
}
/// Reads `RunicGateway.json`, or `None` when the plugin has never written one.
pub fn read_config(path: &Path) -> Result<Option<ConfigView>> {
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<String> {
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"]);
}
}

208
src/rustgame/record.rs Normal file
View File

@@ -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<String, Instance>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
/// 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<ServiceRecord>,
}
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<Option<Self>> {
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<u16> {
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<u16> {
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]);
}
}

219
src/rustgame/server.rs Normal file
View File

@@ -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<RustServer> {
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}");
}
}

290
src/rustgame/sidecar.rs Normal file
View File

@@ -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<ConfigDoc> {
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<u16> {
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<u16>) -> Result<u16> {
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://<your-site>".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)
}
}
}

199
src/rustgame/uninstall.rs Normal file
View File

@@ -0,0 +1,199 @@
//! `uninstall --game rust [--server-id <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<i32> {
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<String> = 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::<Vec<_>>()
.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::<Vec<_>>()
.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<String>,
problems: &mut Vec<String>,
) {
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<String>, problems: &mut Vec<String>) {
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())),
}
}

View File

@@ -231,7 +231,21 @@ fn register_systemd(
) -> Result<Outcome> {
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<Outcome> {
// 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\<name>` 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\<name>`. A Rust
/// instance's is `NT SERVICE\RunicGatewayRust-<id>`, 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<Outcome> {
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<Outcome> {
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<Outco
"sc.exe",
&[
"create",
WINDOWS_SERVICE,
name,
"binPath=",
&bin_path,
"start=",
@@ -397,7 +444,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
"obj=",
&account,
"DisplayName=",
DISPLAY_NAME,
display,
],
)
.context(
@@ -405,7 +452,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
(no password); if this host's policy forbids them, register the service by hand — \
INSTALL.md Appendix A4.",
)?;
let _ = run("sc.exe", &["description", WINDOWS_SERVICE, DISPLAY_NAME]);
let _ = run("sc.exe", &["description", name, display]);
}
// Restart on failure, matching systemd's Restart=on-failure / RestartSec=5. `reset= 86400`
@@ -415,7 +462,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
"sc.exe",
&[
"failure",
WINDOWS_SERVICE,
name,
"reset=",
"86400",
"actions=",
@@ -423,13 +470,15 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
],
)?;
if restart && windows_service_state().contains("RUNNING") {
stop_windows_service()?;
if restart && windows_service_state(name).contains("RUNNING") {
stop_windows_service(name)?;
}
// 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure.
let start = run("sc.exe", &["start", WINDOWS_SERVICE])?;
let start = run("sc.exe", &["start", name])?;
if !start.status.success() && start.status.code() != Some(1056) {
anyhow::bail!(windows_start_failure(
anyhow::bail!(windows_start_failure_for(
name,
min_sidecar,
start.status.code().unwrap_or(-1),
binary,
config
@@ -438,27 +487,27 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
Ok(Outcome::Registered {
kind: "windows-scm",
name: WINDOWS_SERVICE.to_string(),
name: name.to_string(),
unit_path: None,
user: Some(account),
user_created: false,
state: format!("{}, automatic start", windows_service_state()),
state: format!("{}, automatic start", windows_service_state(name)),
})
}
/// 1060 is ERROR_SERVICE_DOES_NOT_EXIST. Anything else — including an access-denied — is treated as
/// "it exists", so the caller reconfigures rather than trying to create a service that is there.
#[cfg(windows)]
fn windows_service_exists() -> 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@<id>.service`.
pub const RUST_TEMPLATE_UNIT: &str = "runicgateway-rust@.service";
/// The prefix of every Rust instance's SCM service name, `RunicGatewayRust-<id>`. 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}/<id>.toml.\n\
# Local edits belong in a drop-in: systemctl edit runicgateway-rust@<id>.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("<id>"),
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<Outcome> {
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<String> {
#[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();

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

57
tests/fixtures/rust-bundle-v2.json vendored Normal file
View File

@@ -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"
}
}
}