feat(rust): --game rust, named instances, and schema-2 bundles (phase 18)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m7s

Module-rust phase 18, step 5 of docs/modules/rust/PLAN.md §34.2.7 (D146,
D148, D149, D153).

Bundles: ServUO is read at schema 2 from v2/servuo/ and lowered into the
schema-1 model. Schema 1 at the root is the fallback, so a pin from before
schema 2 still reproduces. Rust bundles are read from v2/rust/. v2 reads use
the contents API, because /raw/ is CDN-cached for six hours.

--game rust runs install, update, doctor and uninstall for Rust servers
(src/rustgame/):
- the framework is detected from its marker files, which were read off both
  rigs; both or neither is refused;
- --server-id names an instance: its own service (runicgateway-rust@<id>, or
  RunicGatewayRust-<id>), config, database and ports;
- the plugin config is written once, with ServerId and Port only. An existing
  one is never rewritten, and one naming another server refuses the run;
- each instance's sidecar.toml is written once with its ports and an absolute
  database path, and the sidecar generates the token into it;
- one binary per host. update moves every instance, and a replaced binary
  restarts all of them;
- doctor checks the plugin file hash, the plugin config's ServerId, the
  required uMod plugins (a warning), the service and /health, and passes when
  the plugin is connected;
- uninstall removes our plugin and keeps its config. --purge also removes the
  sidecar config and database. The last instance takes the binary, the
  template and the record, and the shared user only when no ServUO record
  remains.

service.rs takes the service name as a parameter internally. The ServUO
public API is unchanged.

Finding: Carbon 2.0.259's config.json has no folder keys, so carbon/plugins
and carbon/configs are what the installer uses. The plan expected a moved
directory to be readable there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-25 23:40:11 -05:00
parent 2285fff759
commit 7027a78a23
24 changed files with 3410 additions and 94 deletions

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

