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
239 lines
8.7 KiB
Rust
239 lines
8.7 KiB
Rust
//! 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"]);
|
|
}
|
|
}
|