feat(installer): implement Phase 2 — uo-link install and service
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 1m15s

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>
This commit is contained in:
2026-08-04 15:40:56 -05:00
parent a4f6b7b756
commit 2228e0848b
7 changed files with 1919 additions and 57 deletions

View File

@@ -1,10 +1,24 @@
//! The `install` command.
//!
//! Phase 1 of `docs/installer/PLAN.md` — the installer core: resolve the bundle, validate the
//! ServUO root, sync the overlay, record what was deployed. The sidecar and its service (Phase 2)
//! and the patch tier (Phase 3) are not in this build, and the run says so in as many words rather
//! than ending on a success line that would read as a finished install. An operator who cannot tell
//! which half ran is the failure this whole tool exists to remove.
//! Phases 1 and 2 of `docs/installer/PLAN.md`: resolve the bundle, validate the ServUO root, sync
//! the overlay, install the sidecar and register its service, record what was deployed, and print
//! the values the website needs. The patch tier (Phase 3) is not in this build, and the run says so
//! in as many words rather than ending on a success line that would read as a finished install. An
//! operator who cannot tell which half ran is the failure this whole tool exists to remove.
//!
//! The order of the run is not incidental:
//!
//! 1. **Resolve everything that can fail cheaply first** — the bundle, the sidecar asset for this
//! platform, the ServUO root, and whether this process can write where it must. A run that
//! cannot finish should end before a single file enters the ServUO tree.
//! 2. **Overlay, then sidecar.** The sidecar is what the shard dials out to, but the shard is
//! stopped throughout; deploying code the shard will compile is the step with a running-process
//! hazard attached, so it happens while the check that guards it is freshest.
//! 3. **Provision the config before registering the service** (PLAN.md §5): `--print-config` writes
//! the file the service definition points at, so the service is never started against a config
//! that does not exist yet.
//! 4. **Record last, print the handoff after that.** The token block is the final thing on screen
//! because it is the only thing the operator still has to act on.
use std::path::{Path, PathBuf};
@@ -12,11 +26,12 @@ use anyhow::{bail, Context, Result};
use crate::cli::{Cli, PatchChoice};
use crate::record::{
now_rfc3339, BundleRef, InstallRecord, InstallerInfo, OverlayRecord, ServUoRef, SCHEMA,
now_rfc3339, BinaryRef, BundleRef, InstallRecord, InstallerInfo, LinkRecord, OverlayRecord,
ServUoRef, ServiceRecord, SCHEMA,
};
use crate::servuo::ServUoRoot;
use crate::util::TempDir;
use crate::{bundle, net, overlay, paths, servuo, ui};
use crate::{bundle, net, overlay, paths, service, servuo, sidecar, ui};
pub fn run(cli: &Cli) -> Result<()> {
let layout = paths::layout();
@@ -60,7 +75,7 @@ pub fn run(cli: &Cli) -> Result<()> {
ui::row(
"Sidecar",
&format!(
"{:<24} protocol {} (Phase 2 — not installed by this build)",
"{:<24} protocol {}",
format!("uo-link {}", bundle.link.tag),
bundle.link.protocol
),
@@ -77,6 +92,13 @@ pub fn run(cli: &Cli) -> Result<()> {
));
}
// Everything below writes into system directories. Finding out here beats finding out after
// the ServUO tree has been half-deployed into — the ServUO half is the one an operator cannot
// simply re-run their way out of.
if !cli.verify {
preflight_writable(&layout)?;
}
// ── Fetch and unpack the overlay ─────────────────────────────────────────
let scratch = TempDir::new("runicgateway-installer")?;
let tarball = scratch.path().join(&bundle.overlay.asset.name);
@@ -172,7 +194,9 @@ pub fn run(cli: &Cli) -> Result<()> {
// ── What this build does not do ──────────────────────────────────────────
report_patch_tier(cli);
report_sidecar(&bundle, &sidecar_asset, &layout);
// ── The sidecar and its service ──────────────────────────────────────────
let sidecar = install_sidecar(cli, &bundle, &sidecar_asset, &layout, scratch.path())?;
// ── Record ───────────────────────────────────────────────────────────────
let record = build_record(
@@ -182,6 +206,7 @@ pub fn run(cli: &Cli) -> Result<()> {
&root,
&manifest,
&planned,
sidecar.as_ref(),
);
if cli.verify {
@@ -218,14 +243,24 @@ pub fn run(cli: &Cli) -> Result<()> {
} else if cli.verify {
println!("Nothing was written. Re-run without --verify to deploy.");
} else {
println!("Nothing to do — this tree already has this overlay.");
println!("The ServUO tree already has this overlay — nothing was changed there.");
}
if cli.host.is_some() || cli.site_url.is_some() {
// ── The one manual step ──────────────────────────────────────────────────
// Last, and after the record, because it is the only thing left for the operator to do. The
// token goes to the terminal and nowhere else (PLAN.md §6).
if let Some(sidecar) = &sidecar {
let host = resolve_host(cli);
println!(
"\nNote: --host/--site-url are used by the token handoff, which arrives with the \
sidecar in Phase 2. They had no effect on this run."
"{}",
sidecar::handoff(&sidecar.doc, &host, cli.site_url.as_deref())
);
if !sidecar.service.registered() {
ui::warn(
"No service was registered, so nothing is listening yet — the values above \
describe the sidecar\n once you start it. See the steps printed above.",
);
}
}
Ok(())
}
@@ -297,29 +332,240 @@ fn report_patch_tier(cli: &Cli) {
println!(" Without it: no vendor.sale events, no in-game moderation audit forwarding.");
}
fn report_sidecar(bundle: &bundle::Bundle, asset: &bundle::Asset, layout: &paths::Layout) {
println!();
ui::warn(&format!(
"uo-link NOT INSTALLED — the sidecar and its service arrive in Phase 2.\n \
Without it the shard has nothing to dial out to and your website stays offline.\n \
Install it by hand for now — INSTALL.md Appendix A3 and A4:\n \
binary {}\n \
config {}\n \
database {}\n \
download {}\n \
sha256 {}\n \
Then provision and read the token back with:\n \
<binary> --print-config --config {}\n \
The bundle pairs it with overlay {} at protocol {}; keep the two in step.",
layout.sidecar_bin.display(),
layout.sidecar_config().display(),
layout.sidecar_db().display(),
asset.url,
asset.sha256,
layout.sidecar_config().display(),
bundle.overlay.tag,
bundle.protocol,
));
/// The sidecar half of a run: binary, config, service. `None` under `--verify`.
struct SidecarOutcome {
record: LinkRecord,
doc: sidecar::ConfigDoc,
service: service::Outcome,
}
/// Installs the sidecar, provisions its config, and registers its service.
///
/// The step order is the one PLAN.md §5 fixes: stop anything running the old binary, replace it,
/// **then** `--print-config` (which writes the config the service will be pointed at), then
/// register. Doing the last two the other way round registers a service against a file that does
/// not exist yet, which fails in a way that looks like a broken sidecar rather than a sequencing
/// mistake.
fn install_sidecar(
cli: &Cli,
bundle: &bundle::Bundle,
asset: &bundle::Asset,
layout: &paths::Layout,
scratch: &Path,
) -> Result<Option<SidecarOutcome>> {
ui::heading("uo-link sidecar");
let action = sidecar::decide(asset, &layout.sidecar_bin)?;
ui::row(
"binary",
&format!("{} {}", layout.sidecar_bin.display(), action.label()),
);
if cli.verify {
ui::row(
"config",
&format!(
"{} (would be provisioned)",
layout.sidecar_config().display()
),
);
ui::row("database", &layout.sidecar_db().display().to_string());
println!(
"\n VERIFY only. Nothing installed, no service registered, no token read.\n \
The token handoff needs a provisioned config, so it is only printed by a real run."
);
return Ok(None);
}
// Detect the service manager and make sure the account exists *before* a config file is
// written that then has to be owned by it.
let prepared = service::prepare(layout.relocated);
let binary_sha256 = if action.writes() {
// The binary is locked on Windows while its service runs, and on Linux replacing it under a
// live process leaves the old code serving until something restarts it.
service::stop_for_replacement(&prepared.manager)?;
let sha = sidecar::place(asset, &layout.sidecar_bin, scratch)?;
ui::ok(&format!(
"sidecar binary verified sha256 {}",
&asset.sha256[..8.min(asset.sha256.len())]
));
sha
} else {
asset.sha256.trim().to_ascii_lowercase()
};
std::fs::create_dir_all(&layout.data_dir)
.with_context(|| format!("cannot create {}", layout.data_dir.display()))?;
let config_path = layout.sidecar_config();
let db_path = layout.sidecar_db();
// The database is pinned by environment only where it does not already land beside the config.
// See `crate::paths` for why that is a platform difference rather than an inconsistency.
let db_env = (config_path.parent() != db_path.parent()).then_some(db_path.as_path());
let doc = sidecar::print_config(&layout.sidecar_bin, &config_path, db_env)?;
// The installed binary is the only thing that will actually speak to the website, so its
// protocol version is the one that counts. The bundle's gate 1 (PLAN.md §7.1) read this number
// from source at the release tag; a disagreement means the pair in front of us is not the pair
// CI checked, and the sidecar would answer the website with 409 rather than mis-parsing.
if doc.protocol != bundle.protocol {
bail!(
"the installed sidecar reports protocol {} but bundle {} was composed at protocol {}. \
Refusing to register a service for a pair that was never checked together — the \
binary is installed but no service has been created.",
doc.protocol,
bundle.bundle,
bundle.protocol
);
}
if doc.version != bundle.link.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.link.version
));
}
ui::row(
"config",
&format!(
"{} {}",
doc.config_path,
if doc.config_created {
"created"
} else {
"already present"
}
),
);
ui::row("database", &doc.store.path);
ui::row(
"listening on",
&format!("shard {} website {}", doc.shard.bind, doc.web.bind),
);
// The config holds the auth token, and neither default location protects it on its own. This
// runs before registration so the file is never left readable while the rest of the run
// happens; on Windows the service account does not exist until `sc create` creates it, so the
// grant for it comes after.
service::protect_config(
&config_path,
&layout.data_dir,
prepared.user.as_deref(),
layout.relocated,
)?;
let outcome = service::register(&prepared, layout, action.writes())?;
service::grant_service_access(&config_path, &layout.data_dir, &outcome)?;
let service_record = match &outcome {
service::Outcome::Registered {
kind,
name,
unit_path,
user,
user_created,
state,
} => {
ui::row("service", &format!("{name} {state}"));
if let Some(user) = user {
ui::row("running as", user);
}
Some(ServiceRecord {
kind: (*kind).to_string(),
name: name.clone(),
unit_path: unit_path.as_ref().map(|p| p.display().to_string()),
user: user.clone(),
user_created: *user_created,
})
}
service::Outcome::Skipped { reason, manual } => {
println!();
ui::warn(&format!(
"service NOT REGISTERED — {reason}.\n \
The binary and its config are in place; nothing is running them. Do this by hand:"
));
print!("{manual}");
None
}
};
Ok(Some(SidecarOutcome {
record: LinkRecord {
repo: bundle.link.repo.clone(),
tag: bundle.link.tag.clone(),
version: doc.version.clone(),
protocol: doc.protocol,
binary: BinaryRef {
path: layout.sidecar_bin.display().to_string(),
sha256: binary_sha256,
},
config_path: doc.config_path.clone(),
db_path: doc.store.path.clone(),
service: service_record,
},
doc,
service: outcome,
}))
}
/// Fails the run before it writes anything if this process cannot write where it must.
///
/// `create_dir_all` succeeding is not the same question as "can this process write here" — it
/// returns `Ok` for a directory that already exists and is read-only to us — so each location is
/// probed with a real file. The alternative, discovering it three steps later, leaves a ServUO tree
/// that has already been deployed into.
fn preflight_writable(layout: &paths::Layout) -> Result<()> {
let bin_dir = layout
.sidecar_bin
.parent()
.unwrap_or(&layout.sidecar_bin)
.to_path_buf();
for dir in [&layout.state_dir, &layout.data_dir, &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 {}.\n \
Run this installer as {}, or set {} to a writable directory for a test run \
(no service is registered then).",
dir.display(),
if cfg!(windows) {
"Administrator, from an elevated PowerShell"
} else {
"root (sudo)"
},
paths::STATE_DIR_ENV
)
})?;
}
Ok(())
}
/// The hostname the website should use to reach this machine.
///
/// Only ever printed, never connected to — which is why a non-interactive run falls back to the
/// detected name instead of failing. Getting it wrong costs the operator one edit in Admin → Shard;
/// aborting a completed install over an unanswerable prompt costs them the whole run.
fn resolve_host(cli: &Cli) -> String {
if let Some(host) = &cli.host {
return host.clone();
}
let detected = sysinfo::System::host_name()
.filter(|h| !h.trim().is_empty())
.unwrap_or_else(|| "this-host".to_string());
if cli.assume_yes {
return detected;
}
let answer = ui::prompt(
"Hostname your website should use to reach this machine",
Some(&detected),
);
answer.unwrap_or(detected)
}
fn build_record(
@@ -329,6 +575,7 @@ fn build_record(
root: &ServUoRoot,
manifest: &overlay::Manifest,
planned: &[overlay::PlannedFile],
sidecar: Option<&SidecarOutcome>,
) -> InstallRecord {
InstallRecord {
schema: SCHEMA,
@@ -353,10 +600,14 @@ fn build_record(
protocol: manifest.protocol,
files: overlay::file_records(planned),
}),
// Sections this build does not own are carried through verbatim, so a Phase 1 binary
// re-running on a fully installed host cannot make a service or a set of applied patch
// hunks disappear from the record that documents them.
link: prior.and_then(|p| p.link.clone()),
// A `--verify` run installs no sidecar and must not erase the record of one that is
// already there; the same reasoning keeps the patch section (Phase 3) and any field a
// newer installer wrote intact. A record that forgot a running service would make
// `doctor` and `uninstall` forget it too.
link: match sidecar {
Some(outcome) => serde_json::to_value(&outcome.record).ok(),
None => prior.and_then(|p| p.link.clone()),
},
patches: prior.map(|p| p.patches.clone()).unwrap_or_default(),
extra: prior.map(|p| p.extra.clone()).unwrap_or_default(),
}

View File

@@ -4,10 +4,10 @@
//! record is `docs/installer/PLAN.md`; the operator-facing contract, written before this binary
//! existed, is `docs/installer/INSTALL.md`.
//!
//! **This build implements Phase 1 (installer core):** bundle resolution, ServUO detection and
//! validation, the overlay sync, and `install.json`. The uo-link sidecar and its service (Phase 2),
//! the patch tier (Phase 3), and `doctor`/`update`/`uninstall` (Phase 4) are not implemented, and
//! every one of them says so when reached rather than failing as though it were a typo.
//! **This build implements Phases 1 and 2:** bundle resolution, ServUO detection and validation,
//! the overlay sync, `install.json`, the uo-link sidecar and its service, and the token handoff.
//! The patch tier (Phase 3) and `doctor`/`update`/`uninstall` (Phase 4) are not implemented, and
//! each of them says so when reached rather than failing as though it were a typo.
//!
//! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same
//! convention as the sidecar's CLI.
@@ -33,7 +33,9 @@ pub mod net;
pub mod overlay;
pub mod paths;
pub mod record;
pub mod service;
pub mod servuo;
pub mod sidecar;
pub mod ui;
pub mod util;

View File

@@ -3,11 +3,15 @@
//! These paths are fixed by `docs/installer/INSTALL.md` §3 and are the installer's side of the
//! working-directory trap described in PLAN.md §2.3: the sidecar's own defaults are relative to its
//! working directory, and a service manager's working directory is not somewhere to put a database.
//! The installer therefore owns the layout and (from Phase 2) pins `UOLINK_CONFIG` and
//! `UOLINK_DB_PATH` into the service definition.
//! The installer therefore owns the layout and pins the config path into the service definition.
//!
//! Phase 1 only needs the state directory — `install.json` and the cached patch set — but the whole
//! layout is declared here so Phase 2 and 3 add nothing new to argue about.
//! **How the database path is pinned differs by platform, and that is not an inconsistency.** The
//! sidecar resolves a relative `[store].path` against the directory holding `sidecar.toml`
//! (Phase 0.2), so on Windows — where config and data are both `%ProgramData%\RunicGateway` — the
//! shipped default already lands exactly where §3 says, and nothing needs to be set. On Linux the
//! two directories are deliberately different (`/etc` vs `/var/lib`), so the unit carries
//! `UOLINK_DB_PATH`. Setting it on Windows would mean a machine-wide environment variable, which
//! every process on the host inherits and which outlives an uninstall.
use std::env;
use std::path::PathBuf;
@@ -21,10 +25,13 @@ pub const STATE_DIR_ENV: &str = "RUNICGATEWAY_STATE_DIR";
pub struct Layout {
/// `/etc/runicgateway` — `install.json`, `sidecar.toml`, `patches/`.
pub state_dir: PathBuf,
/// `/var/lib/runicgateway` — the sidecar's SQLite store. Phase 2.
/// `/var/lib/runicgateway` — the sidecar's SQLite store.
pub data_dir: PathBuf,
/// `/usr/bin/runicgateway-link` — the installed sidecar binary. Phase 2.
/// `/usr/bin/runicgateway-link` — the installed sidecar binary.
pub sidecar_bin: PathBuf,
/// This layout came from [`STATE_DIR_ENV`], so it describes a test run rather than a real
/// deployment. Service registration is skipped when it is set — see [`layout`].
pub relocated: bool,
}
impl Layout {
@@ -39,20 +46,40 @@ impl Layout {
pub fn sidecar_db(&self) -> PathBuf {
self.data_dir.join("uo-link.db")
}
/// The unit file a systemd host gets. Meaningless elsewhere, and unused under a relocated
/// layout, where no service is registered at all.
pub fn systemd_unit(&self) -> PathBuf {
PathBuf::from("/etc/systemd/system").join(crate::service::SYSTEMD_UNIT)
}
}
/// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`].
///
/// The override moves the *state* and *data* directories together. Splitting them under an override
/// would make a test run write half its files into the real system location, which is exactly the
/// accident the override exists to avoid. The binary path is left alone: nothing in Phase 1 writes
/// it, and a relocated binary would not be what the service definition names.
/// The override moves **everything the installer would write**: state, data, and the sidecar
/// binary. Phase 1 left the binary alone because nothing wrote it; Phase 2 does, and a run that
/// relocated its config while still dropping a binary into `/usr/bin` would be exactly the
/// half-in-the-real-system accident this variable exists to avoid.
///
/// A relocated layout also **suppresses service registration** (see `service::ensure`). There is
/// no such thing as a relocated systemd unit or a relocated Windows service — both are
/// system-global — so the honest behaviour is to install the files, say plainly that no service was
/// registered, and print what a real run would have done.
pub fn layout() -> Layout {
let mut layout = platform_layout();
if let Some(dir) = env::var_os(STATE_DIR_ENV).filter(|v| !v.is_empty()) {
let root = PathBuf::from(dir);
// The file name is kept so a relocated run installs the same binary name a real one would,
// which is what makes `--print-config` and `--version` output comparable between the two.
let bin_name = layout
.sidecar_bin
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("uo-link-sidecar"));
layout.data_dir = root.join("data");
layout.sidecar_bin = root.join("bin").join(bin_name);
layout.state_dir = root;
layout.relocated = true;
}
layout
}
@@ -76,6 +103,7 @@ fn platform_layout() -> Layout {
sidecar_bin: program_files
.join("RunicGateway")
.join("uo-link-sidecar.exe"),
relocated: false,
}
}
@@ -85,6 +113,7 @@ fn platform_layout() -> Layout {
state_dir: PathBuf::from("/etc/runicgateway"),
data_dir: PathBuf::from("/var/lib/runicgateway"),
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
relocated: false,
}
}
@@ -109,5 +138,39 @@ mod tests {
assert!(l.state_dir.is_absolute(), "{:?}", l.state_dir);
assert!(l.data_dir.is_absolute(), "{:?}", l.data_dir);
assert!(l.sidecar_bin.is_absolute(), "{:?}", l.sidecar_bin);
assert!(!l.relocated);
}
#[test]
fn the_windows_database_lands_beside_its_config_by_default() {
// The Windows service pins only the config path; the database follows because the sidecar
// anchors a relative [store].path to the config's directory. That only holds while these
// two directories are the same one, so it is asserted rather than assumed.
#[cfg(windows)]
{
let l = platform_layout();
assert_eq!(l.sidecar_config().parent(), l.sidecar_db().parent());
}
}
#[test]
fn a_relocated_layout_moves_the_binary_too() {
// The failure this prevents: a test run that writes its config and database under the
// override while still dropping a binary into /usr/bin or %ProgramFiles%.
let mut l = platform_layout();
let real_bin = l.sidecar_bin.clone();
let root = std::env::temp_dir().join("rg-layout-test");
let bin_name = l.sidecar_bin.file_name().map(PathBuf::from).unwrap();
l.data_dir = root.join("data");
l.sidecar_bin = root.join("bin").join(&bin_name);
l.state_dir = root.clone();
l.relocated = true;
assert!(l.install_record().starts_with(&root));
assert!(l.sidecar_config().starts_with(&root));
assert!(l.sidecar_db().starts_with(&root));
assert!(l.sidecar_bin.starts_with(&root));
assert_ne!(l.sidecar_bin, real_bin);
assert_eq!(l.sidecar_bin.file_name(), real_bin.file_name());
}
}

