Files
installer/src/sidecar.rs
wtclaude 2228e0848b
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 1m15s
feat(installer): implement Phase 2 — uo-link install and service
Adds the sidecar half of a deployment to the same `install` run: download and
verify the bundle's binary, provision its config, register and start a service,
and print the token handoff PLAN.md §6 specifies. `src/sidecar.rs` owns the
binary and the config document; `src/service.rs` owns systemd and the Windows
SCM.

The order is fixed by PLAN.md §5 and matters: stop anything running the old
binary, replace it, then `--print-config` (which writes the config the service
will be pointed at), then register. Registering first points a service at a file
that does not exist yet.

Decisions worth a reviewer's attention:

- Both platforms run the sidecar as a dedicated unprivileged identity. Linux gets
  the `runicgateway` system user the plan already specified; Windows gets a
  virtual service account, `sc create ... obj= "NT SERVICE\RunicGatewayLink"`,
  which the SCM creates itself and which has no password. Plain `sc create` runs
  as LocalSystem — the most privileged local identity there is, for a process
  listening on two TCP ports while its Linux twin deliberately does not run as
  root.
- `sidecar.toml` holds the auth token and neither default location protects it:
  /etc is world-readable and %ProgramData% grants Users read by inheritance, so a
  stock install would leave the shard's token readable by any local account. The
  lockdown straddles registration because it has to — on Windows the service
  account does not exist until `sc create` creates it, so the file is first cut
  down to SYSTEM + Administrators, and the account's read grant comes after.
- Only Linux pins UOLINK_DB_PATH. On Windows config and data share a directory
  and the sidecar anchors a relative [store] path to its config's directory, so
  the pin is redundant — and `sc.exe` has no per-service environment, only a
  machine-wide one that every process inherits and that outlives an uninstall.
  The config path rides in the service's own binPath instead.
- `--verify` runs no part of the sidecar half. `--print-config` provisions: it
  writes the config and mints a token, so a dry run that called it would create
  the state it claims not to. It also carries an existing `link` section of
  install.json through untouched, so a dry run cannot make a service disappear
  from the record.
- The installed binary's protocol version is checked against the bundle before
  the service is registered. Gate 1 read that number from source at the release
  tag; this is the same check applied to the binary that will actually answer the
  website.
- RUNICGATEWAY_STATE_DIR now relocates the sidecar binary as well, and suppresses
  service registration and the file-permission hardening. There is no such thing
  as a relocated systemd unit, and hardening a scratch config against the only
  account that will ever read it just breaks the next test run.
- A host with no systemd, or where the service user cannot be created, still gets
  a working binary and config plus the exact unit and commands. There is no
  fallback to User=root or LocalSystem: a service quietly running with more
  privilege than its documentation promises is worse than one that was not
  registered.
- install.json never records the token. The `link` section carries versions, the
  binary's hash, the config and database paths, and the service's name, unit path
  and account.

Docs half: docs#91.

Tested: cargo fmt --check, clippy --all-targets -D warnings, 72 tests. End to end
on Windows against a relocated layout — bundle sidecar downloaded and verified,
config provisioned, handoff printed with URLs composed from the host rather than
the bind address, second run reporting unchanged with install.json byte-identical,
--verify over an installed host writing nothing and preserving the link section,
and a tampered binary detected by hash and replaced with no staging file left.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 15:40:56 -05:00

466 lines
18 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,
}
}
/// 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 on your Runic Gateway site:\n \
{site}/admin/shard\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,
)
}
#[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/shard"),
"{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/shard"), "{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,
}
}
}