Files
installer/src/sidecar.rs
wtclaude 6da385425e
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m39s
fix(handoff): print the shard screen's real path
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>
2026-08-24 11:22:45 -05:00

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