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:
331
src/install.rs
331
src/install.rs
@@ -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(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user