View File

@@ -35,7 +35,12 @@ pub struct InstallRecord {
pub servuo: ServUoRef,
#[serde(skip_serializing_if = "Option::is_none")]
pub overlay: Option<OverlayRecord>,
/// Phase 2 (uo-link binary, config and service). Carried through untouched by this build.
/// The sidecar: binary, config, database, service (Phase 2).
///
/// Held as raw JSON rather than as a [`LinkRecord`] so that a record written by a *newer*
/// installer — with fields this build has no name for — survives a re-run here intact. Phase 1
/// carried this section through without understanding it at all; the same tolerance now applies
/// in the other direction. Read it with [`InstallRecord::link_record`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<serde_json::Value>,
/// Phase 3 (applied patches, with the rung that applied each). Carried through untouched.
@@ -98,6 +103,53 @@ pub struct FileRecord {
pub state: String,
}
/// The sidecar half of a deployment, as `install.json` records it.
///
/// **The auth token is not here and must never be.** It lives in `sidecar.toml` and is printed once
/// to the operator's terminal (PLAN.md §6); `install.json` is a support artifact that gets pasted
/// into bug reports.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LinkRecord {
pub repo: String,
pub tag: String,
/// What the installed binary reports, not what the bundle claimed — the two agree, and if they
/// ever did not, the binary is the one that will actually run.
pub version: String,
pub protocol: u32,
pub binary: BinaryRef,
pub config_path: String,
/// Absolute, as the sidecar itself resolved it. On Windows this is anchored to the config's
/// directory rather than pinned by the service, which is why it is recorded rather than derived.
pub db_path: String,
/// `None` when no service was registered — a relocated test run, or a host with no service
/// manager the installer can drive. `doctor` reports that as an unfinished install rather than
/// as a healthy one.
#[serde(skip_serializing_if = "Option::is_none")]
pub service: Option<ServiceRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BinaryRef {
pub path: String,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServiceRecord {
/// `systemd` or `windows-scm`.
pub kind: String,
/// `runicgateway-link.service` or `RunicGatewayLink`.
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit_path: Option<String>,
/// The account the service runs as.
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
/// The installer created that account. `uninstall` (Phase 4) removes only what it created —
/// deleting a user that was already on the host is not this tool's business.
pub user_created: bool,
}
impl InstallRecord {
/// Whether a re-run would record anything new.
///
@@ -142,6 +194,15 @@ impl InstallRecord {
pub fn overlay_files(&self) -> Option<&BTreeMap<String, FileRecord>> {
self.overlay.as_ref().map(|o| &o.files)
}
/// The sidecar section, when it is one this build understands.
///
/// A section it cannot parse yields `None` rather than an error: the raw value is still carried
/// through on save, so the worst case is that this run re-derives what it needs instead of
/// reading it — never that an older installer refuses to run on a newer host.
pub fn link_record(&self) -> Option<LinkRecord> {
serde_json::from_value(self.link.clone()?).ok()
}
}
pub fn now_rfc3339() -> String {
@@ -256,6 +317,48 @@ mod tests {
assert!(!a.same_deployment_as(&d));
}
#[test]
fn the_link_section_round_trips_and_holds_no_secret() {
let link = LinkRecord {
repo: "RunicGateway/link".into(),
tag: "v1.1.0".into(),
version: "1.1.0".into(),
protocol: 3,
binary: BinaryRef {
path: "/usr/bin/runicgateway-link".into(),
sha256: "27d491ef".repeat(8),
},
config_path: "/etc/runicgateway/sidecar.toml".into(),
db_path: "/var/lib/runicgateway/uo-link.db".into(),
service: Some(ServiceRecord {
kind: "systemd".into(),
name: "runicgateway-link.service".into(),
unit_path: Some("/etc/systemd/system/runicgateway-link.service".into()),
user: Some("runicgateway".into()),
user_created: true,
}),
};
let mut record = sample();
record.link = Some(serde_json::to_value(&link).unwrap());
assert_eq!(record.link_record().unwrap(), link);
// install.json is pasted into bug reports. The token lives in sidecar.toml and on the
// operator's terminal; there is no field here for it to arrive in.
let text = serde_json::to_string(&record).unwrap();
assert!(!text.contains("auth_token"), "{text}");
assert!(!text.contains("token"), "{text}");
}
#[test]
fn an_unreadable_link_section_is_ignored_rather_than_fatal() {
// A record written by a future installer must not stop this one from running.
let mut record = sample();
record.link = Some(serde_json::json!({ "shape": "from a newer installer" }));
assert!(record.link_record().is_none());
assert!(InstallRecord::load(Path::new("rg-no-such-record.json")).is_ok());
}
#[test]
fn timestamps_are_utc_rfc3339() {
let now = now_rfc3339();

858
src/service.rs Normal file
View File

@@ -0,0 +1,858 @@
//! Registering the sidecar as a service — systemd on Linux, the SCM on Windows.
//!
//! PLAN.md §5 Phase 2 and §8 question 1. The mechanism is the plainest one that works on a stock
//! host: a written unit file and `systemctl`, or `sc.exe`. No WinSW/NSSM shim to ship and keep
//! current, and no `--service` mode added to the sidecar — a change to `link` for something the
//! platform already does.
//!
//! ## The two definitions are not symmetrical, on purpose
//!
//! - **Both run as a dedicated, unprivileged identity.** Linux gets a system user
//! (`runicgateway`); Windows gets a *virtual service account* (`NT SERVICE\RunicGatewayLink`),
//! which the SCM creates itself, has no password, and exists only for this service. A sidecar
//! that listens on two TCP ports has no business running as `LocalSystem` when its Linux twin
//! does not run as root.
//! - **Only Linux carries `UOLINK_DB_PATH`.** On Windows the config and the database live in the
//! same directory and the sidecar already anchors a relative `[store].path` there, so the pin is
//! redundant — and the only way to give a Windows service an environment variable through
//! `sc.exe` is to set a *machine-wide* one, which every process on the host would inherit and
//! which would outlive an uninstall. The config path is passed as `--config` in the service's own
//! command line instead, which is scoped to this service by construction.
//!
//! ## Failure is degradation, not an aborted install
//!
//! A host with no systemd (openrc, a container, a distro that never had it) or one where the
//! service user cannot be created still gets a working binary and a provisioned config. What it
//! does not get is a silently weaker service — there is no fallback to `User=root` or to
//! `LocalSystem`. It gets the exact unit text and the exact commands, printed, and `install.json`
//! records that no service was registered so `doctor` keeps saying so.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::util::{command_line, run, run_ok};
/// The unit file name, and the systemd service name with its suffix.
pub const SYSTEMD_UNIT: &str = "runicgateway-link.service";
/// The Windows service key name (INSTALL.md §3).
pub const WINDOWS_SERVICE: &str = "RunicGatewayLink";
/// The dedicated Linux system user.
pub const SERVICE_USER: &str = "runicgateway";
/// What both platforms show a human.
const DISPLAY_NAME: &str = "Runic Gateway uo-link sidecar";
/// Which service manager this host has — or why it has none this installer can drive.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Manager {
Systemd,
WindowsScm,
/// No service will be registered. The string is the reason, printed to the operator verbatim.
Unavailable(String),
}
impl Manager {
pub fn kind(&self) -> &'static str {
match self {
Self::Systemd => "systemd",
Self::WindowsScm => "windows-scm",
Self::Unavailable(_) => "none",
}
}
}
/// Everything decided before the sidecar's config is provisioned: which manager, and which identity
/// the service will run as. Split from [`register`] because the identity has to exist *before*
/// `--print-config` writes a config file that then has to be owned by it.
#[derive(Debug, Clone)]
pub struct Prepared {
pub manager: Manager,
/// The account the service runs as, when this platform names one the installer has to create.
/// `None` on Windows, where the SCM creates the virtual account itself as part of registration.
pub user: Option<String>,
/// This run created that account — recorded so `uninstall` (Phase 4) knows whether removing it
/// is its business or somebody else's.
pub user_created: bool,
}
/// What registration ended up doing.
#[derive(Debug, Clone)]
pub enum Outcome {
Registered {
kind: &'static str,
name: String,
unit_path: Option<PathBuf>,
user: Option<String>,
user_created: bool,
/// `running, enabled` — read back from the manager, not assumed from the exit codes.
state: String,
},
/// Nothing was registered. `reason` says why in one line; `manual` is the full set of steps.
Skipped { reason: String, manual: String },
}
impl Outcome {
pub fn registered(&self) -> bool {
matches!(self, Self::Registered { .. })
}
}
/// Detects the service manager and makes sure the service identity exists.
///
/// Never returns `Err`: everything that can go wrong here is a reason to skip service registration
/// and say so, not a reason to fail an install whose binary and config are already correct.
pub fn prepare(relocated: bool) -> Prepared {
if relocated {
return unavailable(format!(
"{} is set, so this is a test run",
crate::paths::STATE_DIR_ENV
));
}
prepare_platform()
}
fn unavailable(reason: String) -> Prepared {
Prepared {
manager: Manager::Unavailable(reason),
user: None,
user_created: false,
}
}
// ── Linux ────────────────────────────────────────────────────────────────────
#[cfg(unix)]
fn prepare_platform() -> Prepared {
// The canonical "was this host booted with systemd" test. `systemctl` being on PATH is not the
// same question — it is installed in plenty of containers where PID 1 is not systemd, and
// `systemctl enable` there fails with a message about the D-Bus socket rather than anything an
// operator can act on.
if !Path::new("/run/systemd/system").is_dir() {
return unavailable(
"this host is not running systemd (/run/systemd/system does not exist)".to_string(),
);
}
match ensure_user(SERVICE_USER) {
Ok(created) => Prepared {
manager: Manager::Systemd,
user: Some(SERVICE_USER.to_string()),
user_created: created,
},
// No fallback to User=root. A service that quietly runs with more privilege than its own
// documentation promises is worse than one that was not registered.
Err(error) => unavailable(format!(
"the {SERVICE_USER} service user does not exist and could not be created ({error})"
)),
}
}
/// Creates the dedicated system user if it is not already there. Returns whether it created it.
#[cfg(unix)]
fn ensure_user(user: &str) -> Result<bool> {
if run("id", &["-u", user]).map(|o| o.status.success()) == Ok(true) {
return Ok(false);
}
// `useradd` is the near-universal spelling; `adduser` is the fallback for Debian's wrapper and
// for busybox, whose `adduser` is the only one present on a minimal image.
let useradd = run_ok(
"useradd",
&[
"--system",
"--no-create-home",
"--shell",
"/usr/sbin/nologin",
user,
],
);
if useradd.is_ok() {
return Ok(true);
}
run_ok("adduser", &["--system", "--no-create-home", user])
.map(|_| true)
.map_err(|adduser_error| {
anyhow::anyhow!(
"{}; and {}",
useradd.unwrap_err().to_string().replace('\n', " "),
adduser_error.to_string().replace('\n', " ")
)
})
}
/// The unit file. Pure, so its content is a test rather than something only a Linux host can check.
///
/// Deliberately close to the hand-written unit in INSTALL.md Appendix A4 — an operator who set this
/// up by hand and later runs the installer should recognize what replaces their file.
pub fn systemd_unit_text(binary: &Path, config: &Path, db: &Path, user: &str) -> String {
format!(
"# {DISPLAY_NAME}\n\
#\n\
# Generated by the Runic Gateway installer {installer}. It is rewritten by `install` and\n\
# `update` whenever its content changes, so local edits belong in a drop-in instead:\n\
# systemctl edit {SYSTEMD_UNIT}\n\
\n\
[Unit]\n\
Description={DISPLAY_NAME}\n\
After=network.target\n\
\n\
[Service]\n\
Type=simple\n\
User={user}\n\
Environment=UOLINK_CONFIG={config}\n\
Environment=UOLINK_DB_PATH={db}\n\
ExecStart={binary}\n\
Restart=on-failure\n\
RestartSec=5\n\
\n\
[Install]\n\
WantedBy=multi-user.target\n",
installer = env!("CARGO_PKG_VERSION"),
config = config.display(),
db = db.display(),
binary = binary.display(),
)
}
#[cfg(unix)]
fn register_systemd(
prepared: &Prepared,
binary: &Path,
config: &Path,
db: &Path,
unit_path: &Path,
restart: bool,
) -> Result<Outcome> {
let user = prepared.user.clone().unwrap_or_else(|| "root".into());
let text = systemd_unit_text(binary, config, db, &user);
// An unchanged unit is not rewritten: daemon-reload is not free, and an mtime that moves on
// every run is a change an operator watching /etc has to investigate and then dismiss.
let current = std::fs::read_to_string(unit_path).unwrap_or_default();
if current != text {
crate::util::write_atomic(unit_path, text.as_bytes())
.with_context(|| format!("cannot write {}", unit_path.display()))?;
run_ok("systemctl", &["daemon-reload"])?;
}
run_ok("systemctl", &["enable", SYSTEMD_UNIT])?;
if restart {
// The binary underneath a running service has just been replaced; `start` on an already
// active unit is a no-op and would leave the old code running.
run_ok("systemctl", &["restart", SYSTEMD_UNIT])?;
} else {
run_ok("systemctl", &["start", SYSTEMD_UNIT])?;
}
Ok(Outcome::Registered {
kind: "systemd",
name: SYSTEMD_UNIT.to_string(),
unit_path: Some(unit_path.to_path_buf()),
user: prepared.user.clone(),
user_created: prepared.user_created,
state: systemd_state(),
})
}
/// Reads the unit's state back rather than inferring it from the exit codes above. `systemctl
/// start` succeeding and the service still being up a second later are different claims.
#[cfg(unix)]
fn systemd_state() -> String {
let active = one_word(run("systemctl", &["is-active", SYSTEMD_UNIT]));
let enabled = one_word(run("systemctl", &["is-enabled", SYSTEMD_UNIT]));
format!("{active}, {enabled}")
}
#[cfg(unix)]
fn one_word(result: Result<std::process::Output>) -> String {
result
.ok()
.and_then(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_string)
})
.unwrap_or_else(|| "unknown".to_string())
}
// ── Windows ──────────────────────────────────────────────────────────────────
#[cfg(windows)]
fn prepare_platform() -> Prepared {
// The SCM creates the virtual account as part of `sc create obj= "NT SERVICE\<name>"`, so
// there is nothing to provision here — and nothing that can fail before the config exists.
Prepared {
manager: Manager::WindowsScm,
user: None,
user_created: false,
}
}
/// The virtual service account the SCM creates for this service. Locale-independent: the `NT
/// SERVICE\<name>` form is a well-known prefix, unlike `BUILTIN\Administrators`, whose display name
/// is translated.
pub fn windows_service_account() -> String {
format!("NT SERVICE\\{WINDOWS_SERVICE}")
}
/// The `binPath=` value: the executable and the `--config` it must always be started with.
///
/// Pure and tested on both platforms because it is the single string that decides whether an
/// installed service reads the config the installer wrote, or whatever `sidecar.toml` happens to be
/// in the service's working directory — which, for a Windows service, is `%SystemRoot%\System32`.
pub fn windows_bin_path(binary: &Path, config: &Path) -> String {
format!("\"{}\" --config \"{}\"", binary.display(), config.display())
}
#[cfg(windows)]
fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outcome> {
let bin_path = windows_bin_path(binary, config);
let account = windows_service_account();
if windows_service_exists() {
// `config` rather than delete-and-recreate: recreating would drop the failure actions and,
// more to the point, would briefly leave a host with no service if the create half failed.
run_ok(
"sc.exe",
&[
"config",
WINDOWS_SERVICE,
"binPath=",
&bin_path,
"start=",
"auto",
"obj=",
&account,
],
)?;
} else {
run_ok(
"sc.exe",
&[
"create",
WINDOWS_SERVICE,
"binPath=",
&bin_path,
"start=",
"auto",
"obj=",
&account,
"DisplayName=",
DISPLAY_NAME,
],
)
.context(
"could not register the Windows service. The account is a virtual service account \
(no password); if this host's policy forbids them, register the service by hand — \
INSTALL.md Appendix A4.",
)?;
let _ = run("sc.exe", &["description", WINDOWS_SERVICE, DISPLAY_NAME]);
}
// Restart on failure, matching systemd's Restart=on-failure / RestartSec=5. `reset= 86400`
// means the failure count goes back to zero after a quiet day, so a service that crashes once
// a month keeps being restarted.
run_ok(
"sc.exe",
&[
"failure",
WINDOWS_SERVICE,
"reset=",
"86400",
"actions=",
"restart/5000",
],
)?;
if restart && windows_service_state().contains("RUNNING") {
stop_windows_service()?;
}
// 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure.
let start = run("sc.exe", &["start", WINDOWS_SERVICE])?;
if !start.status.success() && start.status.code() != Some(1056) {
anyhow::bail!(
"`{}` failed with exit code {}. Check the Windows event log; a service that exits \
immediately usually cannot read its config: {}",
command_line("sc.exe", &["start", WINDOWS_SERVICE]),
start.status.code().unwrap_or(-1),
config.display()
);
}
Ok(Outcome::Registered {
kind: "windows-scm",
name: WINDOWS_SERVICE.to_string(),
unit_path: None,
user: Some(account),
user_created: false,
state: format!("{}, automatic start", windows_service_state()),
})
}
/// 1060 is ERROR_SERVICE_DOES_NOT_EXIST. Anything else — including an access-denied — is treated as
/// "it exists", so the caller reconfigures rather than trying to create a service that is there.
#[cfg(windows)]
fn windows_service_exists() -> bool {
match run("sc.exe", &["query", WINDOWS_SERVICE]) {
Ok(output) => output.status.code() != Some(1060),
Err(_) => false,
}
}
#[cfg(windows)]
fn windows_service_state() -> String {
let Ok(output) = run("sc.exe", &["query", WINDOWS_SERVICE]) else {
return "unknown".to_string();
};
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
if line.trim_start().starts_with("STATE") {
// " STATE : 4 RUNNING"
if let Some(word) = line.split_whitespace().last() {
return word.to_string();
}
}
}
"unknown".to_string()
}
#[cfg(windows)]
fn stop_windows_service() -> Result<()> {
// 1062 is ERROR_SERVICE_NOT_ACTIVE — already the state being asked for.
let stop = run("sc.exe", &["stop", WINDOWS_SERVICE])?;
if !stop.status.success() && stop.status.code() != Some(1062) {
anyhow::bail!(
"cannot stop {WINDOWS_SERVICE} (exit code {}). The sidecar binary is locked while the \
service runs, so the install cannot replace it.",
stop.status.code().unwrap_or(-1)
);
}
// The SCM returns as soon as the stop is *pending*; the file stays locked until the process
// actually exits. Polling is the only way to know, and a fixed sleep would be either too short
// or a delay on every run.
for _ in 0..30 {
if windows_service_state() == "STOPPED" {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
anyhow::bail!(
"{WINDOWS_SERVICE} did not stop within 15 seconds. Stop it by hand and re-run: \
sc.exe stop {WINDOWS_SERVICE}"
)
}
// ── Shared entry points ──────────────────────────────────────────────────────
/// Stops a running service so its binary can be replaced.
///
/// Called only when the installed binary differs from the bundle's. On Windows the file is locked
/// while the service runs; on Linux replacing it under a running process is permitted but leaves
/// the old code serving until something restarts it, which is a worse outcome than a brief gap.
pub fn stop_for_replacement(manager: &Manager) -> Result<()> {
match manager {
#[cfg(unix)]
Manager::Systemd => {
// Not `run_ok`: a unit that is not loaded yet (first install) exits non-zero, and that
// is the normal case rather than an error.
let _ = run("systemctl", &["stop", SYSTEMD_UNIT]);
Ok(())
}
#[cfg(windows)]
Manager::WindowsScm => {
if windows_service_exists() {
stop_windows_service()?;
}
Ok(())
}
_ => Ok(()),
}
}
/// Registers, enables and starts the service — or explains why it did not.
///
/// `binary_changed` decides restart versus start: a replaced binary under an already-running
/// service must be restarted, or the host keeps running the code that was just replaced.
pub fn register(
prepared: &Prepared,
layout: &crate::paths::Layout,
binary_changed: bool,
) -> Result<Outcome> {
let config = layout.sidecar_config();
let db = layout.sidecar_db();
match &prepared.manager {
Manager::Unavailable(reason) => Ok(Outcome::Skipped {
reason: reason.clone(),
manual: manual_steps(&layout.sidecar_bin, &config, &db, !layout.relocated),
}),
#[cfg(unix)]
Manager::Systemd => register_systemd(
prepared,
&layout.sidecar_bin,
&config,
&db,
&layout.systemd_unit(),
binary_changed,
),
#[cfg(windows)]
Manager::WindowsScm => register_windows(&layout.sidecar_bin, &config, binary_changed),
// The manager this build cannot drive because it was compiled for the other platform. Only
// reachable if a Manager is constructed by hand; the detector never produces it.
#[allow(unreachable_patterns)]
other => Ok(Outcome::Skipped {
reason: format!("{} is not supported by this build", other.kind()),
manual: manual_steps(&layout.sidecar_bin, &config, &db, !layout.relocated),
}),
}
}
/// What to do by hand when no service was registered. The whole point of degrading rather than
/// failing: the operator ends the run with a working binary and the exact commands.
///
/// `config_protected` says whether the run already locked the config file down. It must not be
/// guessed: a relocated run deliberately leaves the permissions alone (see [`protect_config`]), and
/// a printed recipe that claims a token file is already protected when it is not is worse than one
/// that simply tells the operator to protect it.
pub fn manual_steps(binary: &Path, config: &Path, db: &Path, config_protected: bool) -> String {
#[cfg(windows)]
{
// `binPath=` is wrapped in *single* quotes, which is what makes this line pasteable into
// PowerShell: the value itself contains the double quotes the SCM needs around a path with
// spaces, and PowerShell would otherwise eat them. The `sc.exe` convention of a space after
// each `=` is not a typo either — the key and the value are separate arguments.
format!(
" From an elevated PowerShell:\n\n \
sc.exe create {WINDOWS_SERVICE} binPath= '{bin_path}' obj= '{account}' start= auto\n \
sc.exe failure {WINDOWS_SERVICE} reset= 86400 actions= restart/5000\n \
icacls '{config}' /grant '{account}:(R)'\n \
icacls '{data_dir}' /grant '{account}:(OI)(CI)M'\n \
sc.exe start {WINDOWS_SERVICE}\n\n{token_note}",
bin_path = windows_bin_path(binary, config),
account = windows_service_account(),
config = config.display(),
data_dir = db.parent().unwrap_or(db).display(),
token_note = if config_protected {
" That config file's own permissions have already been restricted to \
Administrators\n and SYSTEM, because it holds the auth token. The two grants \
above are what the\n service account needs once `sc create` has created it.\n"
.to_string()
} else {
format!(
" That config file holds the auth token, and this run did NOT restrict its\n \
permissions. Lock it down too:\n\n \
icacls '{config}' /inheritance:r /grant:r '*S-1-5-18:(F)' /grant:r \
'*S-1-5-32-544:(F)'\n",
config = config.display(),
)
},
)
}
#[cfg(not(windows))]
{
// The chown lines are printed whether or not the file has already been chmod'ed: on this
// platform `protect_config` restricts the mode but can only hand the file to a user that
// exists, and reaching here means one does not.
let _ = config_protected;
format!(
" Write this to /etc/systemd/system/{SYSTEMD_UNIT}:\n\n{unit}\n \
Then:\n\n \
useradd --system --no-create-home --shell /usr/sbin/nologin {SERVICE_USER}\n \
chown {SERVICE_USER} {config}\n \
chown -R {SERVICE_USER} {data_dir}\n \
systemctl daemon-reload\n \
systemctl enable --now {SYSTEMD_UNIT}\n\n \
On a host without systemd, run the binary under whatever supervisor it does have —\n \
the only requirements are that it starts {binary} with UOLINK_CONFIG={config}\n \
and UOLINK_DB_PATH={db}, as an unprivileged user that can write the database.\n",
unit = indent(&systemd_unit_text(binary, config, db, SERVICE_USER)),
config = config.display(),
db = db.display(),
data_dir = db.parent().unwrap_or(db).display(),
binary = binary.display(),
)
}
}
#[cfg(not(windows))]
fn indent(text: &str) -> String {
text.lines()
.map(|l| format!(" {l}\n"))
.collect::<String>()
}
/// Locks down the files holding the auth token, **before** the service is registered.
///
/// `sidecar.toml` holds the token, and neither default location protects it on its own: `/etc` is
/// world-readable, and `%ProgramData%` grants `Users` read by inheritance. This runs as early as the
/// platform allows so the file is never sitting there readable while the rest of the run happens.
///
/// On Linux that is the whole job — the service user already exists, so the file can be handed to it
/// here. On Windows the service account does not exist until `sc create` creates it, so this step
/// only shuts everyone else out and [`grant_service_access`] does the rest afterwards.
///
/// `relocated` is not a courtesy flag. On Windows this replaces the file's ACL with SYSTEM and
/// Administrators, which is right for a real install — the installer runs elevated and a service
/// account is about to be granted read — and wrong for a relocated test run, where there is no
/// service account and the operator is explicitly *not* an administrator. Hardening a scratch file
/// against the only person who will ever read it just makes the next test run fail.
pub fn protect_config(
config: &Path,
data_dir: &Path,
user: Option<&str>,
relocated: bool,
) -> Result<()> {
protect_config_platform(config, data_dir, user, relocated)
}
/// Grants the registered service account access to what it must read and write.
///
/// A no-op on Linux, where the account was known before the config existed and `protect_config`
/// already handed both to it. On Windows this is the second half of that job, and it can only run
/// once the SCM has created the virtual account.
pub fn grant_service_access(config: &Path, data_dir: &Path, outcome: &Outcome) -> Result<()> {
grant_service_access_platform(config, data_dir, outcome)
}
#[cfg(unix)]
fn grant_service_access_platform(
_config: &Path,
_data_dir: &Path,
_outcome: &Outcome,
) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn protect_config_platform(
config: &Path,
data_dir: &Path,
user: Option<&str>,
_relocated: bool,
) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(config, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("cannot restrict permissions on {}", config.display()))?;
if let Some(user) = user {
// `chown` the command rather than a libc call: this crate has no libc dependency, the
// operation happens once per run, and a failure here has to be reported with the exact
// command anyway. Group is deliberately not set — `useradd --system` creates a matching
// group on most distros but not all, and naming one that does not exist fails the chown.
run_ok("chown", &[user, &config.to_string_lossy()])?;
run_ok("chown", &["-R", user, &data_dir.to_string_lossy()])?;
}
Ok(())
}
#[cfg(windows)]
fn protect_config_platform(
config: &Path,
_data_dir: &Path,
_user: Option<&str>,
relocated: bool,
) -> Result<()> {
if relocated {
return Ok(());
}
// Well-known SIDs, not display names: `Administrators` and `SYSTEM` are localized, and an
// icacls line naming them fails on a non-English Windows.
// *S-1-5-18 NT AUTHORITY\SYSTEM
// *S-1-5-32-544 BUILTIN\Administrators
// Removing inheritance is the entire point: %ProgramData% grants `Users` read by inheritance,
// and the auth token is in this file.
run_ok(
"icacls",
&[
config.to_string_lossy().into_owned(),
"/inheritance:r".to_string(),
"/grant:r".to_string(),
"*S-1-5-18:(F)".to_string(),
"/grant:r".to_string(),
"*S-1-5-32-544:(F)".to_string(),
],
)
.context("cannot restrict access to the sidecar config, which holds the auth token")?;
Ok(())
}
#[cfg(windows)]
fn grant_service_access_platform(config: &Path, data_dir: &Path, outcome: &Outcome) -> Result<()> {
// Nothing to grant when nothing was registered: the account only exists because `sc create`
// made it, and a skipped registration leaves the config locked to Administrators — which is the
// right resting state for a host where no service is going to read it.
if !outcome.registered() {
return Ok(());
}
let account = windows_service_account();
run_ok(
"icacls",
&[
config.to_string_lossy().into_owned(),
"/grant".to_string(),
format!("{account}:(R)"),
],
)
.context("cannot grant the service account read access to its config")?;
// The service creates the database — and SQLite's journal and WAL files beside it — so the
// grant is on the directory: `(OI)(CI)M` = modify, inherited by files and subdirectories. On
// Windows that directory also holds `install.json`, because §3 puts both under
// `%ProgramData%\RunicGateway`; the config file is unaffected, since removing its inheritance
// above is what stops a directory grant from reaching it.
run_ok(
"icacls",
&[
data_dir.to_string_lossy().into_owned(),
"/grant".to_string(),
format!("{account}:(OI)(CI)M"),
],
)
.context("cannot grant the service account write access to its database directory")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_unit_pins_both_paths_and_an_unprivileged_user() {
// Every line here is load-bearing: an unpinned config path resolves against the service's
// working directory (PLAN.md §2.3), and User= is the whole reason the installer creates an
// account at all.
let unit = systemd_unit_text(
Path::new("/usr/bin/runicgateway-link"),
Path::new("/etc/runicgateway/sidecar.toml"),
Path::new("/var/lib/runicgateway/uo-link.db"),
SERVICE_USER,
);
assert!(unit.contains("Environment=UOLINK_CONFIG=/etc/runicgateway/sidecar.toml"));
assert!(unit.contains("Environment=UOLINK_DB_PATH=/var/lib/runicgateway/uo-link.db"));
assert!(unit.contains("ExecStart=/usr/bin/runicgateway-link"));
assert!(unit.contains(&format!("User={SERVICE_USER}")));
assert!(!unit.contains("User=root"), "{unit}");
assert!(unit.contains("Restart=on-failure"));
assert!(unit.contains("WantedBy=multi-user.target"));
// The drop-in pointer, so an operator with local changes is not fighting the installer.
assert!(unit.contains("systemctl edit"), "{unit}");
}
#[test]
fn the_windows_bin_path_quotes_both_paths() {
// Both default Windows paths contain a space ("Program Files", and any relocated tree can).
// An unquoted binPath is the classic Windows service bug: the SCM would try to run
// C:\Program.exe with "Files\..." as an argument.
let line = windows_bin_path(
Path::new(r"C:\Program Files\RunicGateway\uo-link-sidecar.exe"),
Path::new(r"C:\ProgramData\RunicGateway\sidecar.toml"),
);
assert_eq!(
line,
"\"C:\\Program Files\\RunicGateway\\uo-link-sidecar.exe\" \
--config \"C:\\ProgramData\\RunicGateway\\sidecar.toml\""
);
}
#[test]
fn the_service_account_is_the_virtual_one() {
// Not LocalSystem. The Linux half runs as an unprivileged user and this is its counterpart.
assert_eq!(windows_service_account(), "NT SERVICE\\RunicGatewayLink");
}
#[test]
fn a_relocated_run_registers_nothing() {
// There is no such thing as a relocated system service, so a test run must not create one.
let prepared = prepare(true);
assert!(matches!(prepared.manager, Manager::Unavailable(_)));
assert!(prepared.user.is_none());
match &prepared.manager {
Manager::Unavailable(reason) => {
assert!(reason.contains(crate::paths::STATE_DIR_ENV), "{reason}")
}
other => panic!("{other:?}"),
}
}
#[test]
fn the_manual_steps_are_a_complete_recipe() {
// This text is all an operator gets on a host the installer cannot drive, so it has to name
// the binary, the config and the service — not merely gesture at the documentation.
let steps = manual_steps(
Path::new("/usr/bin/runicgateway-link"),
Path::new("/etc/runicgateway/sidecar.toml"),
Path::new("/var/lib/runicgateway/uo-link.db"),
true,
);
assert!(steps.contains("sidecar.toml"), "{steps}");
#[cfg(windows)]
{
assert!(steps.contains("sc.exe create"), "{steps}");
assert!(steps.contains("NT SERVICE\\RunicGatewayLink"), "{steps}");
// The binPath value carries its own double quotes, so the argument around it must be
// single-quoted or PowerShell strips them and the SCM gets an unquoted path.
assert!(steps.contains("binPath= '\""), "{steps}");
assert!(!steps.contains("binPath= \"\""), "{steps}");
// The write grant belongs to the database directory, which is not always the config's.
assert!(steps.contains("/var/lib/runicgateway'"), "{steps}");
}
}
#[test]
fn the_recipe_never_claims_a_protection_the_run_did_not_apply() {
// A relocated run leaves the token file's permissions alone. Telling the operator it is
// already locked down would be the one sentence here that could cost them the token.
let args = (
Path::new("/usr/bin/runicgateway-link"),
Path::new("/etc/runicgateway/sidecar.toml"),
Path::new("/var/lib/runicgateway/uo-link.db"),
);
let protected = manual_steps(args.0, args.1, args.2, true);
let unprotected = manual_steps(args.0, args.1, args.2, false);
#[cfg(windows)]
{
assert_ne!(protected, unprotected);
assert!(protected.contains("already been restricted"), "{protected}");
assert!(unprotected.contains("did NOT restrict"), "{unprotected}");
assert!(unprotected.contains("/inheritance:r"), "{unprotected}");
}
// On Linux the recipe is the same either way, and correct either way: `protect_config`
// restricts the mode but cannot hand the file to a user that does not exist yet, so the
// chown lines are needed regardless.
#[cfg(not(windows))]
{
assert_eq!(protected, unprotected);
assert!(protected.contains("chown runicgateway"), "{protected}");
}
#[cfg(not(windows))]
{
assert!(steps.contains("systemctl enable --now"), "{steps}");
assert!(
steps.contains("ExecStart=/usr/bin/runicgateway-link"),
"{steps}"
);
// Non-systemd hosts get the requirements, not just a unit they cannot use.
assert!(steps.contains("without systemd"), "{steps}");
}
}
#[test]
fn a_skipped_registration_still_hands_over_the_recipe() {
let layout = crate::paths::Layout {
state_dir: PathBuf::from("/etc/runicgateway"),
data_dir: PathBuf::from("/var/lib/runicgateway"),
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
relocated: true,
};
let outcome = register(&prepare(true), &layout, false).unwrap();
match outcome {
Outcome::Skipped { reason, manual } => {
assert!(reason.contains("test run"), "{reason}");
assert!(!manual.trim().is_empty());
}
other => panic!("expected a skip, got {other:?}"),
}
}
}

465
src/sidecar.rs Normal file
View File

@@ -0,0 +1,465 @@
//! 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,
}
}
}

