All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m39s
The end-of-run block told operators to paste the four values at `<site>/admin/shard`. That page moved when the shard screens became part of the `uo` module: a module owns one path segment wherever it appears (website `MODULE_SYSTEM.md` §2.8), so it is `/admin/uo/link`, labelled "Shard (uo-link)". The old path is worse than a 404. The SPA has no route for it, so it sends the operator to the dashboard — the link looks like it worked, and the values they were told to paste have nowhere to go. - The path is now a named constant, `ADMIN_SHARD_PATH`, carrying why it is not the obvious string and the fact that API routes are NOT affected by the module namespacing rule (they keep `/api/v1/admin/shard/*`). - Both handoff tests assert the new path, so this cannot regress quietly. - The two user-facing labels that name the screen — the `--site-url` help text and `update`'s protocol-change instruction — say "Admin → Shard (uo-link)", matching what the sidebar actually reads. Found while writing the runicgateway.com installation journey, by pasting the printed link into a real deployment and landing on the dashboard. Co-Authored-By: Claude <noreply@anthropic.com>
482 lines
19 KiB
Rust
482 lines
19 KiB
Rust
//! The uo-link sidecar: install the binary, provision its config, read the token back.
|
|
//!
|
|
//! This is the half of the deployment that makes the website work at all. The overlay puts code in
|
|
//! the ServUO tree; nothing reaches a website until a sidecar is listening on `127.0.0.1:7788` for
|
|
//! the shard to dial out to, and until the website has been given its address, protocol version and
|
|
//! token (PLAN.md §2.4 calls that missing handoff the largest "I installed it and nothing happened"
|
|
//! failure mode).
|
|
//!
|
|
//! Three rules govern this module:
|
|
//!
|
|
//! - **Every value in the handoff comes from asking the installed binary**, via
|
|
//! `--print-config --config <the pinned path>`. Not from the log, not from re-reading the TOML,
|
|
//! and not from the installer's own idea of what it wrote. That single call also *provisions* —
|
|
//! it writes the config file if absent and generates the token if blank — which is why PLAN.md
|
|
//! §5 requires it to run **before** the service is registered: the service must never start
|
|
//! against a config that does not exist yet.
|
|
//! - **The token is printed and never stored.** It goes to the operator's terminal and into
|
|
//! `sidecar.toml`, and nowhere else — not into `install.json`, not into an error message, not
|
|
//! into the output of a failed command (PLAN.md §6). That is why `--print-config` is run through
|
|
//! [`crate::util::run`] and handled here rather than through `run_ok`, which quotes what a
|
|
//! command printed.
|
|
//! - **A binary in place is not a working sidecar.** Nothing here claims more than "the file is
|
|
//! installed and it answered `--print-config`"; whether the shard ever dials in is `doctor`'s
|
|
//! question (Phase 4).
|
|
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
use serde::Deserialize;
|
|
|
|
use crate::bundle::Asset;
|
|
use crate::util::{run, sha256_file};
|
|
|
|
/// The document `uo-link-sidecar --print-config` prints (`link/sidecar/src/config.rs::describe`).
|
|
///
|
|
/// Unknown fields are ignored on purpose: a newer sidecar that adds a key must not break an
|
|
/// installer that does not know about it, and every field read here has been in the document since
|
|
/// the CLI was introduced in link v1.1.0.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ConfigDoc {
|
|
pub component: String,
|
|
pub version: String,
|
|
pub protocol: u32,
|
|
pub config_path: String,
|
|
/// This run created the config file. False on every re-run — which is how the installer knows
|
|
/// not to report a token as newly minted when it is simply being read back.
|
|
pub config_created: bool,
|
|
pub token_generated: bool,
|
|
pub shard: ShardDoc,
|
|
pub web: WebDoc,
|
|
pub store: StoreDoc,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ShardDoc {
|
|
pub bind: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct WebDoc {
|
|
pub bind: String,
|
|
pub ws_path: String,
|
|
pub auth_required: bool,
|
|
/// **A secret.** Printed in the handoff block and never recorded anywhere else.
|
|
pub auth_token: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct StoreDoc {
|
|
/// Absolute, already resolved by the sidecar against its config file's directory.
|
|
pub path: String,
|
|
}
|
|
|
|
/// What installing the binary would do, decided by hash before anything is downloaded.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum BinaryAction {
|
|
/// Nothing is installed at the target path yet.
|
|
Install,
|
|
/// Something is, and it is not what the bundle names.
|
|
Replace,
|
|
/// The bundle's binary is already in place, byte for byte.
|
|
Unchanged,
|
|
}
|
|
|
|
impl BinaryAction {
|
|
pub fn writes(self) -> bool {
|
|
!matches!(self, Self::Unchanged)
|
|
}
|
|
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Install => "install",
|
|
Self::Replace => "replace",
|
|
Self::Unchanged => "unchanged",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Compares what is installed against what the bundle names.
|
|
///
|
|
/// Hash rather than version string: the version an installed binary reports costs a process launch
|
|
/// to obtain and would still not distinguish two builds of the same version. The bundle records the
|
|
/// SHA256 CI computed from the asset it verified (PLAN.md §7.1 gate 2), so this comparison is
|
|
/// against the same value the download will be checked against.
|
|
pub fn decide(asset: &Asset, dest: &Path) -> Result<BinaryAction> {
|
|
if !dest.exists() {
|
|
return Ok(BinaryAction::Install);
|
|
}
|
|
let installed = sha256_file(dest)?;
|
|
if installed.eq_ignore_ascii_case(asset.sha256.trim()) {
|
|
Ok(BinaryAction::Unchanged)
|
|
} else {
|
|
Ok(BinaryAction::Replace)
|
|
}
|
|
}
|
|
|
|
/// Downloads the sidecar binary and puts it at `dest`, executable.
|
|
///
|
|
/// The download lands in the scratch directory and is checksum-verified there, then copied to a
|
|
/// `.new` sibling of the target and renamed over it. The two-step matters on both platforms for
|
|
/// different reasons: on Windows the target is locked while the service runs (the caller stops it
|
|
/// first), and on either, a copy interrupted halfway would otherwise leave a truncated binary at
|
|
/// exactly the path a service is about to execute.
|
|
pub fn place(asset: &Asset, dest: &Path, scratch: &Path) -> Result<String> {
|
|
let staged = scratch.join(&asset.name);
|
|
crate::net::download_verified(&asset.url, &staged, &asset.sha256)?;
|
|
|
|
let parent = dest
|
|
.parent()
|
|
.ok_or_else(|| anyhow::anyhow!("{} has no parent directory", dest.display()))?;
|
|
fs::create_dir_all(parent).with_context(|| {
|
|
format!(
|
|
"cannot create {} — run as root/Administrator",
|
|
parent.display()
|
|
)
|
|
})?;
|
|
|
|
let pending = pending_path(dest);
|
|
fs::copy(&staged, &pending).with_context(|| format!("cannot write {}", pending.display()))?;
|
|
set_executable(&pending)?;
|
|
|
|
// Windows will not rename onto an existing file. The old binary goes first; the replacement is
|
|
// already complete on disk by this point, so the window is a rename wide.
|
|
if dest.exists() {
|
|
fs::remove_file(dest).with_context(|| {
|
|
format!(
|
|
"cannot replace {} — if a service is running it, stop it first",
|
|
dest.display()
|
|
)
|
|
})?;
|
|
}
|
|
fs::rename(&pending, dest)
|
|
.with_context(|| format!("cannot move {} into place", pending.display()))?;
|
|
|
|
sha256_file(dest)
|
|
}
|
|
|
|
/// `uo-link-sidecar.exe` → `uo-link-sidecar.new`, in the same directory as the target.
|
|
///
|
|
/// Same directory so the final step is a rename rather than a cross-filesystem copy: `/tmp` and
|
|
/// `/usr/bin` are routinely different mounts, and a rename between them fails.
|
|
fn pending_path(dest: &Path) -> PathBuf {
|
|
let mut name = dest.file_name().unwrap_or_default().to_os_string();
|
|
name.push(".new");
|
|
dest.with_file_name(name)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn set_executable(path: &Path) -> Result<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
|
|
.with_context(|| format!("cannot make {} executable", path.display()))
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn set_executable(_path: &Path) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Runs the installed binary's `--print-config`, provisioning the config and returning the token.
|
|
///
|
|
/// `db_path` is passed as `UOLINK_DB_PATH` **only when it is not already where the sidecar would
|
|
/// put it** — that is, on Linux, where the config lives in `/etc` and the database in `/var/lib`.
|
|
/// On Windows both are `%ProgramData%\RunicGateway`, the sidecar anchors a relative `[store].path`
|
|
/// to its config's directory, and passing the variable would buy nothing while implying the service
|
|
/// needs a machine-wide environment variable it does not (see [`crate::paths`]).
|
|
///
|
|
/// **This function must not print, log or attach the child's stdout to an error.** It is the one
|
|
/// place in the installer where a secret crosses a process boundary.
|
|
pub fn print_config(binary: &Path, config: &Path, db_path: Option<&Path>) -> Result<ConfigDoc> {
|
|
let mut command = std::process::Command::new(binary);
|
|
command.arg("--print-config").arg("--config").arg(config);
|
|
if let Some(db) = db_path {
|
|
command.env("UOLINK_DB_PATH", db);
|
|
}
|
|
|
|
let output = command.output().with_context(|| {
|
|
format!(
|
|
"cannot run {} --print-config. The binary was just installed, so this usually means it \
|
|
cannot execute here — a 32/64-bit or libc mismatch, or a filesystem mounted noexec.",
|
|
binary.display()
|
|
)
|
|
})?;
|
|
|
|
if !output.status.success() {
|
|
// stderr only. stdout is the document, and the document contains the auth token.
|
|
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 with {}: {reason}",
|
|
binary.display(),
|
|
config.display(),
|
|
match output.status.code() {
|
|
Some(code) => format!("exit code {code}"),
|
|
None => "no exit code".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
let doc: ConfigDoc = serde_json::from_slice(&output.stdout).context(
|
|
"the sidecar's --print-config output is not the document this installer expects. \
|
|
Its contents are not shown here because they would contain the auth token; run the same \
|
|
command by hand to see it (INSTALL.md Appendix A3).",
|
|
)?;
|
|
|
|
if doc.component != "uo-link-sidecar" {
|
|
bail!(
|
|
"the binary at {} identifies itself as {:?}, not uo-link-sidecar",
|
|
binary.display(),
|
|
doc.component
|
|
);
|
|
}
|
|
if doc.web.auth_token.trim().is_empty() {
|
|
// Authentication is always on in the sidecar, so this cannot happen against a real one —
|
|
// and if it ever did, an unauthenticated web surface must not be reported as a success.
|
|
bail!(
|
|
"the sidecar reported an empty auth token from {}. Authentication is always on; refusing \
|
|
to continue with a config that would leave its web surface unauthenticated.",
|
|
doc.config_path
|
|
);
|
|
}
|
|
Ok(doc)
|
|
}
|
|
|
|
/// Asks an installed binary for its version line, `uo-link-sidecar <ver> (protocol <n>)`.
|
|
///
|
|
/// Used to report what is already installed on a run that installs nothing. It is deliberately
|
|
/// tolerant — a binary that cannot answer is described as unknown rather than failing a run whose
|
|
/// real work has already succeeded.
|
|
pub fn version_line(binary: &Path) -> Option<String> {
|
|
let output = run(&binary.to_string_lossy(), &["--version"]).ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
String::from_utf8_lossy(&output.stdout)
|
|
.lines()
|
|
.find(|l| !l.trim().is_empty())
|
|
.map(|l| l.trim().to_string())
|
|
}
|
|
|
|
/// The two URLs the website needs, composed from the sidecar's own answers plus a host.
|
|
///
|
|
/// The bind address is **not** echoed: `[web] bind` is `127.0.0.1` by default and frequently
|
|
/// `0.0.0.0`, and neither is something to hand to a website (PLAN.md §6). Only the port is taken
|
|
/// from it; the host is the one the operator named or the installer detected.
|
|
pub fn website_urls(doc: &ConfigDoc, host: &str) -> (String, String) {
|
|
let port = port_of(&doc.web.bind);
|
|
let ws_path = if doc.web.ws_path.starts_with('/') {
|
|
doc.web.ws_path.clone()
|
|
} else {
|
|
format!("/{}", doc.web.ws_path)
|
|
};
|
|
(
|
|
format!("http://{host}:{port}"),
|
|
format!("ws://{host}:{port}{ws_path}"),
|
|
)
|
|
}
|
|
|
|
/// The port half of a bind address.
|
|
///
|
|
/// Split on the **last** colon so an IPv6 bind (`[::]:8080`) yields `8080` rather than a fragment
|
|
/// of the address. A bind with no port at all is not something the sidecar produces, so the whole
|
|
/// string is handed back rather than guessing a default that would then be wrong everywhere it was
|
|
/// printed.
|
|
fn port_of(bind: &str) -> &str {
|
|
match bind.rsplit_once(':') {
|
|
Some((_, port)) if !port.is_empty() => port,
|
|
_ => bind,
|
|
}
|
|
}
|
|
|
|
/// Where the shard settings live in the website's admin panel.
|
|
///
|
|
/// NOT `/admin/shard`, which is what this printed until 2026-08-24 and what an operator who ran
|
|
/// an older build still has in their scrollback. Those screens belong to the `uo` MODULE now, and
|
|
/// a module owns one path segment wherever it appears (website `MODULE_SYSTEM.md` §2.8), so the
|
|
/// page moved. The old path does not 404 — the SPA sends it to the dashboard, which is the worst
|
|
/// way for a link in a handoff to be wrong, because it looks like it worked.
|
|
///
|
|
/// API routes are NOT affected by that rule and keep `/api/v1/admin/shard/*`. This is the SPA URL
|
|
/// a person types.
|
|
const ADMIN_SHARD_PATH: &str = "/admin/uo/link";
|
|
|
|
/// The end-of-run block from PLAN.md §6 — the one manual step the installer cannot do.
|
|
///
|
|
/// Returned as a string rather than printed so it can be tested, and so the caller decides where it
|
|
/// goes. It goes to stdout. It never goes to a file.
|
|
pub fn handoff(doc: &ConfigDoc, host: &str, site_url: Option<&str>) -> String {
|
|
let (base_url, ws_url) = website_urls(doc, host);
|
|
let site = site_url
|
|
.map(|s| s.trim_end_matches('/').to_string())
|
|
.unwrap_or_else(|| "https://<your-site>".to_string());
|
|
|
|
format!(
|
|
"\nRunic Gateway is installed.\n\n\
|
|
One manual step remains — connect the website to this sidecar:\n\n \
|
|
Base URL {base_url}\n \
|
|
WebSocket URL {ws_url}\n \
|
|
Protocol version {protocol}\n \
|
|
Auth token {token}\n \
|
|
(also in {config})\n\n\
|
|
Paste these into Admin → Shard (uo-link) on your Runic Gateway site:\n \
|
|
{site}{admin_path}\n\n\
|
|
The token is write-only once saved — the site will never show it back to you.\n",
|
|
protocol = doc.protocol,
|
|
token = doc.web.auth_token,
|
|
config = doc.config_path,
|
|
admin_path = ADMIN_SHARD_PATH,
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::util::TempDir;
|
|
|
|
/// The document link v1.1.0 actually prints, copied from INSTALL.md Appendix A3.
|
|
const PRINT_CONFIG: &str = r#"{
|
|
"component": "uo-link-sidecar",
|
|
"version": "1.1.0",
|
|
"protocol": 3,
|
|
"config_path": "/etc/runicgateway/sidecar.toml",
|
|
"config_created": true,
|
|
"token_generated": true,
|
|
"shard": { "bind": "127.0.0.1:7788" },
|
|
"web": {
|
|
"bind": "127.0.0.1:8080",
|
|
"ws_path": "/ws",
|
|
"auth_required": true,
|
|
"auth_token": "4f9c00112233445566778899aabbccdd"
|
|
},
|
|
"store": { "path": "/var/lib/runicgateway/uo-link.db" }
|
|
}"#;
|
|
|
|
fn doc() -> ConfigDoc {
|
|
serde_json::from_str(PRINT_CONFIG).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn the_documented_print_config_output_parses() {
|
|
let doc = doc();
|
|
assert_eq!(doc.version, "1.1.0");
|
|
assert_eq!(doc.protocol, 3);
|
|
assert!(doc.web.auth_required);
|
|
assert_eq!(doc.store.path, "/var/lib/runicgateway/uo-link.db");
|
|
}
|
|
|
|
#[test]
|
|
fn a_newer_sidecar_adding_fields_still_parses() {
|
|
// The sidecar and the installer version independently; a key added to the document must not
|
|
// strand an installed installer.
|
|
let body = PRINT_CONFIG.replace(
|
|
"\"protocol\": 3,",
|
|
"\"protocol\": 3, \"something_new\": { \"nested\": true },",
|
|
);
|
|
assert!(serde_json::from_str::<ConfigDoc>(&body).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn the_website_urls_use_the_host_not_the_bind_address() {
|
|
// The whole point of asking for a host: 127.0.0.1 and 0.0.0.0 are both useless to a website.
|
|
let mut d = doc();
|
|
let (base, ws) = website_urls(&d, "shard.example.com");
|
|
assert_eq!(base, "http://shard.example.com:8080");
|
|
assert_eq!(ws, "ws://shard.example.com:8080/ws");
|
|
|
|
d.web.bind = "0.0.0.0:9001".into();
|
|
let (base, ws) = website_urls(&d, "shard.example.com");
|
|
assert_eq!(base, "http://shard.example.com:9001");
|
|
assert_eq!(ws, "ws://shard.example.com:9001/ws");
|
|
}
|
|
|
|
#[test]
|
|
fn an_ipv6_bind_yields_its_port() {
|
|
// Splitting on the first colon would produce "http://host::" from "[::]:8080".
|
|
assert_eq!(port_of("[::]:8080"), "8080");
|
|
assert_eq!(port_of("[::1]:7788"), "7788");
|
|
assert_eq!(port_of("127.0.0.1:8080"), "8080");
|
|
}
|
|
|
|
#[test]
|
|
fn the_handoff_carries_every_value_the_admin_form_asks_for() {
|
|
// INSTALL.md §5 maps four fields; all four must be in the block, plus where to paste them.
|
|
let doc = doc();
|
|
let block = handoff(&doc, "shard.example.com", Some("https://my-site.example/"));
|
|
assert!(block.contains("http://shard.example.com:8080"), "{block}");
|
|
assert!(block.contains("ws://shard.example.com:8080/ws"), "{block}");
|
|
assert!(block.contains("Protocol version 3"), "{block}");
|
|
assert!(block.contains(&doc.web.auth_token), "{block}");
|
|
// The trailing slash on the site URL must not produce a double slash in the link.
|
|
assert!(
|
|
block.contains("https://my-site.example/admin/uo/link"),
|
|
"{block}"
|
|
);
|
|
assert!(block.contains("/etc/runicgateway/sidecar.toml"), "{block}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_handoff_still_works_without_a_site_url() {
|
|
// An unattended run has nobody to ask, and the token is far too useful to withhold over a
|
|
// link the operator does not need.
|
|
let block = handoff(&doc(), "shard", None);
|
|
assert!(
|
|
block.contains("https://<your-site>/admin/uo/link"),
|
|
"{block}"
|
|
);
|
|
assert!(block.contains("4f9c"), "{block}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_installed_binary_matching_the_bundle_is_left_alone() {
|
|
let dir = TempDir::new("rg-test-sidecar").unwrap();
|
|
let dest = dir.path().join("uo-link-sidecar");
|
|
assert_eq!(
|
|
decide(&asset("0".repeat(64)), &dest).unwrap(),
|
|
BinaryAction::Install
|
|
);
|
|
|
|
fs::write(&dest, b"pretend binary").unwrap();
|
|
let installed = sha256_file(&dest).unwrap();
|
|
assert_eq!(
|
|
decide(&asset(installed.clone()), &dest).unwrap(),
|
|
BinaryAction::Unchanged
|
|
);
|
|
// Hex case must not decide whether a host reinstalls its sidecar on every run.
|
|
assert_eq!(
|
|
decide(&asset(installed.to_uppercase()), &dest).unwrap(),
|
|
BinaryAction::Unchanged
|
|
);
|
|
assert_eq!(
|
|
decide(&asset("a".repeat(64)), &dest).unwrap(),
|
|
BinaryAction::Replace
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_staging_file_sits_beside_its_target() {
|
|
// /tmp and /usr/bin are routinely different filesystems, and rename across them fails.
|
|
let dest = Path::new("/usr/bin/runicgateway-link");
|
|
assert_eq!(pending_path(dest).parent(), dest.parent());
|
|
assert_eq!(
|
|
pending_path(Path::new("/usr/bin/x.exe"))
|
|
.file_name()
|
|
.unwrap(),
|
|
"x.exe.new"
|
|
);
|
|
}
|
|
|
|
fn asset(sha256: String) -> Asset {
|
|
Asset {
|
|
name: "uo-link-sidecar-linux-x86_64".into(),
|
|
url: "https://example/uo-link-sidecar".into(),
|
|
sha256,
|
|
}
|
|
}
|
|
}
|