diff --git a/src/install.rs b/src/install.rs index fe06903..b2a8d88 100644 --- a/src/install.rs +++ b/src/install.rs @@ -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 \ - --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> { + 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(), } diff --git a/src/lib.rs b/src/lib.rs index 3e641dc..e3e4fcf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/paths.rs b/src/paths.rs index 5105983..f3415a2 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -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()); } } diff --git a/src/record.rs b/src/record.rs index 61ce773..588a04a 100644 --- a/src/record.rs +++ b/src/record.rs @@ -35,7 +35,12 @@ pub struct InstallRecord { pub servuo: ServUoRef, #[serde(skip_serializing_if = "Option::is_none")] pub overlay: Option, - /// 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, /// 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, +} + +#[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, + /// The account the service runs as. + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + /// 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> { 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 { + 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(); diff --git a/src/service.rs b/src/service.rs new file mode 100644 index 0000000..0b31e8b --- /dev/null +++ b/src/service.rs @@ -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, + /// 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, + user: Option, + 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 { + 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 { + 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) -> 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\"`, 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\` 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 { + 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 { + 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::() +} + +/// 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:?}"), + } + } +} diff --git a/src/sidecar.rs b/src/sidecar.rs new file mode 100644 index 0000000..61b5831 --- /dev/null +++ b/src/sidecar.rs @@ -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 `. 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 { + 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 { + 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 { + 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 (protocol )`. +/// +/// 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 { + 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://".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::(&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:///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, + } + } +} diff --git a/src/util.rs b/src/util.rs index 8fde480..f0e994d 100644 --- a/src/util.rs +++ b/src/util.rs @@ -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>(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>(program: &str, args: &[S]) -> Result { + 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>(program: &str, args: &[S]) -> Result { + 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, 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::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();