@@ -0,0 +1,297 @@
//! `doctor --game rust` — every recorded instance, or one with `--server-id`.
//!
//! The same rule as ServUO's doctor: every row asks the host what is true now, and the record only
//! says where to look. Per instance (docs/modules/rust/PLAN.md §34.2.3):
//!
//! - the framework, and whether the plugin file is still the one deployed;
//! - that the plugin's config names this instance;
//! - `requires_plugins` present, as a warning (D153);
//! - the service registered and running;
//! - `/health`: reachable, the protocol, and whether the plugin is connected.
//!
//! **The framework version floor is not measured from files.** A plugin that compiled and connected
//! on this framework is the fact a floor only approximates, so a connected plugin is the pass, and
//! the floors are printed beside a plugin that is not.
use std::path::Path;
use anyhow::Result;
use super::plugin;
use super::record::{Instance, RustRecord};
use super::server;
use crate::cli::Cli;
use crate::doctor::{health_of, report, Row};
use crate::{bundle, paths, service, ui};
pub fn run(cli: &Cli) -> Result<i32> {
let layout = paths::layout();
let record_path = layout.rust_record();
println!(
"\nRunic Gateway installer {} — doctor (Rust)",
env!("CARGO_PKG_VERSION")
);
let Some(record) = RustRecord::load(&record_path)? else {
println!();
ui::warn(&format!(
"No Rust deployment is recorded on this host.\n Looked for {}\n Run `install --game \
rust` first. If you installed with {} set, set it again for this run.",
record_path.display(),
paths::STATE_DIR_ENV
));
return Ok(1);
};
let mut rows = vec![Row::ok(
"Rust record",
format!(
"{} (bundle {}, {} instance(s), installer {})",
record_path.display(),
record.bundle.tag,
record.instances.len(),
record.installer.version
),
)];
let selected: Vec<(&String, &Instance)> = match &cli.server_id {
Some(id) => match record.instances.get_key_value(id) {
Some(pair) => vec![pair],
None => {
rows.push(Row::fail(
"Instance",
format!(
"{id:?} is not recorded (recorded: {})",
record
.instances
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
),
));
return Ok(report(&rows));
}
},
None => record.instances.iter().collect(),
};
// The floors, fetched once, for the note beside a plugin that has not connected. Read from the
// installed bundle rather than the current one: they describe the plugin that is installed.
let installed_bundle = bundle::fetch_rust(Some(&record.bundle.tag)).ok();
for (id, instance) in selected {
rows.extend(instance_rows(
id,
instance,
&record,
installed_bundle.as_ref().map(|b| &b.0),
));
}
rows.push(bundle_row(&record));
Ok(report(&rows))
}
fn instance_rows(
id: &str,
instance: &Instance,
record: &RustRecord,
bundle: Option<&bundle::RustBundle>,
) -> Vec<Row> {
let mut rows = Vec::new();
let label = |what: &str| format!("{id}: {what}");
// ── The server and its framework ─────────────────────────────────────────
let server = server::open(Path::new(&instance.server_root));
match &server {
Ok(s) if s.framework.as_str() == instance.framework => rows.push(Row::ok(
&label("server"),
format!("{} ({})", s.path.display(), instance.framework),
)),
Ok(s) => rows.push(
Row::fail(
&label("server"),
format!(
"{} now runs {}, but the plugin was installed for {}",
s.path.display(),
s.framework.as_str(),
instance.framework
),
)
.note(format!(
"reinstall it: install --game rust --server-id {id} --rust {}",
s.path.display()
)),
),
Err(e) => rows.push(Row::fail(&label("server"), e.to_string())),
}
// ── The plugin file: ours ────────────────────────────────────────────────
let plugin_path = Path::new(&instance.plugin_path);
match std::fs::read(plugin_path) {
Ok(bytes) if crate::util::sha256_bytes(&bytes) == instance.plugin_sha256 => {
rows.push(Row::ok(
&label("plugin"),
format!(
"{} (Rust-Plugins {})",
instance.plugin_path, record.plugin.tag
),
))
}
Ok(_) => rows.push(
Row::fail(
&label("plugin"),
format!("{} is not the file that was deployed", instance.plugin_path),
)
.note("edited or replaced by hand; `update --game rust` puts the released one back"),
),
Err(_) => rows.push(Row::fail(
&label("plugin"),
format!("{} is missing", instance.plugin_path),
)),
}
// ── The plugin's config: the website's ───────────────────────────────────
match plugin::read_config(Path::new(&instance.plugin_config)) {
Ok(Some(view)) if view.server_id.as_deref() == Some(id) => rows.push(Row::ok(
&label("plugin config"),
format!(
"ServerId {id}, Port {}",
view.port
.map(|p| p.to_string())
.unwrap_or_else(|| "default".into())
),
)),
Ok(Some(view)) => rows.push(Row::fail(
&label("plugin config"),
format!(
"{} says ServerId {:?}; this instance is {id:?}",
instance.plugin_config,
view.server_id.unwrap_or_else(|| "main (default)".into())
),
)),
Ok(None) => rows.push(Row::warn(
&label("plugin config"),
format!(
"{} is gone — the plugin will write one with ServerId \"main\" on its next load",
instance.plugin_config
),
)),
Err(e) => rows.push(Row::fail(&label("plugin config"), e.to_string())),
}
// ── Third-party plugins: reported, never installed (D153) ────────────────
if let (Ok(s), Some(b)) = (&server, bundle) {
let missing = plugin::missing_plugins(&s.plugins_dir(), &b.payload.compat.requires_plugins);
rows.push(if missing.is_empty() {
Row::ok(
&label("required plugins"),
b.payload.compat.requires_plugins.join(", "),
)
} else {
Row::warn(
&label("required plugins"),
format!(
"missing {} in {}",
missing.join(", "),
s.plugins_dir().display()
),
)
.note("install from uMod; the features that use them stay off until then")
});
}
// ── The service ──────────────────────────────────────────────────────────
match &instance.service {
Some(svc) => {
let status = service::observe(&svc.kind, &svc.name);
let detail = format!("{} {}", svc.name, status.detail);
rows.push(if status.running && status.enabled {
Row::ok(&label("service"), detail)
} else {
Row::fail(&label("service"), detail)
});
}
None => rows.push(Row::fail(
&label("service"),
"none registered — the install printed the steps to register it by hand",
)),
}
// ── The sidecar, and the plugin through it ───────────────────────────────
let health = health_of(&format!("127.0.0.1:{}", instance.web_port));
let Some(health) = health else {
rows.push(Row::fail(
&label("sidecar"),
format!(
"nothing answered http://127.0.0.1:{}/health",
instance.web_port
),
));
return rows;
};
rows.push(Row::ok(
&label("sidecar"),
format!(
"127.0.0.1:{} /health {}, database {}",
instance.web_port,
health.status.as_deref().unwrap_or("ok"),
health.database.as_deref().unwrap_or("unknown")
),
));
match health.protocol {
Some(p) if p == record.bundle.protocol => {
rows.push(Row::ok(&label("protocol"), p.to_string()))
}
Some(p) => rows.push(Row::fail(
&label("protocol"),
format!(
"the sidecar speaks {p}; bundle {} is {}",
record.bundle.tag, record.bundle.protocol
),
)),
None => rows.push(Row::warn(&label("protocol"), "the sidecar did not say")),
}
let floors = bundle
.map(|b| {
b.payload
.compat
.frameworks
.iter()
.map(|(f, v)| format!("{f} {}", v.min_version))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let running = server.as_ref().is_ok_and(|s| server::is_running(&s.path));
rows.push(match (health.plugin_connected, running) {
(Some(true), _) => Row::ok(&label("plugin connected"), "yes"),
(_, false) => Row::warn(&label("plugin connected"), "no — the server is not running"),
(_, true) => {
Row::fail(&label("plugin connected"), "no, and the server is running").note(format!(
"the plugin has not dialled 127.0.0.1:{}. Check the server console for a compile \
error; the plugin is known good on {floors}",
instance.game_port
))
}
});
rows
}
/// Whether a newer Rust bundle than the installed one is published.
fn bundle_row(record: &RustRecord) -> Row {
match bundle::fetch_rust(None) {
Ok((current, _)) if current.bundle == record.bundle.tag => {
Row::ok("Bundle", format!("{} is current", record.bundle.tag))
}
Ok((current, _)) => Row::warn(
"Bundle",
format!(
"{} is installed; {} is published",
record.bundle.tag, current.bundle
),
)
.note("update --game rust moves every instance to it"),
Err(e) => Row::warn("Bundle", format!("could not check for a newer one: {e}")),
}
}

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

@@ -0,0 +1,750 @@
//! `install --game rust` and `update --game rust` — one pipeline, as for ServUO.
//!
//! docs/modules/rust/PLAN.md §34.2.3. What an instance is: one Rust server root, its plugin, and a
//! sidecar of its own — service, config, database, game port and web port — named by `--server-id`
//! (D148). What instances share: the one sidecar binary and the one bundle, so `update` moves every
//! instance on the host together (§34.4).
//!
//! The order follows ServUO's, for the same reasons:
//!
//! 1. **Resolve everything that can fail cheaply first** — the bundle, this host's binary, the
//! server root and its framework, the plugin's config and the ports — so a run that cannot
//! finish ends before anything is written.
//! 2. **The sidecar before the plugin.** Both frameworks load a plugin file the moment it lands,
//! even on a running server (§34.2.3), so the sidecar it dials is registered first.
//! 3. **Provision the config before registering the service**, record last, handoff after that.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use super::plugin::{self, ConfigView};
use super::record::{ComponentRecord, Instance, RustRecord, SCHEMA};
use super::server::{self, RustServer};
use super::sidecar;
use crate::bundle::{self, RustBundle};
use crate::cli::Cli;
use crate::install::Mode;
use crate::record::{now_rfc3339, BinaryRef, BundleRef, InstallerInfo, ServiceRecord};
use crate::sidecar::BinaryAction;
use crate::util::{write_atomic, TempDir};
use crate::{paths, service, ui};
/// The plugin's own default game port, and where the search for a free one starts.
const GAME_PORT_START: u16 = 7799;
/// Where the search for a free web port starts. Clear of ServUO's uo-link default (8080).
const WEB_PORT_START: u16 = 8090;
/// One instance this run is about, with every decision made and nothing written.
struct Planned {
id: String,
server: RustServer,
running: bool,
plugin_config: Option<ConfigView>,
plugin_action: BinaryAction,
game_port: u16,
web_port: u16,
config_path: PathBuf,
db_path: PathBuf,
config_exists: bool,
}
pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
let layout = paths::layout();
let record_path = layout.rust_record();
let prior = RustRecord::load(&record_path)?;
// ── Which instances ──────────────────────────────────────────────────────
let targets: Vec<(String, PathBuf)> = match mode {
Mode::Install => {
let id = cli.server_id.clone().ok_or_else(|| {
anyhow::anyhow!(
"install --game rust needs --server-id <id>: the id the website will know this \
server by, which also names its sidecar. For example:\n \
runicgateway-installer install --game rust --rust /srv/rust --server-id main"
)
})?;
let recorded = prior
.as_ref()
.and_then(|p| p.instances.get(&id))
.map(|i| PathBuf::from(&i.server_root));
let root = match (&cli.rust, recorded) {
(Some(path), _) => PathBuf::from(path),
(None, Some(root)) => root,
(None, None) => bail!(
"install --game rust needs --rust <path>: the Rust server root, the directory \
holding RustDedicated"
),
};
vec![(id, root)]
}
Mode::Update => {
if cli.server_id.is_some() || cli.rust.is_some() {
bail!(
"update --game rust moves every Rust instance on this host together — they \
share one sidecar binary, and a host whose instances spoke two protocols is one \
no bundle describes. Run it without --server-id or --rust."
);
}
let Some(prior) = prior.as_ref() else {
bail!(
"nothing to update — no Rust deployment is recorded on this host (looked for {}). \
Run `install --game rust` first.",
record_path.display()
);
};
prior
.instances
.iter()
.map(|(id, i)| (id.clone(), PathBuf::from(&i.server_root)))
.collect()
}
};
// ── What to install ──────────────────────────────────────────────────────
let (bundle, bundle_url) = bundle::fetch_rust(cli.bundle.as_deref())?;
let asset = bundle.sidecar_asset()?.clone();
println!(
"\nRunic Gateway installer {} — Rust {} to bundle {} (protocol {}){}",
env!("CARGO_PKG_VERSION"),
if mode.is_update() {
"update"
} else {
"install"
},
bundle.bundle,
bundle.protocol,
if cli.verify {
" [--verify: nothing will be written]"
} else {
""
}
);
println!();
ui::row(
"Sidecar",
&format!(
"{:<24} protocol {}",
format!("rust-link {}", bundle.sidecar.tag),
bundle.sidecar.protocol
),
);
ui::row(
"Plugin",
&format!(
"{:<24} protocol {}",
format!("Rust-Plugins {}", bundle.payload.tag),
bundle.payload.protocol
),
);
// The plugin is fetched before anything is planned: its manifest is the last statement of the
// protocol to check, and a pair that disagrees must stop the run before any file moves.
let scratch = TempDir::new("runicgateway-rust")?;
let tarball = scratch.path().join(&bundle.payload.asset.name);
crate::net::download_verified(
&bundle.payload.asset.url,
&tarball,
&bundle.payload.asset.sha256,
)?;
let released = plugin::read_tarball(&tarball)?;
if released.manifest.version != bundle.payload.version {
ui::warn(&format!(
"the plugin tarball says version {} but bundle {} names {}. The checksum matched, so \
this is a labelling mismatch in the release rather than a wrong download.",
released.manifest.version, bundle.bundle, bundle.payload.version
));
}
if released.manifest.protocol != bundle.protocol {
bail!(
"the plugin in bundle {} declares protocol {}, and the bundle {}. The Rust game link \
has no version handshake, so a mismatched plugin mis-parses rather than being \
refused — refusing the pair here.",
bundle.bundle,
released.manifest.protocol,
bundle.protocol
);
}
// ── Plan every instance ──────────────────────────────────────────────────
let mut planned = Vec::new();
for (id, root) in &targets {
planned.push(plan_instance(
cli,
&layout,
prior.as_ref(),
id,
root,
&released,
)?);
}
let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?;
print_plan(&layout, &planned, binary_action, &bundle);
if cli.verify {
println!(
"\nNothing was written. Re-run without --verify to {}.",
if mode.is_update() { "update" } else { "deploy" }
);
return Ok(());
}
preflight_writable(&layout)?;
// ── The sidecar binary, shared by every instance ─────────────────────────
let prepared = service::prepare(layout.relocated);
let binary_sha256 = if binary_action.writes() {
// Every instance runs this one file: on Windows it is locked while any of them runs, and on
// Linux replacing it leaves each one serving old code until restarted.
let mut running: Vec<String> = prior
.as_ref()
.map(|p| p.instances.keys().cloned().collect())
.unwrap_or_default();
running.extend(planned.iter().map(|p| p.id.clone()));
running.sort();
running.dedup();
service::stop_rust_instances(&prepared.manager, &running)?;
let sha = crate::sidecar::place(&asset, &layout.rust_sidecar_bin, scratch.path())?;
ui::ok(&format!(
"rust-link binary verified sha256 {}…",
&asset.sha256[..8.min(asset.sha256.len())]
));
sha
} else {
asset.sha256.trim().to_ascii_lowercase()
};
// ── Each instance ────────────────────────────────────────────────────────
let mut record = prior
.clone()
.unwrap_or_else(|| empty_record(&bundle, &bundle_url));
let mut handoffs = Vec::new();
for plan in &planned {
let (instance, doc, registered) = deploy_instance(
&layout,
&prepared,
&bundle,
&released,
plan,
binary_action.writes(),
prior
.as_ref()
.and_then(|p| p.instances.get(&plan.id))
.map(|i| i.plugin_config_written)
.unwrap_or(false),
)?;
record.instances.insert(plan.id.clone(), instance);
handoffs.push((plan.id.clone(), doc, registered));
}
// Instances this run did not touch still run the binary it just replaced.
if binary_action.writes() {
if let Some(prior) = &prior {
for (id, instance) in &prior.instances {
if planned.iter().any(|p| &p.id == id) || instance.service.is_none() {
continue;
}
match service::restart_rust_instance(id) {
Ok(()) => ui::ok(&format!(
"restarted {} on the new binary",
service::rust_service_name(id)
)),
Err(e) => ui::warn(&format!("could not restart {id} on the new binary: {e}")),
}
}
}
}
// ── Record ───────────────────────────────────────────────────────────────
record.schema = SCHEMA;
record.installer = InstallerInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
};
record.updated = now_rfc3339();
record.bundle = BundleRef {
tag: bundle.bundle.clone(),
protocol: bundle.protocol,
url: bundle_url.clone(),
};
record.sidecar = component(
&bundle.sidecar.repo,
&bundle.sidecar.tag,
&bundle.sidecar.version,
"",
bundle.sidecar.protocol,
);
record.binary = BinaryRef {
path: layout.rust_sidecar_bin.display().to_string(),
sha256: binary_sha256,
};
record.plugin = component(
&bundle.payload.repo,
&bundle.payload.tag,
&bundle.payload.version,
&bundle.payload.commit,
bundle.payload.protocol,
);
record.service_user_created |= prepared.user_created;
match prior.as_ref() {
Some(previous) if previous.same_deployment_as(&record) => {
println!("\n {} unchanged", record_path.display());
}
_ => {
record.save(&record_path).with_context(|| {
format!(
"cannot write {} — run as root/Administrator, or set {} for a test run",
record_path.display(),
paths::STATE_DIR_ENV
)
})?;
println!("\n Recorded {}", record_path.display());
}
}
// ── What is left for the operator ────────────────────────────────────────
match mode {
Mode::Install => {
let host = crate::install::resolve_host(cli);
for (id, doc, registered) in &handoffs {
println!(
"{}",
sidecar::handoff(id, doc, &host, cli.site_url.as_deref(), *registered)
);
}
}
Mode::Update => {
let before = prior.as_ref().map(|p| p.bundle.clone());
match before {
Some(b) if b.tag == bundle.bundle => {
println!("\nAlready on bundle {} — nothing moved.", bundle.bundle)
}
Some(b) => {
println!(
"\nMoved {} instance(s) from bundle {} to {}.",
planned.len(),
b.tag,
bundle.bundle
);
if b.protocol != bundle.protocol {
ui::warn(&format!(
"The protocol moved from {} to {}. The website's Rust module must speak \
{} too, or it will be refused with 409 — update the site first.",
b.protocol, bundle.protocol, bundle.protocol
));
}
}
None => {}
}
}
}
Ok(())
}
/// Every decision for one instance, made without writing anything.
fn plan_instance(
cli: &Cli,
layout: &paths::Layout,
prior: Option<&RustRecord>,
id: &str,
root: &Path,
released: &plugin::Released,
) -> Result<Planned> {
let server = server::open(root)
.with_context(|| format!("cannot use {} as a Rust server root", root.display()))?;
let recorded = prior.and_then(|p| p.instances.get(id));
if let Some(instance) = recorded {
if Path::new(&instance.server_root) != server.path {
bail!(
"instance {id:?} is recorded for {}, not {}. An id names one server; uninstall it \
first (`uninstall --game rust --server-id {id}`) to move it.",
instance.server_root,
server.path.display()
);
}
}
if let Some((other, _)) = prior.and_then(|p| {
p.instances
.iter()
.find(|(other, i)| other.as_str() != id && Path::new(&i.server_root) == server.path)
}) {
bail!(
"{} is already installed as instance {other:?}. One server root has one plugin and one \
ServerId; run with --server-id {other}.",
server.path.display()
);
}
let plugin_config = plugin::read_config(&server.plugin_config_path())?;
let held_game = prior.map(|p| p.game_ports_except(id)).unwrap_or_default();
let game_port = match &plugin_config {
Some(view) => {
plugin::check_server_id(view, id, &server.plugin_config_path())?;
// A config without a Port gets the plugin's default.
let port = view.port.unwrap_or(GAME_PORT_START);
if held_game.contains(&port) {
bail!(
"{} says the plugin dials port {port}, which another Rust instance on this host \
already listens on. Give it a free Port and run again.",
server.plugin_config_path().display()
);
}
port
}
None => sidecar::choose_port(GAME_PORT_START, &held_game, recorded.map(|i| i.game_port))?,
};
let held_web = prior.map(|p| p.web_ports_except(id)).unwrap_or_default();
if let Some(port) = cli.web_port {
if held_web.contains(&port) {
bail!("--web-port {port} is already another Rust instance's web port");
}
}
let web_port = match (cli.web_port, recorded) {
(Some(port), _) => port,
(None, Some(instance)) => instance.web_port,
(None, None) => sidecar::choose_port(WEB_PORT_START, &held_web, None)?,
};
let plugin_action = match std::fs::read(server.plugin_path()) {
Err(_) => BinaryAction::Install,
Ok(bytes) if crate::util::sha256_bytes(&bytes) == released.sha256 => {
BinaryAction::Unchanged
}
Ok(_) => BinaryAction::Replace,
};
let config_path = layout.rust_config(id);
Ok(Planned {
id: id.to_string(),
running: server::is_running(&server.path),
config_exists: config_path.exists(),
config_path,
db_path: layout.rust_db(id),
server,
plugin_config,
plugin_action,
game_port,
web_port,
})
}
fn print_plan(
layout: &paths::Layout,
planned: &[Planned],
binary: BinaryAction,
bundle: &RustBundle,
) {
ui::row(
"binary",
&format!("{} {}", layout.rust_sidecar_bin.display(), binary.label()),
);
for p in planned {
ui::heading(&format!("Instance {}", p.id));
ui::row(
"server",
&format!(
"{} ({}, {})",
p.server.path.display(),
p.server.framework.as_str(),
if p.running {
"running — the plugin loads at once"
} else {
"not running — the plugin loads at next boot"
}
),
);
ui::row(
"plugin",
&format!(
"{} {}",
p.server.plugin_path().display(),
p.plugin_action.label()
),
);
ui::row(
"plugin config",
&match &p.plugin_config {
Some(_) => format!(
"{} kept (the website's)",
p.server.plugin_config_path().display()
),
None => format!(
"{} written: ServerId {}, Port {}",
p.server.plugin_config_path().display(),
p.id,
p.game_port
),
},
);
ui::row(
"sidecar config",
&format!(
"{} {}",
p.config_path.display(),
if p.config_exists { "kept" } else { "written" }
),
);
ui::row(
"ports",
&format!(
"game 127.0.0.1:{} web 127.0.0.1:{}",
p.game_port, p.web_port
),
);
let missing = plugin::missing_plugins(
&p.server.plugins_dir(),
&bundle.payload.compat.requires_plugins,
);
if !missing.is_empty() {
ui::warn(&format!(
"{} not in {} — the features that use {} stay off until you install {} from uMod. \
The installer does not fetch third-party plugins (D153).",
missing.join(", "),
p.server.plugins_dir().display(),
if missing.len() == 1 { "it" } else { "them" },
if missing.len() == 1 { "it" } else { "them" },
));
}
}
}
/// Writes one instance: plugin config, sidecar config, service, plugin — in that order.
fn deploy_instance(
layout: &paths::Layout,
prepared: &service::Prepared,
bundle: &RustBundle,
released: &plugin::Released,
plan: &Planned,
binary_changed: bool,
plugin_config_written_before: bool,
) -> Result<(Instance, sidecar::ConfigDoc, bool)> {
ui::heading(&format!("Instance {}", plan.id));
let data_dir = layout.rust_data_dir(&plan.id);
std::fs::create_dir_all(&data_dir)
.with_context(|| format!("cannot create {}", data_dir.display()))?;
// The plugin's config, once, and only if it does not exist (§34.2.3).
let plugin_config_path = plan.server.plugin_config_path();
let wrote_plugin_config = plan.plugin_config.is_none();
if wrote_plugin_config {
if let Some(parent) = plugin_config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
write_atomic(
&plugin_config_path,
plugin::initial_config(&plan.id, plan.game_port).as_bytes(),
)?;
ui::ok(&format!("wrote {}", plugin_config_path.display()));
}
// The sidecar's config, once; the token is the sidecar's to generate.
if !plan.config_exists {
if let Some(parent) = plan.config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
write_atomic(
&plan.config_path,
sidecar::instance_config(&plan.id, plan.game_port, plan.web_port, &plan.db_path)
.as_bytes(),
)?;
}
let doc = sidecar::print_config(&layout.rust_sidecar_bin, &plan.config_path)?;
if doc.protocol != bundle.protocol {
bail!(
"the installed rust-link sidecar reports protocol {} but bundle {} was composed at {}. \
Refusing to register a service for a pair that was never checked together.",
doc.protocol,
bundle.bundle,
bundle.protocol
);
}
if doc.version != bundle.sidecar.version {
ui::warn(&format!(
"the installed binary reports version {} but bundle {} names {}. The checksum matched, \
so this is a labelling mismatch in the release rather than a wrong download.",
doc.version, bundle.bundle, bundle.sidecar.version
));
}
// The sidecar's server_id is a cross-check against the plugin's hello. An edited config that
// names another server makes every hello log a disagreement, so it is said here, once.
if !doc.game.server_id.is_empty() && doc.game.server_id != plan.id {
ui::warn(&format!(
"{} cross-checks server id {:?}, but this instance is {:?}; the sidecar will log a \
disagreement on every connect.",
doc.config_path, doc.game.server_id, plan.id
));
}
// An existing sidecar config is the operator's; what it says is what runs. Say so when it
// disagrees with the plugin, because that pair never meets.
if let Some(port) = sidecar::port_of(&doc.game.bind) {
if port != plan.game_port {
ui::warn(&format!(
"{} listens for the plugin on {port}, but the plugin dials {}. Make them agree, or \
the plugin never connects.",
doc.config_path, plan.game_port
));
}
}
ui::row(
"sidecar config",
&format!(
"{} {}",
doc.config_path,
if doc.token_generated {
"token generated"
} else {
"token kept"
}
),
);
service::protect_config(
&plan.config_path,
&data_dir,
prepared.user.as_deref(),
layout.relocated,
)?;
let outcome = service::register_rust(
prepared,
layout,
&plan.id,
&plan.config_path,
binary_changed,
)?;
service::grant_service_access(&plan.config_path, &data_dir, &outcome)?;
let service_record = match &outcome {
service::Outcome::Registered {
kind,
name,
user,
state,
..
} => {
ui::row("service", &format!("{name} {state}"));
Some(ServiceRecord {
kind: (*kind).to_string(),
name: name.clone(),
unit_path: None,
user: user.clone(),
// The account is shared with every instance and possibly with ServUO's sidecar, so
// no single instance's removal may delete it (see `RustRecord`).
user_created: false,
})
}
service::Outcome::Skipped { reason, manual } => {
ui::warn(&format!(
"service NOT REGISTERED — {reason}.\n The binary and config are in place; nothing is \
running them. Do this by hand:"
));
print!("{manual}");
None
}
};
// The plugin last: it loads the moment it lands, and its sidecar is now there to dial.
let plugin_path = plan.server.plugin_path();
if plan.plugin_action.writes() {
std::fs::create_dir_all(plan.server.plugins_dir())
.with_context(|| format!("cannot create {}", plan.server.plugins_dir().display()))?;
write_atomic(&plugin_path, &released.source)?;
ui::ok(&format!(
"plugin {} {}",
if plan.plugin_action == BinaryAction::Replace {
"replaced"
} else {
"installed"
},
plugin_path.display()
));
}
Ok((
Instance {
server_root: plan.server.path.display().to_string(),
framework: plan.server.framework.as_str().to_string(),
plugin_path: plugin_path.display().to_string(),
plugin_sha256: released.sha256.clone(),
plugin_config: plugin_config_path.display().to_string(),
plugin_config_written: wrote_plugin_config || plugin_config_written_before,
game_port: plan.game_port,
web_port: sidecar::port_of(&doc.web.bind).unwrap_or(plan.web_port),
config_path: doc.config_path.clone(),
db_path: doc.store.path.clone(),
service: service_record,
},
doc,
outcome.registered(),
))
}
fn component(repo: &str, tag: &str, version: &str, commit: &str, protocol: u32) -> ComponentRecord {
ComponentRecord {
repo: repo.to_string(),
tag: tag.to_string(),
version: version.to_string(),
commit: commit.to_string(),
protocol,
}
}
fn empty_record(bundle: &RustBundle, url: &str) -> RustRecord {
RustRecord {
schema: SCHEMA,
installer: InstallerInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
},
updated: String::new(),
bundle: BundleRef {
tag: bundle.bundle.clone(),
protocol: bundle.protocol,
url: url.to_string(),
},
sidecar: component("", "", "", "", 0),
binary: BinaryRef {
path: String::new(),
sha256: String::new(),
},
plugin: component("", "", "", "", 0),
service_user_created: false,
instances: Default::default(),
extra: Default::default(),
}
}
/// Fails before anything is written if this process cannot write where it must.
fn preflight_writable(layout: &paths::Layout) -> Result<()> {
let bin_dir = layout
.rust_sidecar_bin
.parent()
.unwrap_or(&layout.rust_sidecar_bin)
.to_path_buf();
for dir in [
layout.rust_config_dir(),
layout.data_dir.join("rust"),
bin_dir,
] {
std::fs::create_dir_all(&dir)
.and_then(|_| {
let probe = dir.join(".runicgateway-write-test");
std::fs::write(&probe, b"")?;
std::fs::remove_file(&probe)
})
.with_context(|| {
format!(
"cannot write to {}. Run as {}, or set {} for a test run (no service is \
registered then).",
dir.display(),
if cfg!(windows) {
"Administrator"
} else {
"root (sudo)"
},
paths::STATE_DIR_ENV
)
})?;
}
Ok(())
}

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

