feat(installer): implement Phase 2 — uo-link install and service
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 1m15s
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:
83
src/paths.rs
83
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user