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,10 +1,24 @@
//! The `install` command.
//!
//! Phase 1 of `docs/installer/PLAN.md` — the installer core: resolve the bundle, validate the
//! ServUO root, sync the overlay, record what was deployed. The sidecar and its service (Phase 2)
//! and the patch tier (Phase 3) are not in this build, and the run says so in as many words rather
//! than ending on a success line that would read as a finished install. An operator who cannot tell
//! which half ran is the failure this whole tool exists to remove.
//! Phases 1 and 2 of `docs/installer/PLAN.md`: resolve the bundle, validate the ServUO root, sync
//! the overlay, install the sidecar and register its service, record what was deployed, and print
//! the values the website needs. The patch tier (Phase 3) is not in this build, and the run says so
//! in as many words rather than ending on a success line that would read as a finished install. An
//! operator who cannot tell which half ran is the failure this whole tool exists to remove.
//!
//! The order of the run is not incidental:
//!
//! 1. **Resolve everything that can fail cheaply first** — the bundle, the sidecar asset for this
//! platform, the ServUO root, and whether this process can write where it must. A run that
//! cannot finish should end before a single file enters the ServUO tree.
//! 2. **Overlay, then sidecar.** The sidecar is what the shard dials out to, but the shard is
//! stopped throughout; deploying code the shard will compile is the step with a running-process
//! hazard attached, so it happens while the check that guards it is freshest.
//! 3. **Provision the config before registering the service** (PLAN.md §5): `--print-config` writes
//! the file the service definition points at, so the service is never started against a config
//! that does not exist yet.
//! 4. **Record last, print the handoff after that.** The token block is the final thing on screen
//! because it is the only thing the operator still has to act on.
use std::path::{Path, PathBuf};
@@ -12,11 +26,12 @@ use anyhow::{bail, Context, Result};
use crate::cli::{Cli, PatchChoice};
use crate::record::{
now_rfc3339, BundleRef, InstallRecord, InstallerInfo, OverlayRecord, ServUoRef, SCHEMA,
now_rfc3339, BinaryRef, BundleRef, InstallRecord, InstallerInfo, LinkRecord, OverlayRecord,
ServUoRef, ServiceRecord, SCHEMA,
};
use crate::servuo::ServUoRoot;
use crate::util::TempDir;
use crate::{bundle, net, overlay, paths, servuo, ui};
use crate::{bundle, net, overlay, paths, service, servuo, sidecar, ui};
pub fn run(cli: &Cli) -> Result<()> {
let layout = paths::layout();
@@ -60,7 +75,7 @@ pub fn run(cli: &Cli) -> Result<()> {
ui::row(
"Sidecar",
&format!(
"{:<24} protocol {} (Phase 2 — not installed by this build)",
"{:<24} protocol {}",
format!("uo-link {}", bundle.link.tag),
bundle.link.protocol
),
@@ -77,6 +92,13 @@ pub fn run(cli: &Cli) -> Result<()> {
));
}
// Everything below writes into system directories. Finding out here beats finding out after
// the ServUO tree has been half-deployed into — the ServUO half is the one an operator cannot
// simply re-run their way out of.
if !cli.verify {
preflight_writable(&layout)?;
}
// ── Fetch and unpack the overlay ─────────────────────────────────────────
let scratch = TempDir::new("runicgateway-installer")?;
let tarball = scratch.path().join(&bundle.overlay.asset.name);
@@ -172,7 +194,9 @@ pub fn run(cli: &Cli) -> Result<()> {
// ── What this build does not do ──────────────────────────────────────────
report_patch_tier(cli);
report_sidecar(&bundle, &sidecar_asset, &layout);
// ── The sidecar and its service ──────────────────────────────────────────
let sidecar = install_sidecar(cli, &bundle, &sidecar_asset, &layout, scratch.path())?;
// ── Record ───────────────────────────────────────────────────────────────
let record = build_record(
@@ -182,6 +206,7 @@ pub fn run(cli: &Cli) -> Result<()> {
&root,
&manifest,
&planned,
sidecar.as_ref(),
);
if cli.verify {
@@ -218,14 +243,24 @@ pub fn run(cli: &Cli) -> Result<()> {
} else if cli.verify {
println!("Nothing was written. Re-run without --verify to deploy.");
} else {
println!("Nothing to do — this tree already has this overlay.");
println!("The ServUO tree already has this overlay — nothing was changed there.");
}
if cli.host.is_some() || cli.site_url.is_some() {
// ── The one manual step ──────────────────────────────────────────────────
// Last, and after the record, because it is the only thing left for the operator to do. The
// token goes to the terminal and nowhere else (PLAN.md §6).
if let Some(sidecar) = &sidecar {
let host = resolve_host(cli);
println!(
"\nNote: --host/--site-url are used by the token handoff, which arrives with the \
sidecar in Phase 2. They had no effect on this run."
"{}",
sidecar::handoff(&sidecar.doc, &host, cli.site_url.as_deref())
);
if !sidecar.service.registered() {
ui::warn(
"No service was registered, so nothing is listening yet — the values above \
describe the sidecar\n once you start it. See the steps printed above.",
);
}
}
Ok(())
}
@@ -297,29 +332,240 @@ fn report_patch_tier(cli: &Cli) {
println!(" Without it: no vendor.sale events, no in-game moderation audit forwarding.");
}
fn report_sidecar(bundle: &bundle::Bundle, asset: &bundle::Asset, layout: &paths::Layout) {
println!();
ui::warn(&format!(
"uo-link NOT INSTALLED — the sidecar and its service arrive in Phase 2.\n \
Without it the shard has nothing to dial out to and your website stays offline.\n \
Install it by hand for now — INSTALL.md Appendix A3 and A4:\n \
binary {}\n \
config {}\n \
database {}\n \
download {}\n \
sha256 {}\n \
Then provision and read the token back with:\n \
<binary> --print-config --config {}\n \
The bundle pairs it with overlay {} at protocol {}; keep the two in step.",
layout.sidecar_bin.display(),
layout.sidecar_config().display(),
layout.sidecar_db().display(),
asset.url,
asset.sha256,
layout.sidecar_config().display(),
bundle.overlay.tag,
bundle.protocol,
));
/// The sidecar half of a run: binary, config, service. `None` under `--verify`.
struct SidecarOutcome {
record: LinkRecord,
doc: sidecar::ConfigDoc,
service: service::Outcome,
}
/// Installs the sidecar, provisions its config, and registers its service.
///
/// The step order is the one PLAN.md §5 fixes: stop anything running the old binary, replace it,
/// **then** `--print-config` (which writes the config the service will be pointed at), then
/// register. Doing the last two the other way round registers a service against a file that does
/// not exist yet, which fails in a way that looks like a broken sidecar rather than a sequencing
/// mistake.
fn install_sidecar(
cli: &Cli,
bundle: &bundle::Bundle,
asset: &bundle::Asset,
layout: &paths::Layout,
scratch: &Path,
) -> Result<Option<SidecarOutcome>> {
ui::heading("uo-link sidecar");
let action = sidecar::decide(asset, &layout.sidecar_bin)?;
ui::row(
"binary",
&format!("{} {}", layout.sidecar_bin.display(), action.label()),
);
if cli.verify {
ui::row(
"config",
&format!(
"{} (would be provisioned)",
layout.sidecar_config().display()
),
);
ui::row("database", &layout.sidecar_db().display().to_string());
println!(
"\n VERIFY only. Nothing installed, no service registered, no token read.\n \
The token handoff needs a provisioned config, so it is only printed by a real run."
);
return Ok(None);
}
// Detect the service manager and make sure the account exists *before* a config file is
// written that then has to be owned by it.
let prepared = service::prepare(layout.relocated);
let binary_sha256 = if action.writes() {
// The binary is locked on Windows while its service runs, and on Linux replacing it under a
// live process leaves the old code serving until something restarts it.
service::stop_for_replacement(&prepared.manager)?;
let sha = sidecar::place(asset, &layout.sidecar_bin, scratch)?;
ui::ok(&format!(
"sidecar binary verified sha256 {}",
&asset.sha256[..8.min(asset.sha256.len())]
));
sha
} else {
asset.sha256.trim().to_ascii_lowercase()
};
std::fs::create_dir_all(&layout.data_dir)
.with_context(|| format!("cannot create {}", layout.data_dir.display()))?;
let config_path = layout.sidecar_config();
let db_path = layout.sidecar_db();
// The database is pinned by environment only where it does not already land beside the config.
// See `crate::paths` for why that is a platform difference rather than an inconsistency.
let db_env = (config_path.parent() != db_path.parent()).then_some(db_path.as_path());
let doc = sidecar::print_config(&layout.sidecar_bin, &config_path, db_env)?;
// The installed binary is the only thing that will actually speak to the website, so its
// protocol version is the one that counts. The bundle's gate 1 (PLAN.md §7.1) read this number
// from source at the release tag; a disagreement means the pair in front of us is not the pair
// CI checked, and the sidecar would answer the website with 409 rather than mis-parsing.
if doc.protocol != bundle.protocol {
bail!(
"the installed sidecar reports protocol {} but bundle {} was composed at protocol {}. \
Refusing to register a service for a pair that was never checked together — the \
binary is installed but no service has been created.",
doc.protocol,
bundle.bundle,
bundle.protocol
);
}
if doc.version != bundle.link.version {
ui::warn(&format!(
"the installed binary reports version {} but bundle {} names {}. The checksum matched, \
so this is a labelling mismatch in the release rather than a wrong download.",
doc.version, bundle.bundle, bundle.link.version
));
}
ui::row(
"config",
&format!(
"{} {}",
doc.config_path,
if doc.config_created {
"created"
} else {
"already present"
}
),
);
ui::row("database", &doc.store.path);
ui::row(
"listening on",
&format!("shard {} website {}", doc.shard.bind, doc.web.bind),
);
// The config holds the auth token, and neither default location protects it on its own. This
// runs before registration so the file is never left readable while the rest of the run
// happens; on Windows the service account does not exist until `sc create` creates it, so the
// grant for it comes after.
service::protect_config(
&config_path,
&layout.data_dir,
prepared.user.as_deref(),
layout.relocated,
)?;
let outcome = service::register(&prepared, layout, action.writes())?;
service::grant_service_access(&config_path, &layout.data_dir, &outcome)?;
let service_record = match &outcome {
service::Outcome::Registered {
kind,
name,
unit_path,
user,
user_created,
state,
} => {
ui::row("service", &format!("{name} {state}"));
if let Some(user) = user {
ui::row("running as", user);
}
Some(ServiceRecord {
kind: (*kind).to_string(),
name: name.clone(),
unit_path: unit_path.as_ref().map(|p| p.display().to_string()),
user: user.clone(),
user_created: *user_created,
})
}
service::Outcome::Skipped { reason, manual } => {
println!();
ui::warn(&format!(
"service NOT REGISTERED — {reason}.\n \
The binary and its config are in place; nothing is running them. Do this by hand:"
));
print!("{manual}");
None
}
};
Ok(Some(SidecarOutcome {
record: LinkRecord {
repo: bundle.link.repo.clone(),
tag: bundle.link.tag.clone(),
version: doc.version.clone(),
protocol: doc.protocol,
binary: BinaryRef {
path: layout.sidecar_bin.display().to_string(),
sha256: binary_sha256,
},
config_path: doc.config_path.clone(),
db_path: doc.store.path.clone(),
service: service_record,
},
doc,
service: outcome,
}))
}
/// Fails the run before it writes anything if this process cannot write where it must.
///
/// `create_dir_all` succeeding is not the same question as "can this process write here" — it
/// returns `Ok` for a directory that already exists and is read-only to us — so each location is
/// probed with a real file. The alternative, discovering it three steps later, leaves a ServUO tree
/// that has already been deployed into.
fn preflight_writable(layout: &paths::Layout) -> Result<()> {
let bin_dir = layout
.sidecar_bin
.parent()
.unwrap_or(&layout.sidecar_bin)
.to_path_buf();
for dir in [&layout.state_dir, &layout.data_dir, &bin_dir] {
std::fs::create_dir_all(dir)
.and_then(|_| {
let probe = dir.join(".runicgateway-write-test");
std::fs::write(&probe, b"")?;
std::fs::remove_file(&probe)
})
.with_context(|| {
format!(
"cannot write to {}.\n \
Run this installer as {}, or set {} to a writable directory for a test run \
(no service is registered then).",
dir.display(),
if cfg!(windows) {
"Administrator, from an elevated PowerShell"
} else {
"root (sudo)"
},
paths::STATE_DIR_ENV
)
})?;
}
Ok(())
}
/// The hostname the website should use to reach this machine.
///
/// Only ever printed, never connected to — which is why a non-interactive run falls back to the
/// detected name instead of failing. Getting it wrong costs the operator one edit in Admin → Shard;
/// aborting a completed install over an unanswerable prompt costs them the whole run.
fn resolve_host(cli: &Cli) -> String {
if let Some(host) = &cli.host {
return host.clone();
}
let detected = sysinfo::System::host_name()
.filter(|h| !h.trim().is_empty())
.unwrap_or_else(|| "this-host".to_string());
if cli.assume_yes {
return detected;
}
let answer = ui::prompt(
"Hostname your website should use to reach this machine",
Some(&detected),
);
answer.unwrap_or(detected)
}
fn build_record(
@@ -329,6 +575,7 @@ fn build_record(
root: &ServUoRoot,
manifest: &overlay::Manifest,
planned: &[overlay::PlannedFile],
sidecar: Option<&SidecarOutcome>,
) -> InstallRecord {
InstallRecord {
schema: SCHEMA,
@@ -353,10 +600,14 @@ fn build_record(
protocol: manifest.protocol,
files: overlay::file_records(planned),
}),
// Sections this build does not own are carried through verbatim, so a Phase 1 binary
// re-running on a fully installed host cannot make a service or a set of applied patch
// hunks disappear from the record that documents them.
link: prior.and_then(|p| p.link.clone()),
// A `--verify` run installs no sidecar and must not erase the record of one that is
// already there; the same reasoning keeps the patch section (Phase 3) and any field a
// newer installer wrote intact. A record that forgot a running service would make
// `doctor` and `uninstall` forget it too.
link: match sidecar {
Some(outcome) => serde_json::to_value(&outcome.record).ok(),
None => prior.and_then(|p| p.link.clone()),
},
patches: prior.map(|p| p.patches.clone()).unwrap_or_default(),
extra: prior.map(|p| p.extra.clone()).unwrap_or_default(),
}