@@ -0,0 +1,34 @@
//! `--game rust`: the installer's second game (docs/modules/rust/PLAN.md §34, R4).
//!
//! The same four verbs as ServUO and the same bundle discipline — an exact, protocol-checked pair
//! from CI, never "latest of each" — over a different shape of host. A Rust host commonly runs
//! several servers, and R8 gives each its own sidecar, so where ServUO has one deployment this has
//! **named instances** (D148): one server root, one plugin, and one sidecar service per
//! `--server-id`, all sharing one binary and one bundle.
//!
//! Kept in its own module rather than threaded through the ServUO pipeline: the two share the
//! bundle reader, the download and checksum code, the service machinery and the report format, and
//! share nothing about what is deployed or where. ServUO's files and record are untouched by any
//! of this.
mod doctor;
mod install;
mod plugin;
mod record;
mod server;
mod sidecar;
mod uninstall;
use anyhow::Result;
use crate::cli::{Cli, Command};
/// Runs one verb for Rust. The `i32` is the exit code, as for ServUO.
pub fn run(cli: &Cli, command: Command) -> Result<i32> {
match command {
Command::Install => install::deploy(cli, crate::install::Mode::Install).map(|()| 0),
Command::Update => install::deploy(cli, crate::install::Mode::Update).map(|()| 0),
Command::Doctor => doctor::run(cli),
Command::Uninstall => uninstall::run(cli),
}
}

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

