Merge pull request 'feat(rust): cut edge over to main — --game rust, named instances, schema-2 bundles (rust phase 18)' (#32) from edge into main
Reviewed-on: #32 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -402,6 +402,7 @@ name = "runicgateway-installer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"serde",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
27
README.md
27
README.md
@@ -111,6 +111,33 @@ passed 13/13.
|
||||
| `update` | Resolve the current bundle manifest, then update the sidecar (replace + restart) and the overlay (re-sync + tell the operator to restart ServUO). |
|
||||
| `uninstall` | Remove only what the installer exclusively owns. It **never edits the ServUO tree** — it prints the overlay files to delete and the patch hunks to revert, and leaves that call to the operator. |
|
||||
|
||||
### Rust: `--game rust`
|
||||
|
||||
The same four verbs set up **Rust servers** (Oxide or Carbon) from a schema-2 Rust bundle — a
|
||||
Rust-Link sidecar and a Rust-Plugins plugin, checked by CI to speak one protocol. A Rust host
|
||||
commonly runs several servers, so each is a **named instance** with its own service, config,
|
||||
database and ports; every instance shares one sidecar binary, and `update` moves them together.
|
||||
|
||||
```bash
|
||||
sudo ./runicgateway-installer-linux-x86_64 install --game rust --rust /srv/rust --server-id main
|
||||
```
|
||||
|
||||
- The framework is **detected**, never asked, and the plugin goes to `oxide/plugins/` or
|
||||
`carbon/plugins/`. A tree with both, or neither, is refused.
|
||||
- `--server-id` is the id the website knows the server by. Where the plugin has no config yet the
|
||||
installer writes one holding just `ServerId` and `Port`; an existing one is never rewritten, and
|
||||
one naming another server refuses the run.
|
||||
- Services: `runicgateway-rust@<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
|
||||
|
||||
420
src/bundle.rs
420
src/bundle.rs
@@ -14,6 +14,7 @@
|
||||
//! half-understood.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use base64::Engine as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -27,9 +28,6 @@ use std::collections::BTreeMap;
|
||||
const BUNDLE_BASE: &str =
|
||||
"https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles";
|
||||
|
||||
/// The only `schema` this build understands.
|
||||
const SUPPORTED_SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Bundle {
|
||||
pub schema: u32,
|
||||
@@ -126,7 +124,10 @@ pub fn platform_key() -> Result<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// URL of the current bundle, or of a specific one when `--bundle <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]
|
||||
|
||||
137
src/cli.rs
137
src/cli.rs
@@ -56,6 +56,14 @@ pub enum PatchChoice {
|
||||
No,
|
||||
}
|
||||
|
||||
/// Which game's shard side this run is about (docs/modules/rust/PLAN.md §34.2.3). ServUO is the
|
||||
/// default, so every command line written before Rust existed means exactly what it meant.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Game {
|
||||
ServUo,
|
||||
Rust,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
Run(Command),
|
||||
@@ -66,6 +74,15 @@ pub enum Mode {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Cli {
|
||||
pub mode: Mode,
|
||||
/// `--game servuo|rust`.
|
||||
pub game: Game,
|
||||
/// `--rust <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,20 +128,23 @@ 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]
|
||||
|
||||
Commands:
|
||||
install Deploy the plugin overlay, install the uo-link sidecar and its
|
||||
service, record what was deployed, and print the values the
|
||||
website needs.
|
||||
install Deploy the game-side plugin (the ServUO overlay, or the Rust
|
||||
plugin), install the sidecar and its service, record what was
|
||||
deployed, and print the values the website needs.
|
||||
doctor Diagnose an existing deployment end to end.
|
||||
update Re-resolve the bundle and move both components to it.
|
||||
uninstall Remove what the installer exclusively owns. Never edits the
|
||||
ServUO tree — it prints what to remove there.
|
||||
ServUO tree — it prints what to remove there. For Rust it
|
||||
removes only its own plugin file and keeps the plugin config.
|
||||
|
||||
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 +173,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 +233,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 +270,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 +293,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 +422,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();
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -576,8 +576,9 @@ fn install_sidecar(
|
||||
layout.relocated,
|
||||
)?;
|
||||
|
||||
let outcome = service::register(&prepared, layout, action.writes())?;
|
||||
let mut outcome = service::register(&prepared, layout, action.writes())?;
|
||||
service::grant_service_access(&config_path, &layout.data_dir, &outcome)?;
|
||||
service::start_registered(&mut outcome, &layout.sidecar_bin, &config_path)?;
|
||||
let service_record = match &outcome {
|
||||
service::Outcome::Registered {
|
||||
kind,
|
||||
@@ -694,7 +695,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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
79
src/paths.rs
79
src/paths.rs
@@ -29,6 +29,9 @@ pub struct Layout {
|
||||
pub data_dir: PathBuf,
|
||||
/// `/usr/bin/runicgateway-link` — the installed sidecar binary.
|
||||
pub sidecar_bin: PathBuf,
|
||||
/// `/usr/bin/runicgateway-rust-link` — the rust-link sidecar, one binary for every Rust
|
||||
/// instance on the host (docs/modules/rust/PLAN.md §34.2.3).
|
||||
pub rust_sidecar_bin: PathBuf,
|
||||
/// This layout came from [`STATE_DIR_ENV`], so it describes a test run rather than a real
|
||||
/// deployment. Service registration is skipped when it is set — see [`layout`].
|
||||
pub relocated: bool,
|
||||
@@ -83,6 +86,53 @@ impl Layout {
|
||||
pub fn systemd_unit(&self) -> PathBuf {
|
||||
PathBuf::from("/etc/systemd/system").join(crate::service::SYSTEMD_UNIT)
|
||||
}
|
||||
|
||||
// ── Rust ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Rust's files live under a `rust/` of their own, beside ServUO's rather than among them, so a
|
||||
// host running both games has two records that cannot corrupt each other (§34.4) and an
|
||||
// uninstall of one never reaches the other.
|
||||
|
||||
/// `/etc/runicgateway/rust/install.json` — the Rust record: the bundle and every instance.
|
||||
pub fn rust_record(&self) -> PathBuf {
|
||||
self.state_dir.join("rust").join("install.json")
|
||||
}
|
||||
|
||||
/// Where instance configs live on Linux — the directory the template unit names with `%i`.
|
||||
pub fn rust_config_dir(&self) -> PathBuf {
|
||||
self.state_dir.join("rust")
|
||||
}
|
||||
|
||||
/// One instance's `sidecar.toml`.
|
||||
///
|
||||
/// **Linux:** `/etc/runicgateway/rust/<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
297
src/rustgame/doctor.rs
Normal 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}")),
|
||||
}
|
||||
}
|
||||
777
src/rustgame/install.rs
Normal file
777
src/rustgame/install.rs
Normal file
@@ -0,0 +1,777 @@
|
||||
//! `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, plan.running));
|
||||
}
|
||||
|
||||
// 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, running) in &handoffs {
|
||||
println!(
|
||||
"{}",
|
||||
sidecar::handoff(
|
||||
id,
|
||||
doc,
|
||||
&host,
|
||||
cli.site_url.as_deref(),
|
||||
*registered,
|
||||
*running
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
Mode::Update => {
|
||||
let before = prior.as_ref().map(|p| p.bundle.clone());
|
||||
match before {
|
||||
Some(b) if b.tag == bundle.bundle => {
|
||||
// The same bundle can still have written something: a plugin edited by hand is
|
||||
// put back, which is what `doctor` tells an operator to run `update` for.
|
||||
let restored = planned.iter().filter(|p| p.plugin_action.writes()).count();
|
||||
if restored == 0 && !binary_action.writes() {
|
||||
println!("\nAlready on bundle {} — nothing moved.", bundle.bundle)
|
||||
} else {
|
||||
println!(
|
||||
"\nAlready on bundle {} — put back what no longer matched it (see above).",
|
||||
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 mut outcome = service::register_rust(
|
||||
prepared,
|
||||
layout,
|
||||
&plan.id,
|
||||
&plan.config_path,
|
||||
binary_changed,
|
||||
)?;
|
||||
service::grant_service_access(&plan.config_path, &data_dir, &outcome)?;
|
||||
service::start_registered(&mut outcome, &layout.rust_sidecar_bin, &plan.config_path)?;
|
||||
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()))?;
|
||||
// Overwritten in place, NOT `write_atomic`. Oxide and Carbon watch the plugins directory
|
||||
// and reload on a CHANGE; `write_atomic` removes the old file and renames a `.tmp` over it,
|
||||
// which both frameworks see as a delete — they unload the bridge — and a rename they
|
||||
// ignore, so the new file is never loaded. On a running server that was an `update` that
|
||||
// silently took the bridge down until the next boot (the phase 18 walk, on all three
|
||||
// instances and both frameworks). A write in place is what an operator's `cp` does, and
|
||||
// it reloads. A crash mid-write leaves a file that fails to compile; `doctor` reports it
|
||||
// as not the deployed file and `update` writes it again.
|
||||
std::fs::write(&plugin_path, &released.source)
|
||||
.with_context(|| format!("cannot write {}", plugin_path.display()))?;
|
||||
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
34
src/rustgame/mod.rs
Normal 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
238
src/rustgame/plugin.rs
Normal 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
208
src/rustgame/record.rs
Normal 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
219
src/rustgame/server.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
315
src/rustgame/sidecar.rs
Normal file
315
src/rustgame/sidecar.rs
Normal file
@@ -0,0 +1,315 @@
|
||||
//! 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.
|
||||
///
|
||||
/// It says what the run did, not what it hopes: nothing here has seen the plugin connect — on a
|
||||
/// stopped server it has not even loaded — so the first line never claims a link. `doctor` is the
|
||||
/// command that checks one (D156).
|
||||
pub fn handoff(
|
||||
server_id: &str,
|
||||
doc: &ConfigDoc,
|
||||
host: &str,
|
||||
site_url: Option<&str>,
|
||||
registered: bool,
|
||||
running: bool,
|
||||
) -> String {
|
||||
let port = port_of(&doc.web.bind)
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_else(|| doc.web.bind.clone());
|
||||
let site = site_url
|
||||
.map(|s| s.trim_end_matches('/').to_string())
|
||||
.unwrap_or_else(|| "https://<your-site>".to_string());
|
||||
let loopback = doc.web.bind.starts_with("127.") || doc.web.bind.starts_with("[::1]");
|
||||
format!(
|
||||
"\nRust server {server_id:?} is set up. {when}\n\n\
|
||||
One manual step remains — add it to the website:\n\n \
|
||||
Server id {server_id}\n \
|
||||
Sidecar URL http://{host}:{port}\n \
|
||||
Auth token {token}\n \
|
||||
(also in {config})\n \
|
||||
Protocol {protocol}\n\n\
|
||||
Add these at {site}/admin/rust/servers\n{loopback_note}{service_note}",
|
||||
token = doc.web.auth_token,
|
||||
config = doc.config_path,
|
||||
protocol = doc.protocol,
|
||||
when = if running {
|
||||
format!(
|
||||
"The plugin loads now; `doctor --game rust --server-id {server_id}` confirms it connected."
|
||||
)
|
||||
} else {
|
||||
"The plugin connects when the server next starts.".to_string()
|
||||
},
|
||||
loopback_note = if loopback {
|
||||
format!(
|
||||
"\nThe sidecar listens on {} only. If the website runs on another machine, put a \
|
||||
TLS proxy in\nfront of it and give the site the proxy's URL \
|
||||
(docs/rust-link/INSTALL.md).\n",
|
||||
doc.web.bind
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
service_note = if registered {
|
||||
""
|
||||
} else {
|
||||
"\nNo service was registered, so nothing is listening yet — see the steps above.\n"
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_instance_config_parses_and_keeps_a_windows_path_intact() {
|
||||
let db = Path::new(r"C:\ProgramData\RunicGateway\rust\alpha\rust-link.db");
|
||||
let text = instance_config("alpha", 7800, 8091, db);
|
||||
let value: toml_lite::Doc = toml_lite::parse(&text);
|
||||
assert_eq!(value.get("game", "bind"), Some("127.0.0.1:7800"));
|
||||
assert_eq!(value.get("game", "server_id"), Some("alpha"));
|
||||
assert_eq!(value.get("web", "bind"), Some("127.0.0.1:8091"));
|
||||
assert_eq!(value.get("web", "auth_token"), Some(""));
|
||||
assert_eq!(
|
||||
value.get("store", "path"),
|
||||
Some(r"C:\ProgramData\RunicGateway\rust\alpha\rust-link.db")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_print_config_document_parses() {
|
||||
// What Rust-Link v0.1.0 prints (sidecar/src/config.rs::describe).
|
||||
let doc: ConfigDoc = serde_json::from_str(
|
||||
r#"{ "component": "rust-link-sidecar", "version": "0.1.0", "protocol": 12,
|
||||
"config_path": "/etc/runicgateway/rust/alpha.toml", "config_created": false,
|
||||
"token_generated": true,
|
||||
"game": { "bind": "127.0.0.1:7800", "server_id": "alpha" },
|
||||
"web": { "bind": "127.0.0.1:8091", "ws_path": "/ws", "auth_required": true,
|
||||
"auth_token": "t0k" },
|
||||
"store": { "path": "/var/lib/runicgateway/rust/alpha/rust-link.db" } }"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(port_of(&doc.web.bind), Some(8091));
|
||||
let text = handoff("alpha", &doc, "rust.example", None, true, false);
|
||||
for needle in [
|
||||
"alpha",
|
||||
"http://rust.example:8091",
|
||||
"t0k",
|
||||
"/admin/rust/servers",
|
||||
"12",
|
||||
] {
|
||||
assert!(text.contains(needle), "{needle} missing from:\n{text}");
|
||||
}
|
||||
assert!(text.contains("TLS proxy"), "{text}");
|
||||
|
||||
// D156: never a claim about a link nothing has seen.
|
||||
assert!(!text.contains("is connected"), "{text}");
|
||||
assert!(
|
||||
text.contains("The plugin connects when the server next starts."),
|
||||
"{text}"
|
||||
);
|
||||
let running = handoff("alpha", &doc, "rust.example", None, true, true);
|
||||
assert!(!running.contains("is connected"), "{running}");
|
||||
assert!(
|
||||
running.contains("doctor --game rust --server-id alpha"),
|
||||
"{running}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_port_is_skipped_and_a_kept_one_is_kept() {
|
||||
let port = choose_port(47_990, &[47_990, 47_991], None).unwrap();
|
||||
assert!(port >= 47_992);
|
||||
assert_eq!(
|
||||
choose_port(47_990, &[47_990], Some(47_990)).unwrap(),
|
||||
47_990
|
||||
);
|
||||
}
|
||||
|
||||
/// Enough TOML to read back the file `instance_config` writes — `[section]` and
|
||||
/// `key = 'literal'` / `key = "basic"` lines. The installer has no TOML dependency and this
|
||||
/// test is the only reader it needs.
|
||||
mod toml_lite {
|
||||
pub struct Doc(Vec<(String, String, String)>);
|
||||
impl Doc {
|
||||
pub fn get(&self, section: &str, key: &str) -> Option<&str> {
|
||||
self.0
|
||||
.iter()
|
||||
.find(|(s, k, _)| s == section && k == key)
|
||||
.map(|(_, _, v)| v.as_str())
|
||||
}
|
||||
}
|
||||
pub fn parse(text: &str) -> Doc {
|
||||
let mut section = String::new();
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines().map(str::trim) {
|
||||
if line.starts_with('#') || line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
section = name.to_string();
|
||||
} else if let Some((k, v)) = line.split_once('=') {
|
||||
let v = v.trim();
|
||||
let v = v
|
||||
.strip_prefix('\'')
|
||||
.and_then(|v| v.strip_suffix('\''))
|
||||
.or_else(|| v.strip_prefix('"').and_then(|v| v.strip_suffix('"')))
|
||||
.unwrap_or(v);
|
||||
out.push((section.clone(), k.trim().to_string(), v.to_string()));
|
||||
}
|
||||
}
|
||||
Doc(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
199
src/rustgame/uninstall.rs
Normal file
199
src/rustgame/uninstall.rs
Normal 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())),
|
||||
}
|
||||
}
|
||||
463
src/service.rs
463
src/service.rs
@@ -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,29 @@ 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, binary, config, restart)
|
||||
}
|
||||
|
||||
if windows_service_exists() {
|
||||
/// Registers (or reconfigures) one SCM service and sets its restart policy. It does not start it:
|
||||
/// [`start_registered`] does, after [`grant_service_access`].
|
||||
#[cfg(windows)]
|
||||
fn register_windows_named(
|
||||
name: &str,
|
||||
display: &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 +429,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
|
||||
"sc.exe",
|
||||
&[
|
||||
"create",
|
||||
WINDOWS_SERVICE,
|
||||
name,
|
||||
"binPath=",
|
||||
&bin_path,
|
||||
"start=",
|
||||
@@ -397,7 +437,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
|
||||
"obj=",
|
||||
&account,
|
||||
"DisplayName=",
|
||||
DISPLAY_NAME,
|
||||
display,
|
||||
],
|
||||
)
|
||||
.context(
|
||||
@@ -405,7 +445,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 +455,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
|
||||
"sc.exe",
|
||||
&[
|
||||
"failure",
|
||||
WINDOWS_SERVICE,
|
||||
name,
|
||||
"reset=",
|
||||
"86400",
|
||||
"actions=",
|
||||
@@ -423,42 +463,38 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
|
||||
],
|
||||
)?;
|
||||
|
||||
if restart && windows_service_state().contains("RUNNING") {
|
||||
stop_windows_service()?;
|
||||
}
|
||||
// 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure.
|
||||
let start = run("sc.exe", &["start", WINDOWS_SERVICE])?;
|
||||
if !start.status.success() && start.status.code() != Some(1056) {
|
||||
anyhow::bail!(windows_start_failure(
|
||||
start.status.code().unwrap_or(-1),
|
||||
binary,
|
||||
config
|
||||
));
|
||||
if restart && windows_service_state(name).contains("RUNNING") {
|
||||
stop_windows_service(name)?;
|
||||
}
|
||||
// NOT started here: the virtual account `sc create` just made cannot read the config yet —
|
||||
// `protect_config` locked it to SYSTEM and Administrators, and `grant_service_access` is what
|
||||
// lets the account in. Started first, the sidecar died on `Access is denied` and the service
|
||||
// sat STOPPED; the restart policy never fires for a clean exit with an error code. The phase 18
|
||||
// walk found it (D157). The caller grants, then calls `start_registered`.
|
||||
|
||||
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 +510,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 +524,259 @@ 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})"),
|
||||
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 +795,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 +863,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 +881,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);
|
||||
@@ -681,8 +959,11 @@ fn remove_platform(record: &ServiceRecord) -> Removal {
|
||||
)),
|
||||
}
|
||||
} else if let Some(user) = record.user.as_deref() {
|
||||
// Not "this installer did not create it": a Rust instance's service never owns the account
|
||||
// even when this installer made it, because every instance shares it — the last instance's
|
||||
// removal decides (`rustgame::uninstall`). Said that way, the line is true for both games.
|
||||
out.done.push(format!(
|
||||
"left the {user} account alone — this installer did not create it"
|
||||
"left the {user} account alone — it is not this service's to remove"
|
||||
));
|
||||
}
|
||||
out
|
||||
@@ -699,7 +980,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 +997,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 {
|
||||
@@ -877,6 +1166,47 @@ pub fn protect_config(
|
||||
protect_config_platform(config, data_dir, user, relocated)
|
||||
}
|
||||
|
||||
/// Starts a service that [`register`] or [`register_rust`] has just registered, once
|
||||
/// [`grant_service_access`] has let its account read the config.
|
||||
///
|
||||
/// A no-op on Linux, where registration starts the unit itself: the service user was known before
|
||||
/// the config existed, so `protect_config` had already handed the file to it. On Windows the order
|
||||
/// is the whole point — see `register_windows_named`. The outcome's `state` is refreshed so the
|
||||
/// run reports what the service is doing now, not what it was doing before it was started.
|
||||
pub fn start_registered(outcome: &mut Outcome, binary: &Path, config: &Path) -> Result<()> {
|
||||
start_registered_platform(outcome, binary, config)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn start_registered_platform(_outcome: &mut Outcome, _binary: &Path, _config: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn start_registered_platform(outcome: &mut Outcome, binary: &Path, config: &Path) -> Result<()> {
|
||||
let Outcome::Registered { name, state, .. } = outcome else {
|
||||
return Ok(());
|
||||
};
|
||||
let min_sidecar = if name.as_str() == WINDOWS_SERVICE {
|
||||
MIN_SERVICE_SIDECAR
|
||||
} else {
|
||||
MIN_RUST_SERVICE_SIDECAR
|
||||
};
|
||||
// 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure.
|
||||
let start = run("sc.exe", &["start", name])?;
|
||||
if !start.status.success() && start.status.code() != Some(1056) {
|
||||
anyhow::bail!(windows_start_failure_for(
|
||||
name,
|
||||
min_sidecar,
|
||||
start.status.code().unwrap_or(-1),
|
||||
binary,
|
||||
config
|
||||
));
|
||||
}
|
||||
*state = format!("{}, automatic start", windows_service_state(name));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Grants the registered service account access to what it must read and write.
|
||||
///
|
||||
/// A no-op on Linux, where the account was known before the config existed and `protect_config`
|
||||
@@ -954,10 +1284,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 +1489,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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
45
tests/fixtures/published-bundle-2026.09.15.json
vendored
Normal file
45
tests/fixtures/published-bundle-2026.09.15.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
47
tests/fixtures/published-bundle-v2-servuo.json
vendored
Normal file
47
tests/fixtures/published-bundle-v2-servuo.json
vendored
Normal 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
57
tests/fixtures/rust-bundle-v2.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user