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:
124
src/util.rs
124
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<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();
|
||||
|
||||
Reference in New Issue
Block a user