@@ -0,0 +1,238 @@
//! The plugin: its released tarball, and its config in the server root.
//!
//! Two files, two owners (docs/modules/rust/PLAN.md §34.2.3):
//!
//! - **`RunicGateway.cs` is the installer's.** It comes from the bundle, is replaced when the bundle
//! moves, and is removed by `uninstall`.
//! - **`RunicGateway.json` is the website's.** The plugin writes it, the site edits it through the
//! plugin, and it locks `ServerId`. The installer writes it exactly once — when it does not exist
//! yet, holding just `ServerId` and `Port` — and never rewrites it. An existing one whose
//! `ServerId` is not this instance's refuses the run, naming both. That is the same rule the
//! ServUO overlay follows for `Bridge.cfg`.
use std::collections::BTreeMap;
use std::io::Read;
use std::path::Path;
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use crate::util::sha256_bytes;
/// The fixed top directory inside the plugin tarball (Rust-Plugins' release.yml).
const PREFIX: &str = "runicgateway-rust-plugin";
/// `runicgateway-rust-plugin/manifest.json`, which the release folds from `overlay.toml`.
#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
pub version: String,
pub protocol: u32,
#[serde(default)]
pub files: BTreeMap<String, String>,
}
/// The released plugin, read out of its tarball.
#[derive(Debug, Clone)]
pub struct Released {
pub manifest: Manifest,
pub source: Vec<u8>,
pub sha256: String,
}
/// Reads the plugin and its manifest out of a downloaded tarball, and checks that the two agree.
pub fn read_tarball(path: &Path) -> Result<Released> {
let file =
std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file));
let mut manifest: Option<Vec<u8>> = None;
let mut source: Option<Vec<u8>> = None;
for entry in archive
.entries()
.context("the plugin tarball is not a tar.gz")?
{
let mut entry = entry.context("the plugin tarball is truncated")?;
let name = entry.path()?.to_string_lossy().replace('\\', "/");
let slot = if name == format!("{PREFIX}/manifest.json") {
&mut manifest
} else if name == format!("{PREFIX}/{}", super::server::PLUGIN_FILE) {
&mut source
} else {
continue;
};
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
*slot = Some(bytes);
}
let manifest = manifest
.ok_or_else(|| anyhow::anyhow!("the plugin tarball has no {PREFIX}/manifest.json"))?;
let source = source.ok_or_else(|| {
anyhow::anyhow!(
"the plugin tarball has no {PREFIX}/{}",
super::server::PLUGIN_FILE
)
})?;
let manifest: Manifest =
serde_json::from_slice(&manifest).context("the plugin's manifest.json is unreadable")?;
let sha256 = sha256_bytes(&source);
if let Some(declared) = manifest.files.get(super::server::PLUGIN_FILE) {
if !declared.eq_ignore_ascii_case(&sha256) {
bail!(
"the plugin in the tarball does not match its own manifest (sha256 {sha256}, \
manifest says {declared}). The tarball matched the bundle's checksum, so the \
release itself is inconsistent — refusing it."
);
}
}
Ok(Released {
manifest,
source,
sha256,
})
}
/// What the plugin's config says, as far as the installer cares.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigView {
pub server_id: Option<String>,
pub port: Option<u16>,
}
/// Reads `RunicGateway.json`, or `None` when the plugin has never written one.
pub fn read_config(path: &Path) -> Result<Option<ConfigView>> {
if !path.exists() {
return Ok(None);
}
let text =
std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let value: serde_json::Value = serde_json::from_str(&text).with_context(|| {
format!(
"{} is not valid JSON. The plugin will not load it either; fix or remove it.",
path.display()
)
})?;
Ok(Some(ConfigView {
server_id: value
.get("ServerId")
.and_then(|v| v.as_str())
.map(str::to_string),
port: value
.get("Port")
.and_then(|v| v.as_u64())
.and_then(|p| u16::try_from(p).ok()),
}))
}
/// The config the installer writes when there is none: just the two keys it decides. The plugin
/// fills in every other key on its first load, with its own defaults (§34.2.3).
pub fn initial_config(server_id: &str, port: u16) -> String {
let doc = serde_json::json!({ "ServerId": server_id, "Port": port });
let mut text = serde_json::to_string_pretty(&doc).unwrap_or_default();
text.push('\n');
text
}
/// Refuses an existing config that names another server.
pub fn check_server_id(view: &ConfigView, server_id: &str, path: &Path) -> Result<()> {
match view.server_id.as_deref() {
Some(id) if id == server_id => Ok(()),
Some(id) => bail!(
"{} already names this server {id:?}, and this run was asked to install {server_id:?}.\n \
The website locks a server's id once it has seen it, and this installer never rewrites \
the plugin's config. Run again with --server-id {id}, or — if this server really is \
new to the site — remove that file and run again.",
path.display()
),
// A config with no ServerId gets the plugin's default ("main") on load. Treat that as what
// it will become rather than as agreement.
None => {
if server_id == "main" {
Ok(())
} else {
bail!(
"{} has no ServerId, so the plugin will call this server \"main\"; this run was \
asked to install {server_id:?}. Add \"ServerId\": \"{server_id}\" to it, or run \
with --server-id main.",
path.display()
)
}
}
}
}
/// Which of `required` are missing from a plugins directory. Reported, never installed (D153).
pub fn missing_plugins(plugins_dir: &Path, required: &[String]) -> Vec<String> {
required
.iter()
.filter(|name| !plugins_dir.join(format!("{name}.cs")).is_file())
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
#[test]
fn the_first_config_holds_only_what_the_installer_decides() {
let text = initial_config("alpha", 7800);
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(value.as_object().unwrap().len(), 2);
assert_eq!(value["ServerId"], "alpha");
assert_eq!(value["Port"], 7800);
}
#[test]
fn an_existing_config_for_another_server_refuses_the_run() {
let path = Path::new("oxide/config/RunicGateway.json");
let view = ConfigView {
server_id: Some("rust-oxide".into()),
port: Some(7799),
};
assert!(check_server_id(&view, "rust-oxide", path).is_ok());
let err = check_server_id(&view, "alpha", path)
.unwrap_err()
.to_string();
assert!(
err.contains("\"rust-oxide\"") && err.contains("\"alpha\""),
"{err}"
);
}
#[test]
fn a_config_without_an_id_is_main() {
let path = Path::new("x.json");
let view = ConfigView {
server_id: None,
port: None,
};
assert!(check_server_id(&view, "main", path).is_ok());
assert!(check_server_id(&view, "alpha", path).is_err());
}
#[test]
fn the_plugins_config_is_read_back_without_the_rest_of_its_keys() {
let dir = TempDir::new("rg-rust-cfg").unwrap();
let path = dir.path().join("RunicGateway.json");
assert_eq!(read_config(&path).unwrap(), None);
// What the rig's plugin actually wrote, 2026-09-26.
std::fs::write(
&path,
r#"{ "Host": "127.0.0.1", "Port": 7799, "QueueCap": 5000, "ServerId": "rust-oxide",
"EventsEnabled": true, "MapMaxBases": 2000 }"#,
)
.unwrap();
let view = read_config(&path).unwrap().unwrap();
assert_eq!(view.server_id.as_deref(), Some("rust-oxide"));
assert_eq!(view.port, Some(7799));
}
#[test]
fn a_missing_required_plugin_is_named() {
let dir = TempDir::new("rg-rust-req").unwrap();
std::fs::write(dir.path().join("Kits.cs"), b"").unwrap();
let missing = missing_plugins(dir.path(), &["Kits".into(), "ZoneManager".into()]);
assert_eq!(missing, ["ZoneManager"]);
}
}

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

