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

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