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

Adds the sidecar half of a deployment to the same `install` run: download and
verify the bundle's binary, provision its config, register and start a service,
and print the token handoff PLAN.md §6 specifies. `src/sidecar.rs` owns the
binary and the config document; `src/service.rs` owns systemd and the Windows
SCM.

The order is fixed by PLAN.md §5 and matters: stop anything running the old
binary, replace it, then `--print-config` (which writes the config the service
will be pointed at), then register. Registering first points a service at a file
that does not exist yet.

Decisions worth a reviewer's attention:

- Both platforms run the sidecar as a dedicated unprivileged identity. Linux gets
  the `runicgateway` system user the plan already specified; Windows gets a
  virtual service account, `sc create ... obj= "NT SERVICE\RunicGatewayLink"`,
  which the SCM creates itself and which has no password. Plain `sc create` runs
  as LocalSystem — the most privileged local identity there is, for a process
  listening on two TCP ports while its Linux twin deliberately does not run as
  root.
- `sidecar.toml` holds the auth token and neither default location protects it:
  /etc is world-readable and %ProgramData% grants Users read by inheritance, so a
  stock install would leave the shard's token readable by any local account. The
  lockdown straddles registration because it has to — on Windows the service
  account does not exist until `sc create` creates it, so the file is first cut
  down to SYSTEM + Administrators, and the account's read grant comes after.
- Only Linux pins UOLINK_DB_PATH. On Windows config and data share a directory
  and the sidecar anchors a relative [store] path to its config's directory, so
  the pin is redundant — and `sc.exe` has no per-service environment, only a
  machine-wide one that every process inherits and that outlives an uninstall.
  The config path rides in the service's own binPath instead.
- `--verify` runs no part of the sidecar half. `--print-config` provisions: it
  writes the config and mints a token, so a dry run that called it would create
  the state it claims not to. It also carries an existing `link` section of
  install.json through untouched, so a dry run cannot make a service disappear
  from the record.
- The installed binary's protocol version is checked against the bundle before
  the service is registered. Gate 1 read that number from source at the release
  tag; this is the same check applied to the binary that will actually answer the
  website.
- RUNICGATEWAY_STATE_DIR now relocates the sidecar binary as well, and suppresses
  service registration and the file-permission hardening. There is no such thing
  as a relocated systemd unit, and hardening a scratch config against the only
  account that will ever read it just breaks the next test run.
- A host with no systemd, or where the service user cannot be created, still gets
  a working binary and config plus the exact unit and commands. There is no
  fallback to User=root or LocalSystem: a service quietly running with more
  privilege than its documentation promises is worse than one that was not
  registered.
- install.json never records the token. The `link` section carries versions, the
  binary's hash, the config and database paths, and the service's name, unit path
  and account.

Docs half: docs#91.

Tested: cargo fmt --check, clippy --all-targets -D warnings, 72 tests. End to end
on Windows against a relocated layout — bundle sidecar downloaded and verified,
config provisioned, handoff printed with URLs composed from the host rather than
the bind address, second run reporting unchanged with install.json byte-identical,
--verify over an installed host writing nothing and preserving the link section,
and a tampered binary detected by hash and replaced with no staging file left.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-04 15:40:56 -05:00
parent a4f6b7b756
commit 2228e0848b
7 changed files with 1919 additions and 57 deletions

View File

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