@@ -0,0 +1,208 @@
//! `rust/install.json` — what this host has deployed for Rust.
//!
//! Its own file rather than a list added to ServUO's `install.json` (docs/modules/rust/PLAN.md
//! §34.4): a host running both games has two records that cannot corrupt each other, and an
//! installer that predates Rust never sees this one at all.
//!
//! One bundle and one sidecar binary for the host, and a map of instances. `update` moves every
//! instance together because they share the binary (§34.4), which is why the bundle is recorded once
//! rather than per instance. Like `install.json`, **the auth tokens are not here** — they live in
//! each instance's `sidecar.toml` — and fields this build has no name for are carried through.
use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::record::{BinaryRef, BundleRef, InstallerInfo, ServiceRecord};
use crate::util::write_atomic;
/// The shape of this document.
pub const SCHEMA: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RustRecord {
pub schema: u32,
pub installer: InstallerInfo,
pub updated: String,
pub bundle: BundleRef,
pub sidecar: ComponentRecord,
pub binary: BinaryRef,
pub plugin: ComponentRecord,
/// This installer created the shared `runicgateway` service user. Kept here rather than on each
/// instance: the account outlives any one instance and may also run ServUO's sidecar, so only
/// the last Rust instance's removal — on a host with no ServUO record — may delete it.
#[serde(default)]
pub service_user_created: bool,
pub instances: BTreeMap<String, Instance>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
/// A released component, as installed.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ComponentRecord {
pub repo: String,
pub tag: String,
pub version: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub commit: String,
pub protocol: u32,
}
/// One Rust server and its sidecar.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Instance {
pub server_root: String,
/// `oxide` or `carbon`, as detected when this was recorded.
pub framework: String,
pub plugin_path: String,
/// What was deployed, so `doctor` can tell an edited plugin file from the release's.
pub plugin_sha256: String,
/// The plugin's config — the website's file, recorded so `doctor` knows where to look.
pub plugin_config: String,
/// The installer wrote that config (it did not exist). Informational; it is kept either way.
#[serde(default)]
pub plugin_config_written: bool,
/// The loopback port the plugin dials — the plugin config's `Port`.
pub game_port: u16,
/// The port the website reaches this instance's sidecar on.
pub web_port: u16,
pub config_path: String,
pub db_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub service: Option<ServiceRecord>,
}
impl RustRecord {
/// Everything but the timestamp, so a second run with nothing to do writes nothing.
pub fn same_deployment_as(&self, other: &Self) -> bool {
let mut a = self.clone();
let mut b = other.clone();
a.updated.clear();
b.updated.clear();
a == b
}
pub fn load(path: &Path) -> Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
let body = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let record = serde_json::from_str(&body).with_context(|| {
format!(
"{} exists but is not a record this installer understands. \
Move it aside to start over, or install a newer installer.",
path.display()
)
})?;
Ok(Some(record))
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
let mut body =
serde_json::to_string_pretty(self).context("cannot serialize rust/install.json")?;
body.push('\n');
write_atomic(path, body.as_bytes())
}
/// Game ports held by instances other than `except`.
pub fn game_ports_except(&self, except: &str) -> Vec<u16> {
self.instances
.iter()
.filter(|(id, _)| id.as_str() != except)
.map(|(_, i)| i.game_port)
.collect()
}
/// Web ports held by instances other than `except`.
pub fn web_ports_except(&self, except: &str) -> Vec<u16> {
self.instances
.iter()
.filter(|(id, _)| id.as_str() != except)
.map(|(_, i)| i.web_port)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
pub fn sample() -> RustRecord {
let instance = |root: &str, game: u16, web: u16| Instance {
server_root: root.into(),
framework: "oxide".into(),
plugin_path: format!("{root}/oxide/plugins/RunicGateway.cs"),
plugin_sha256: "ab".repeat(32),
plugin_config: format!("{root}/oxide/config/RunicGateway.json"),
plugin_config_written: true,
game_port: game,
web_port: web,
config_path: "/etc/runicgateway/rust/x.toml".into(),
db_path: "/var/lib/runicgateway/rust/x/rust-link.db".into(),
service: None,
};
RustRecord {
schema: SCHEMA,
installer: InstallerInfo {
version: "0.3.0".into(),
},
updated: "2026-09-26T00:00:00Z".into(),
bundle: BundleRef {
tag: "2026.09.26".into(),
protocol: 12,
url: "https://example/v2/rust/current.json".into(),
},
sidecar: ComponentRecord {
repo: "RunicGateway/Rust-Link".into(),
tag: "v0.1.0".into(),
version: "0.1.0".into(),
commit: String::new(),
protocol: 12,
},
binary: BinaryRef {
path: "/usr/bin/runicgateway-rust-link".into(),
sha256: "cd".repeat(32),
},
plugin: ComponentRecord {
repo: "RunicGateway/Rust-Plugins".into(),
tag: "v0.1.0".into(),
version: "0.1.0".into(),
commit: "abc".into(),
protocol: 12,
},
service_user_created: true,
instances: BTreeMap::from([
("alpha".to_string(), instance("/srv/a", 7799, 8090)),
("beta".to_string(), instance("/srv/b", 7800, 8091)),
]),
extra: BTreeMap::new(),
}
}
#[test]
fn a_record_round_trips_and_holds_no_token() {
let dir = TempDir::new("rg-rust-record").unwrap();
let path = dir.path().join("rust").join("install.json");
let record = sample();
record.save(&path).unwrap();
assert_eq!(RustRecord::load(&path).unwrap().unwrap(), record);
let text = std::fs::read_to_string(&path).unwrap();
assert!(!text.contains("auth_token") && !text.contains("token\""));
}
#[test]
fn ports_held_by_other_instances_exclude_the_one_being_installed() {
let record = sample();
assert_eq!(record.game_ports_except("alpha"), [7800]);
assert_eq!(record.web_ports_except("beta"), [8090]);
}
}

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