View File

@@ -1,11 +1,13 @@
//! Hashing and scratch-directory helpers.
//! Hashing, scratch directories, and running other programs.
use std::ffi::OsStr;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use anyhow::{bail, Context, Result};
use sha2::{Digest, Sha256};
/// Lower-case hex, written out rather than taken from a crate.
@@ -146,6 +148,78 @@ pub fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
Ok(())
}
/// How a command is written back to the operator when it fails.
///
/// Reproducible by hand is the whole point: every external command this tool runs — `systemctl`,
/// `useradd`, `sc.exe` — is one an operator can run themselves, and a failure they can retype is a
/// failure they can diagnose.
pub fn command_line<S: AsRef<OsStr>>(program: &str, args: &[S]) -> String {
let mut line = String::from(program);
for arg in args {
let text = arg.as_ref().to_string_lossy().into_owned();
line.push(' ');
if text.contains(' ') && !text.starts_with('"') {
line.push('"');
line.push_str(&text);
line.push('"');
} else {
line.push_str(&text);
}
}
line
}
/// Runs a program to completion, capturing its output. A non-zero exit is **not** an error here —
/// several callers ask questions whose answer *is* the exit code (`id -u`, `sc query`).
pub fn run<S: AsRef<OsStr>>(program: &str, args: &[S]) -> Result<Output> {
Command::new(program).args(args).output().with_context(|| {
format!(
"cannot run `{}` — is it installed and on PATH?",
command_line(program, args)
)
})
}
/// Runs a program and treats a non-zero exit as a failure, quoting what it printed.
///
/// **Never call this on anything that emits a secret.** The sidecar's `--print-config` writes the
/// auth token to stdout, so it is run through [`run`] and handled where the token can be kept out
/// of the error path (PLAN.md §6).
pub fn run_ok<S: AsRef<OsStr>>(program: &str, args: &[S]) -> Result<Output> {
let output = run(program, args)?;
if !output.status.success() {
bail!(failure_message(
&command_line(program, args),
output.status.code(),
&output.stderr,
&output.stdout,
));
}
Ok(output)
}
/// The message a failed command produces. Split out from [`run_ok`] because it is the part worth
/// testing — spawning a process that fails identically on Linux and Windows is not.
fn failure_message(line: &str, code: Option<i32>, stderr: &[u8], stdout: &[u8]) -> String {
let detail = first_useful_line(stderr)
.or_else(|| first_useful_line(stdout))
.unwrap_or_else(|| "(no output)".to_string());
let status = match code {
Some(code) => format!("exit code {code}"),
None => "no exit code (killed by a signal)".to_string(),
};
format!("`{line}` failed with {status}: {detail}")
}
/// The first non-blank line of a captured stream, for a one-line error message.
fn first_useful_line(bytes: &[u8]) -> Option<String> {
String::from_utf8_lossy(bytes)
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -193,6 +267,52 @@ mod tests {
assert!(!path.exists());
}
#[test]
fn a_failed_command_is_reported_with_what_it_printed() {
// Both halves matter: the command to retype, and the reason it failed. stderr wins over
// stdout because that is where systemctl and sc.exe put the reason.
let message = failure_message(
"systemctl enable --now runicgateway-link.service",
Some(1),
b"Failed to enable unit: Unit file does not exist.\n",
b"noise\n",
);
assert!(message.contains("systemctl enable"), "{message}");
assert!(message.contains("exit code 1"), "{message}");
assert!(message.contains("Unit file does not exist."), "{message}");
// A command that fails silently must still say something usable.
let quiet = failure_message("sc.exe start RunicGatewayLink", Some(1053), b"", b"");
assert!(
quiet.contains("1053") && quiet.contains("(no output)"),
"{quiet}"
);
}
#[test]
fn a_missing_program_says_so_rather_than_panicking() {
let err = run("rg-no-such-program-exists", &["x"])
.unwrap_err()
.to_string();
assert!(err.contains("rg-no-such-program-exists"), "{err}");
}
#[test]
fn command_lines_quote_arguments_containing_spaces() {
// These strings are printed for an operator to paste back; an unquoted Windows path with
// spaces in it would be a command that does not work when they do.
let line = command_line(
"sc.exe",
&[
"create",
"RunicGatewayLink",
"binPath=",
"C:\\Program Files\\x.exe",
],
);
assert!(line.contains("\"C:\\Program Files\\x.exe\""), "{line}");
}
#[test]
fn atomic_write_replaces_an_existing_file() {
let dir = TempDir::new("rg-test-atomic").unwrap();