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

@@ -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());
}
}