@@ -0,0 +1,219 @@
//! A Rust server root: that it is one, and which modding framework it runs.
//!
//! **The framework is detected, never asked** (docs/modules/rust/PLAN.md §34.2.3). The plugin is one
//! file that runs unchanged on Oxide and on Carbon (R19); what differs is only the directory it goes
//! in, and the server itself already says which framework it has. Asking would be one more answer an
//! operator could get wrong.
//!
//! The marker files were read off the two rigs (2026-09-26), one per framework, before this code
//! relied on them:
//!
//! - **Oxide:** `RustDedicated_Data/Managed/Oxide.Rust.dll`.
//! - **Carbon:** `carbon/managed/Carbon.dll`.
//!
//! Both present is refused: Oxide and Carbon cannot run in one install (R21), so a tree carrying both
//! is one mid-migration, and guessing would put the plugin where one of them never looks.
//!
//! **Carbon's directories are its defaults.** The plan expected a moved plugins directory to be
//! readable from `carbon/config.json`; on 2.0.259, the build R19 was proven on, that file has no
//! folder keys at all — Carbon's directories move only by a launch argument, which an installer
//! cannot see. `carbon/plugins/` and `carbon/configs/` are what every stock install uses.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
/// Which framework loads the plugin.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Framework {
Oxide,
Carbon,
}
impl Framework {
pub fn as_str(self) -> &'static str {
match self {
Self::Oxide => "oxide",
Self::Carbon => "carbon",
}
}
/// The file whose presence says this framework is installed.
fn marker(self, root: &Path) -> PathBuf {
match self {
Self::Oxide => root
.join("RustDedicated_Data")
.join("Managed")
.join("Oxide.Rust.dll"),
Self::Carbon => root.join("carbon").join("managed").join("Carbon.dll"),
}
}
/// Where the framework loads plugins from.
pub fn plugins_dir(self, root: &Path) -> PathBuf {
match self {
Self::Oxide => root.join("oxide").join("plugins"),
Self::Carbon => root.join("carbon").join("plugins"),
}
}
/// Where the framework keeps each plugin's config. Oxide says `config`, Carbon `configs`.
pub fn config_dir(self, root: &Path) -> PathBuf {
match self {
Self::Oxide => root.join("oxide").join("config"),
Self::Carbon => root.join("carbon").join("configs"),
}
}
}
/// A validated Rust server root.
#[derive(Debug, Clone)]
pub struct RustServer {
pub path: PathBuf,
pub framework: Framework,
}
/// The file this installer owns in a server root: the plugin.
pub const PLUGIN_FILE: &str = "RunicGateway.cs";
/// The plugin's config, which is the website's once written (§34.2.3).
pub const PLUGIN_CONFIG: &str = "RunicGateway.json";
impl RustServer {
pub fn plugins_dir(&self) -> PathBuf {
self.framework.plugins_dir(&self.path)
}
pub fn plugin_path(&self) -> PathBuf {
self.plugins_dir().join(PLUGIN_FILE)
}
pub fn plugin_config_path(&self) -> PathBuf {
self.framework.config_dir(&self.path).join(PLUGIN_CONFIG)
}
}
/// Validates `path` as a Rust server root and detects its framework.
pub fn open(path: &Path) -> Result<RustServer> {
let path = std::fs::canonicalize(path)
.map(crate::servuo::strip_extended_prefix)
.with_context(|| format!("{} does not exist", path.display()))?;
if !path.is_dir() {
bail!("{} is not a directory", path.display());
}
if !path.join("RustDedicated").is_file() && !path.join("RustDedicated.exe").is_file() {
bail!(
"{} is not a Rust server root: it has no RustDedicated or RustDedicated.exe",
path.display()
);
}
let oxide = Framework::Oxide.marker(&path).is_file();
let carbon = Framework::Carbon.marker(&path).is_file();
let framework = match (oxide, carbon) {
(true, false) => Framework::Oxide,
(false, true) => Framework::Carbon,
(true, true) => bail!(
"{} has both Oxide ({}) and Carbon ({}). They cannot run in one install, so this tree \
is part-way through a migration. Remove the one you are leaving, then run again.",
path.display(),
Framework::Oxide.marker(&path).display(),
Framework::Carbon.marker(&path).display()
),
(false, false) => bail!(
"{} has neither Oxide nor Carbon installed, and the bridge is a plugin for one of \
them. Install one (looked for {} and {}), start the server once so it lays out its \
directories, and run this again.",
path.display(),
Framework::Oxide.marker(&path).display(),
Framework::Carbon.marker(&path).display()
),
};
Ok(RustServer { path, framework })
}
/// Whether a RustDedicated is running out of this root.
///
/// Informational, unlike ServUO's refusal: both frameworks load and unload a plugin file while the
/// server runs, so an install under a live server is simply one that takes effect at once (§34.2.3).
/// `doctor` also uses it, to tell a server that is down from one whose plugin is silent.
pub fn is_running(root: &Path) -> bool {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let root = normalize(&root.to_string_lossy());
system.processes().values().any(|process| {
let exe = process
.exe()
.map(|p| normalize(&p.to_string_lossy()))
.unwrap_or_default();
exe.starts_with(&root) && exe.contains("rustdedicated")
})
}
fn normalize(s: &str) -> String {
s.to_lowercase().replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
fn server(dir: &Path, frameworks: &[Framework]) {
std::fs::write(dir.join("RustDedicated"), b"").unwrap();
for f in frameworks {
let marker = f.marker(dir);
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
std::fs::write(marker, b"").unwrap();
}
}
#[test]
fn each_framework_is_detected_and_places_the_plugin_its_own_way() {
for (f, plugins, config) in [
(Framework::Oxide, "oxide/plugins", "oxide/config"),
(Framework::Carbon, "carbon/plugins", "carbon/configs"),
] {
let dir = TempDir::new("rg-rust-root").unwrap();
server(dir.path(), &[f]);
let root = open(dir.path()).unwrap();
assert_eq!(root.framework, f);
let rel = |p: PathBuf| {
p.strip_prefix(&root.path)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
};
assert_eq!(rel(root.plugins_dir()), plugins);
assert_eq!(
rel(root.plugin_config_path()),
format!("{config}/RunicGateway.json")
);
}
}
#[test]
fn both_frameworks_are_refused_and_so_is_neither() {
let dir = TempDir::new("rg-rust-both").unwrap();
server(dir.path(), &[Framework::Oxide, Framework::Carbon]);
let err = open(dir.path()).unwrap_err().to_string();
assert!(
err.contains("both Oxide") && err.contains("Carbon"),
"{err}"
);
let dir = TempDir::new("rg-rust-none").unwrap();
server(dir.path(), &[]);
let err = open(dir.path()).unwrap_err().to_string();
assert!(err.contains("neither Oxide nor Carbon"), "{err}");
}
#[test]
fn a_directory_without_the_game_is_not_a_server_root() {
let dir = TempDir::new("rg-rust-empty").unwrap();
let err = open(dir.path()).unwrap_err().to_string();
assert!(err.contains("RustDedicated"), "{err}");
}
}

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

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

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

@@ -0,0 +1,199 @@
//! `uninstall --game rust [--server-id <id>] [--purge]`.
//!
//! What it removes, per instance (docs/modules/rust/PLAN.md §34.2.3, §34.4):
//!
//! - **the service**, and **our plugin file** — `RunicGateway.cs` is the installer's;
//! - **never the plugin's config** — `RunicGateway.json` is the website's, and it holds the
//! server's id; a reinstall must find it;
//! - with `--purge`, the instance's sidecar config (its token) and database.
//!
//! With the last instance gone: the shared binary, the template unit, the record, and — only if
//! this installer created it and no ServUO deployment uses it — the service account.
use std::path::Path;
use anyhow::Result;
use super::record::RustRecord;
use crate::cli::Cli;
use crate::{paths, service, ui};
pub fn run(cli: &Cli) -> Result<i32> {
let layout = paths::layout();
let record_path = layout.rust_record();
println!(
"\nRunic Gateway installer {} — uninstall (Rust)",
env!("CARGO_PKG_VERSION")
);
let Some(mut record) = RustRecord::load(&record_path)? else {
println!();
ui::warn(&format!(
"Nothing to uninstall — no Rust deployment is recorded on this host (looked for {}).",
record_path.display()
));
return Ok(0);
};
let ids: Vec<String> = match &cli.server_id {
Some(id) if record.instances.contains_key(id) => vec![id.clone()],
Some(id) => anyhow::bail!(
"{id:?} is not a recorded Rust instance (recorded: {})",
record
.instances
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
),
None => record.instances.keys().cloned().collect(),
};
let last = ids.len() == record.instances.len();
// ── Say what will happen, then ask ───────────────────────────────────────
println!();
for id in &ids {
let i = &record.instances[id];
ui::heading(&format!("Instance {id}"));
if let Some(svc) = &i.service {
println!(" · remove the service {}", svc.name);
}
println!(" · remove {}", i.plugin_path);
println!(
" · keep {} (the website's; it names this server)",
i.plugin_config
);
if cli.purge {
println!(
" · remove {} and the database {}",
i.config_path, i.db_path
);
} else {
println!(
" · keep {} and {} (--purge removes them)",
i.config_path, i.db_path
);
}
}
if last {
println!(
"\n and, with no instance left: {} and {}",
record.binary.path,
record_path.display()
);
}
println!();
let proceed = if cli.assume_yes {
println!("Remove the components listed above? [y/N] (--yes)");
true
} else {
ui::confirm("Remove the components listed above?", false, false)?
};
if !proceed {
println!("\nNothing was removed.");
return Ok(0);
}
// ── Remove ───────────────────────────────────────────────────────────────
let mut done = Vec::new();
let mut problems = Vec::new();
for id in &ids {
let Some(instance) = record.instances.remove(id) else {
continue;
};
if let Some(svc) = &instance.service {
let removal = service::remove(svc);
done.extend(removal.done);
problems.extend(removal.problems);
}
remove_file(Path::new(&instance.plugin_path), &mut done, &mut problems);
if cli.purge {
remove_file(Path::new(&instance.config_path), &mut done, &mut problems);
let data_dir = layout.rust_data_dir(id);
if data_dir.exists() {
match std::fs::remove_dir_all(&data_dir) {
Ok(()) => done.push(format!("removed {}", data_dir.display())),
Err(e) => problems.push(format!("cannot remove {}: {e}", data_dir.display())),
}
}
}
}
if record.instances.is_empty() {
remove_file(Path::new(&record.binary.path), &mut done, &mut problems);
done.extend(service::remove_rust_template(&layout));
remove_shared_user(&record, &layout, &mut done, &mut problems);
remove_file(&record_path, &mut done, &mut problems);
} else {
match record.save(&record_path) {
Ok(()) => done.push(format!(
"{} keeps {} instance(s): {}",
record_path.display(),
record.instances.len(),
record
.instances
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
)),
Err(e) => problems.push(format!("cannot update {}: {e}", record_path.display())),
}
}
for line in &done {
println!(" · {line}");
}
for problem in &problems {
println!();
ui::warn(problem);
}
println!(
"\nRemove each server from Admin -> Rust -> Servers on the website too; the installer never \
contacts it."
);
Ok(if problems.is_empty() { 0 } else { 1 })
}
/// The `runicgateway` account is shared: every Rust instance runs as it, and so does ServUO's
/// sidecar. It goes only when this installer created it for Rust and no ServUO deployment remains.
fn remove_shared_user(
record: &RustRecord,
layout: &paths::Layout,
done: &mut Vec<String>,
problems: &mut Vec<String>,
) {
if !record.service_user_created || cfg!(windows) {
return;
}
if layout.install_record().exists() {
done.push(format!(
"left the {} account — the ServUO deployment on this host runs as it",
service::SERVICE_USER
));
return;
}
let removed = crate::util::run_ok("userdel", &[service::SERVICE_USER])
.or_else(|_| crate::util::run_ok("deluser", &[service::SERVICE_USER]));
match removed {
Ok(_) => done.push(format!(
"removed the {} service user",
service::SERVICE_USER
)),
Err(e) => problems.push(format!(
"cannot remove the {} service user ({}); remove it by hand if you want it gone",
service::SERVICE_USER,
e.to_string().replace('\n', " ")
)),
}
}
fn remove_file(path: &Path, done: &mut Vec<String>, problems: &mut Vec<String>) {
match std::fs::remove_file(path) {
Ok(()) => done.push(format!("removed {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
done.push(format!("{} was already gone", path.display()))
}
Err(e) => problems.push(format!("cannot remove {}: {e}", path.display())),
}
}