Merge pull request 'feat(installer): implement Phase 2 — uo-link install and service' (#5) from feat/phase2-sidecar-service into edge
Reviewed-on: #5 Reviewed-by: Colby Whitlock <whitlocktech@gmail.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(),
|
||||
}
|
||||
|
||||
10
src/lib.rs
10
src/lib.rs
@@ -4,10 +4,10 @@
|
||||
//! record is `docs/installer/PLAN.md`; the operator-facing contract, written before this binary
|
||||
//! existed, is `docs/installer/INSTALL.md`.
|
||||
//!
|
||||
//! **This build implements Phase 1 (installer core):** bundle resolution, ServUO detection and
|
||||
//! validation, the overlay sync, and `install.json`. The uo-link sidecar and its service (Phase 2),
|
||||
//! the patch tier (Phase 3), and `doctor`/`update`/`uninstall` (Phase 4) are not implemented, and
|
||||
//! every one of them says so when reached rather than failing as though it were a typo.
|
||||
//! **This build implements Phases 1 and 2:** bundle resolution, ServUO detection and validation,
|
||||
//! the overlay sync, `install.json`, the uo-link sidecar and its service, and the token handoff.
|
||||
//! The patch tier (Phase 3) and `doctor`/`update`/`uninstall` (Phase 4) are not implemented, and
|
||||
//! each of them says so when reached rather than failing as though it were a typo.
|
||||
//!
|
||||
//! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same
|
||||
//! convention as the sidecar's CLI.
|
||||
@@ -33,7 +33,9 @@ pub mod net;
|
||||
pub mod overlay;
|
||||
pub mod paths;
|
||||
pub mod record;
|
||||
pub mod service;
|
||||
pub mod servuo;
|
||||
pub mod sidecar;
|
||||
pub mod ui;
|
||||
pub mod util;
|
||||
|
||||
|
||||
83
src/paths.rs
83
src/paths.rs
@@ -3,11 +3,15 @@
|
||||
//! These paths are fixed by `docs/installer/INSTALL.md` §3 and are the installer's side of the
|
||||
//! working-directory trap described in PLAN.md §2.3: the sidecar's own defaults are relative to its
|
||||
//! working directory, and a service manager's working directory is not somewhere to put a database.
|
||||
//! The installer therefore owns the layout and (from Phase 2) pins `UOLINK_CONFIG` and
|
||||
//! `UOLINK_DB_PATH` into the service definition.
|
||||
//! The installer therefore owns the layout and pins the config path into the service definition.
|
||||
//!
|
||||
//! Phase 1 only needs the state directory — `install.json` and the cached patch set — but the whole
|
||||
//! layout is declared here so Phase 2 and 3 add nothing new to argue about.
|
||||
//! **How the database path is pinned differs by platform, and that is not an inconsistency.** The
|
||||
//! sidecar resolves a relative `[store].path` against the directory holding `sidecar.toml`
|
||||
//! (Phase 0.2), so on Windows — where config and data are both `%ProgramData%\RunicGateway` — the
|
||||
//! shipped default already lands exactly where §3 says, and nothing needs to be set. On Linux the
|
||||
//! two directories are deliberately different (`/etc` vs `/var/lib`), so the unit carries
|
||||
//! `UOLINK_DB_PATH`. Setting it on Windows would mean a machine-wide environment variable, which
|
||||
//! every process on the host inherits and which outlives an uninstall.
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
@@ -21,10 +25,13 @@ pub const STATE_DIR_ENV: &str = "RUNICGATEWAY_STATE_DIR";
|
||||
pub struct Layout {
|
||||
/// `/etc/runicgateway` — `install.json`, `sidecar.toml`, `patches/`.
|
||||
pub state_dir: PathBuf,
|
||||
/// `/var/lib/runicgateway` — the sidecar's SQLite store. Phase 2.
|
||||
/// `/var/lib/runicgateway` — the sidecar's SQLite store.
|
||||
pub data_dir: PathBuf,
|
||||
/// `/usr/bin/runicgateway-link` — the installed sidecar binary. Phase 2.
|
||||
/// `/usr/bin/runicgateway-link` — the installed sidecar binary.
|
||||
pub sidecar_bin: PathBuf,
|
||||
/// This layout came from [`STATE_DIR_ENV`], so it describes a test run rather than a real
|
||||
/// deployment. Service registration is skipped when it is set — see [`layout`].
|
||||
pub relocated: bool,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
@@ -39,20 +46,40 @@ impl Layout {
|
||||
pub fn sidecar_db(&self) -> PathBuf {
|
||||
self.data_dir.join("uo-link.db")
|
||||
}
|
||||
|
||||
/// The unit file a systemd host gets. Meaningless elsewhere, and unused under a relocated
|
||||
/// layout, where no service is registered at all.
|
||||
pub fn systemd_unit(&self) -> PathBuf {
|
||||
PathBuf::from("/etc/systemd/system").join(crate::service::SYSTEMD_UNIT)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`].
|
||||
///
|
||||
/// The override moves the *state* and *data* directories together. Splitting them under an override
|
||||
/// would make a test run write half its files into the real system location, which is exactly the
|
||||
/// accident the override exists to avoid. The binary path is left alone: nothing in Phase 1 writes
|
||||
/// it, and a relocated binary would not be what the service definition names.
|
||||
/// The override moves **everything the installer would write**: state, data, and the sidecar
|
||||
/// binary. Phase 1 left the binary alone because nothing wrote it; Phase 2 does, and a run that
|
||||
/// relocated its config while still dropping a binary into `/usr/bin` would be exactly the
|
||||
/// half-in-the-real-system accident this variable exists to avoid.
|
||||
///
|
||||
/// A relocated layout also **suppresses service registration** (see `service::ensure`). There is
|
||||
/// no such thing as a relocated systemd unit or a relocated Windows service — both are
|
||||
/// system-global — so the honest behaviour is to install the files, say plainly that no service was
|
||||
/// registered, and print what a real run would have done.
|
||||
pub fn layout() -> Layout {
|
||||
let mut layout = platform_layout();
|
||||
if let Some(dir) = env::var_os(STATE_DIR_ENV).filter(|v| !v.is_empty()) {
|
||||
let root = PathBuf::from(dir);
|
||||
// The file name is kept so a relocated run installs the same binary name a real one would,
|
||||
// which is what makes `--print-config` and `--version` output comparable between the two.
|
||||
let bin_name = layout
|
||||
.sidecar_bin
|
||||
.file_name()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("uo-link-sidecar"));
|
||||
layout.data_dir = root.join("data");
|
||||
layout.sidecar_bin = root.join("bin").join(bin_name);
|
||||
layout.state_dir = root;
|
||||
layout.relocated = true;
|
||||
}
|
||||
layout
|
||||
}
|
||||
@@ -76,6 +103,7 @@ fn platform_layout() -> Layout {
|
||||
sidecar_bin: program_files
|
||||
.join("RunicGateway")
|
||||
.join("uo-link-sidecar.exe"),
|
||||
relocated: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +113,7 @@ fn platform_layout() -> Layout {
|
||||
state_dir: PathBuf::from("/etc/runicgateway"),
|
||||
data_dir: PathBuf::from("/var/lib/runicgateway"),
|
||||
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
|
||||
relocated: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,5 +138,39 @@ mod tests {
|
||||
assert!(l.state_dir.is_absolute(), "{:?}", l.state_dir);
|
||||
assert!(l.data_dir.is_absolute(), "{:?}", l.data_dir);
|
||||
assert!(l.sidecar_bin.is_absolute(), "{:?}", l.sidecar_bin);
|
||||
assert!(!l.relocated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_windows_database_lands_beside_its_config_by_default() {
|
||||
// The Windows service pins only the config path; the database follows because the sidecar
|
||||
// anchors a relative [store].path to the config's directory. That only holds while these
|
||||
// two directories are the same one, so it is asserted rather than assumed.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let l = platform_layout();
|
||||
assert_eq!(l.sidecar_config().parent(), l.sidecar_db().parent());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relocated_layout_moves_the_binary_too() {
|
||||
// The failure this prevents: a test run that writes its config and database under the
|
||||
// override while still dropping a binary into /usr/bin or %ProgramFiles%.
|
||||
let mut l = platform_layout();
|
||||
let real_bin = l.sidecar_bin.clone();
|
||||
let root = std::env::temp_dir().join("rg-layout-test");
|
||||
let bin_name = l.sidecar_bin.file_name().map(PathBuf::from).unwrap();
|
||||
l.data_dir = root.join("data");
|
||||
l.sidecar_bin = root.join("bin").join(&bin_name);
|
||||
l.state_dir = root.clone();
|
||||
l.relocated = true;
|
||||
|
||||
assert!(l.install_record().starts_with(&root));
|
||||
assert!(l.sidecar_config().starts_with(&root));
|
||||
assert!(l.sidecar_db().starts_with(&root));
|
||||
assert!(l.sidecar_bin.starts_with(&root));
|
||||
assert_ne!(l.sidecar_bin, real_bin);
|
||||
assert_eq!(l.sidecar_bin.file_name(), real_bin.file_name());
|
||||
}
|
||||
}
|
||||
|
||||
105
src/record.rs
105
src/record.rs
@@ -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();
|
||||
|
||||
860
src/service.rs
Normal file
860
src/service.rs
Normal file
@@ -0,0 +1,860 @@
|
||||
//! Registering the sidecar as a service — systemd on Linux, the SCM on Windows.
|
||||
//!
|
||||
//! PLAN.md §5 Phase 2 and §8 question 1. The mechanism is the plainest one that works on a stock
|
||||
//! host: a written unit file and `systemctl`, or `sc.exe`. No WinSW/NSSM shim to ship and keep
|
||||
//! current, and no `--service` mode added to the sidecar — a change to `link` for something the
|
||||
//! platform already does.
|
||||
//!
|
||||
//! ## The two definitions are not symmetrical, on purpose
|
||||
//!
|
||||
//! - **Both run as a dedicated, unprivileged identity.** Linux gets a system user
|
||||
//! (`runicgateway`); Windows gets a *virtual service account* (`NT SERVICE\RunicGatewayLink`),
|
||||
//! which the SCM creates itself, has no password, and exists only for this service. A sidecar
|
||||
//! that listens on two TCP ports has no business running as `LocalSystem` when its Linux twin
|
||||
//! does not run as root.
|
||||
//! - **Only Linux carries `UOLINK_DB_PATH`.** On Windows the config and the database live in the
|
||||
//! same directory and the sidecar already anchors a relative `[store].path` there, so the pin is
|
||||
//! redundant — and the only way to give a Windows service an environment variable through
|
||||
//! `sc.exe` is to set a *machine-wide* one, which every process on the host would inherit and
|
||||
//! which would outlive an uninstall. The config path is passed as `--config` in the service's own
|
||||
//! command line instead, which is scoped to this service by construction.
|
||||
//!
|
||||
//! ## Failure is degradation, not an aborted install
|
||||
//!
|
||||
//! A host with no systemd (openrc, a container, a distro that never had it) or one where the
|
||||
//! service user cannot be created still gets a working binary and a provisioned config. What it
|
||||
//! does not get is a silently weaker service — there is no fallback to `User=root` or to
|
||||
//! `LocalSystem`. It gets the exact unit text and the exact commands, printed, and `install.json`
|
||||
//! records that no service was registered so `doctor` keeps saying so.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
// `command_line` is used only by the Windows registration path, so it is qualified at its call
|
||||
// site rather than imported here — an unconditional import is an unused-import error on Linux.
|
||||
use crate::util::{run, run_ok};
|
||||
|
||||
/// The unit file name, and the systemd service name with its suffix.
|
||||
pub const SYSTEMD_UNIT: &str = "runicgateway-link.service";
|
||||
/// The Windows service key name (INSTALL.md §3).
|
||||
pub const WINDOWS_SERVICE: &str = "RunicGatewayLink";
|
||||
/// The dedicated Linux system user.
|
||||
pub const SERVICE_USER: &str = "runicgateway";
|
||||
/// What both platforms show a human.
|
||||
const DISPLAY_NAME: &str = "Runic Gateway uo-link sidecar";
|
||||
|
||||
/// Which service manager this host has — or why it has none this installer can drive.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Manager {
|
||||
Systemd,
|
||||
WindowsScm,
|
||||
/// No service will be registered. The string is the reason, printed to the operator verbatim.
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Systemd => "systemd",
|
||||
Self::WindowsScm => "windows-scm",
|
||||
Self::Unavailable(_) => "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything decided before the sidecar's config is provisioned: which manager, and which identity
|
||||
/// the service will run as. Split from [`register`] because the identity has to exist *before*
|
||||
/// `--print-config` writes a config file that then has to be owned by it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Prepared {
|
||||
pub manager: Manager,
|
||||
/// The account the service runs as, when this platform names one the installer has to create.
|
||||
/// `None` on Windows, where the SCM creates the virtual account itself as part of registration.
|
||||
pub user: Option<String>,
|
||||
/// This run created that account — recorded so `uninstall` (Phase 4) knows whether removing it
|
||||
/// is its business or somebody else's.
|
||||
pub user_created: bool,
|
||||
}
|
||||
|
||||
/// What registration ended up doing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Outcome {
|
||||
Registered {
|
||||
kind: &'static str,
|
||||
name: String,
|
||||
unit_path: Option<PathBuf>,
|
||||
user: Option<String>,
|
||||
user_created: bool,
|
||||
/// `running, enabled` — read back from the manager, not assumed from the exit codes.
|
||||
state: String,
|
||||
},
|
||||
/// Nothing was registered. `reason` says why in one line; `manual` is the full set of steps.
|
||||
Skipped { reason: String, manual: String },
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
pub fn registered(&self) -> bool {
|
||||
matches!(self, Self::Registered { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// Detects the service manager and makes sure the service identity exists.
|
||||
///
|
||||
/// Never returns `Err`: everything that can go wrong here is a reason to skip service registration
|
||||
/// and say so, not a reason to fail an install whose binary and config are already correct.
|
||||
pub fn prepare(relocated: bool) -> Prepared {
|
||||
if relocated {
|
||||
return unavailable(format!(
|
||||
"{} is set, so this is a test run",
|
||||
crate::paths::STATE_DIR_ENV
|
||||
));
|
||||
}
|
||||
prepare_platform()
|
||||
}
|
||||
|
||||
fn unavailable(reason: String) -> Prepared {
|
||||
Prepared {
|
||||
manager: Manager::Unavailable(reason),
|
||||
user: None,
|
||||
user_created: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Linux ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(unix)]
|
||||
fn prepare_platform() -> Prepared {
|
||||
// The canonical "was this host booted with systemd" test. `systemctl` being on PATH is not the
|
||||
// same question — it is installed in plenty of containers where PID 1 is not systemd, and
|
||||
// `systemctl enable` there fails with a message about the D-Bus socket rather than anything an
|
||||
// operator can act on.
|
||||
if !Path::new("/run/systemd/system").is_dir() {
|
||||
return unavailable(
|
||||
"this host is not running systemd (/run/systemd/system does not exist)".to_string(),
|
||||
);
|
||||
}
|
||||
match ensure_user(SERVICE_USER) {
|
||||
Ok(created) => Prepared {
|
||||
manager: Manager::Systemd,
|
||||
user: Some(SERVICE_USER.to_string()),
|
||||
user_created: created,
|
||||
},
|
||||
// No fallback to User=root. A service that quietly runs with more privilege than its own
|
||||
// documentation promises is worse than one that was not registered.
|
||||
Err(error) => unavailable(format!(
|
||||
"the {SERVICE_USER} service user does not exist and could not be created ({error})"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the dedicated system user if it is not already there. Returns whether it created it.
|
||||
#[cfg(unix)]
|
||||
fn ensure_user(user: &str) -> Result<bool> {
|
||||
if run("id", &["-u", user]).is_ok_and(|o| o.status.success()) {
|
||||
return Ok(false);
|
||||
}
|
||||
// `useradd` is the near-universal spelling; `adduser` is the fallback for Debian's wrapper and
|
||||
// for busybox, whose `adduser` is the only one present on a minimal image.
|
||||
let useradd = run_ok(
|
||||
"useradd",
|
||||
&[
|
||||
"--system",
|
||||
"--no-create-home",
|
||||
"--shell",
|
||||
"/usr/sbin/nologin",
|
||||
user,
|
||||
],
|
||||
);
|
||||
if useradd.is_ok() {
|
||||
return Ok(true);
|
||||
}
|
||||
run_ok("adduser", &["--system", "--no-create-home", user])
|
||||
.map(|_| true)
|
||||
.map_err(|adduser_error| {
|
||||
anyhow::anyhow!(
|
||||
"{}; and {}",
|
||||
useradd.unwrap_err().to_string().replace('\n', " "),
|
||||
adduser_error.to_string().replace('\n', " ")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The unit file. Pure, so its content is a test rather than something only a Linux host can check.
|
||||
///
|
||||
/// Deliberately close to the hand-written unit in INSTALL.md Appendix A4 — an operator who set this
|
||||
/// up by hand and later runs the installer should recognize what replaces their file.
|
||||
pub fn systemd_unit_text(binary: &Path, config: &Path, db: &Path, user: &str) -> String {
|
||||
format!(
|
||||
"# {DISPLAY_NAME}\n\
|
||||
#\n\
|
||||
# Generated by the Runic Gateway installer {installer}. It is rewritten by `install` and\n\
|
||||
# `update` whenever its content changes, so local edits belong in a drop-in instead:\n\
|
||||
# systemctl edit {SYSTEMD_UNIT}\n\
|
||||
\n\
|
||||
[Unit]\n\
|
||||
Description={DISPLAY_NAME}\n\
|
||||
After=network.target\n\
|
||||
\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
User={user}\n\
|
||||
Environment=UOLINK_CONFIG={config}\n\
|
||||
Environment=UOLINK_DB_PATH={db}\n\
|
||||
ExecStart={binary}\n\
|
||||
Restart=on-failure\n\
|
||||
RestartSec=5\n\
|
||||
\n\
|
||||
[Install]\n\
|
||||
WantedBy=multi-user.target\n",
|
||||
installer = env!("CARGO_PKG_VERSION"),
|
||||
config = config.display(),
|
||||
db = db.display(),
|
||||
binary = binary.display(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn register_systemd(
|
||||
prepared: &Prepared,
|
||||
binary: &Path,
|
||||
config: &Path,
|
||||
db: &Path,
|
||||
unit_path: &Path,
|
||||
restart: bool,
|
||||
) -> Result<Outcome> {
|
||||
let user = prepared.user.clone().unwrap_or_else(|| "root".into());
|
||||
let text = systemd_unit_text(binary, config, db, &user);
|
||||
|
||||
// An unchanged unit is not rewritten: daemon-reload is not free, and an mtime that moves on
|
||||
// every run is a change an operator watching /etc has to investigate and then dismiss.
|
||||
let current = std::fs::read_to_string(unit_path).unwrap_or_default();
|
||||
if current != text {
|
||||
crate::util::write_atomic(unit_path, text.as_bytes())
|
||||
.with_context(|| format!("cannot write {}", unit_path.display()))?;
|
||||
run_ok("systemctl", &["daemon-reload"])?;
|
||||
}
|
||||
|
||||
run_ok("systemctl", &["enable", SYSTEMD_UNIT])?;
|
||||
if restart {
|
||||
// The binary underneath a running service has just been replaced; `start` on an already
|
||||
// active unit is a no-op and would leave the old code running.
|
||||
run_ok("systemctl", &["restart", SYSTEMD_UNIT])?;
|
||||
} else {
|
||||
run_ok("systemctl", &["start", SYSTEMD_UNIT])?;
|
||||
}
|
||||
|
||||
Ok(Outcome::Registered {
|
||||
kind: "systemd",
|
||||
name: SYSTEMD_UNIT.to_string(),
|
||||
unit_path: Some(unit_path.to_path_buf()),
|
||||
user: prepared.user.clone(),
|
||||
user_created: prepared.user_created,
|
||||
state: systemd_state(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the unit's state back rather than inferring it from the exit codes above. `systemctl
|
||||
/// start` succeeding and the service still being up a second later are different claims.
|
||||
#[cfg(unix)]
|
||||
fn systemd_state() -> String {
|
||||
let active = one_word(run("systemctl", &["is-active", SYSTEMD_UNIT]));
|
||||
let enabled = one_word(run("systemctl", &["is-enabled", SYSTEMD_UNIT]));
|
||||
format!("{active}, {enabled}")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn one_word(result: Result<std::process::Output>) -> String {
|
||||
result
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
// ── Windows ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(windows)]
|
||||
fn prepare_platform() -> Prepared {
|
||||
// The SCM creates the virtual account as part of `sc create obj= "NT SERVICE\<name>"`, so
|
||||
// there is nothing to provision here — and nothing that can fail before the config exists.
|
||||
Prepared {
|
||||
manager: Manager::WindowsScm,
|
||||
user: None,
|
||||
user_created: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The virtual service account the SCM creates for this service. Locale-independent: the `NT
|
||||
/// SERVICE\<name>` form is a well-known prefix, unlike `BUILTIN\Administrators`, whose display name
|
||||
/// is translated.
|
||||
pub fn windows_service_account() -> String {
|
||||
format!("NT SERVICE\\{WINDOWS_SERVICE}")
|
||||
}
|
||||
|
||||
/// The `binPath=` value: the executable and the `--config` it must always be started with.
|
||||
///
|
||||
/// Pure and tested on both platforms because it is the single string that decides whether an
|
||||
/// installed service reads the config the installer wrote, or whatever `sidecar.toml` happens to be
|
||||
/// in the service's working directory — which, for a Windows service, is `%SystemRoot%\System32`.
|
||||
pub fn windows_bin_path(binary: &Path, config: &Path) -> String {
|
||||
format!("\"{}\" --config \"{}\"", binary.display(), config.display())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outcome> {
|
||||
let bin_path = windows_bin_path(binary, config);
|
||||
let account = windows_service_account();
|
||||
|
||||
if windows_service_exists() {
|
||||
// `config` rather than delete-and-recreate: recreating would drop the failure actions and,
|
||||
// more to the point, would briefly leave a host with no service if the create half failed.
|
||||
run_ok(
|
||||
"sc.exe",
|
||||
&[
|
||||
"config",
|
||||
WINDOWS_SERVICE,
|
||||
"binPath=",
|
||||
&bin_path,
|
||||
"start=",
|
||||
"auto",
|
||||
"obj=",
|
||||
&account,
|
||||
],
|
||||
)?;
|
||||
} else {
|
||||
run_ok(
|
||||
"sc.exe",
|
||||
&[
|
||||
"create",
|
||||
WINDOWS_SERVICE,
|
||||
"binPath=",
|
||||
&bin_path,
|
||||
"start=",
|
||||
"auto",
|
||||
"obj=",
|
||||
&account,
|
||||
"DisplayName=",
|
||||
DISPLAY_NAME,
|
||||
],
|
||||
)
|
||||
.context(
|
||||
"could not register the Windows service. The account is a virtual service account \
|
||||
(no password); if this host's policy forbids them, register the service by hand — \
|
||||
INSTALL.md Appendix A4.",
|
||||
)?;
|
||||
let _ = run("sc.exe", &["description", WINDOWS_SERVICE, DISPLAY_NAME]);
|
||||
}
|
||||
|
||||
// Restart on failure, matching systemd's Restart=on-failure / RestartSec=5. `reset= 86400`
|
||||
// means the failure count goes back to zero after a quiet day, so a service that crashes once
|
||||
// a month keeps being restarted.
|
||||
run_ok(
|
||||
"sc.exe",
|
||||
&[
|
||||
"failure",
|
||||
WINDOWS_SERVICE,
|
||||
"reset=",
|
||||
"86400",
|
||||
"actions=",
|
||||
"restart/5000",
|
||||
],
|
||||
)?;
|
||||
|
||||
if restart && windows_service_state().contains("RUNNING") {
|
||||
stop_windows_service()?;
|
||||
}
|
||||
// 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure.
|
||||
let start = run("sc.exe", &["start", WINDOWS_SERVICE])?;
|
||||
if !start.status.success() && start.status.code() != Some(1056) {
|
||||
anyhow::bail!(
|
||||
"`{}` failed with exit code {}. Check the Windows event log; a service that exits \
|
||||
immediately usually cannot read its config: {}",
|
||||
crate::util::command_line("sc.exe", &["start", WINDOWS_SERVICE]),
|
||||
start.status.code().unwrap_or(-1),
|
||||
config.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Outcome::Registered {
|
||||
kind: "windows-scm",
|
||||
name: WINDOWS_SERVICE.to_string(),
|
||||
unit_path: None,
|
||||
user: Some(account),
|
||||
user_created: false,
|
||||
state: format!("{}, automatic start", windows_service_state()),
|
||||
})
|
||||
}
|
||||
|
||||
/// 1060 is ERROR_SERVICE_DOES_NOT_EXIST. Anything else — including an access-denied — is treated as
|
||||
/// "it exists", so the caller reconfigures rather than trying to create a service that is there.
|
||||
#[cfg(windows)]
|
||||
fn windows_service_exists() -> bool {
|
||||
match run("sc.exe", &["query", WINDOWS_SERVICE]) {
|
||||
Ok(output) => output.status.code() != Some(1060),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_service_state() -> String {
|
||||
let Ok(output) = run("sc.exe", &["query", WINDOWS_SERVICE]) else {
|
||||
return "unknown".to_string();
|
||||
};
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
if line.trim_start().starts_with("STATE") {
|
||||
// " STATE : 4 RUNNING"
|
||||
if let Some(word) = line.split_whitespace().last() {
|
||||
return word.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn stop_windows_service() -> Result<()> {
|
||||
// 1062 is ERROR_SERVICE_NOT_ACTIVE — already the state being asked for.
|
||||
let stop = run("sc.exe", &["stop", WINDOWS_SERVICE])?;
|
||||
if !stop.status.success() && stop.status.code() != Some(1062) {
|
||||
anyhow::bail!(
|
||||
"cannot stop {WINDOWS_SERVICE} (exit code {}). The sidecar binary is locked while the \
|
||||
service runs, so the install cannot replace it.",
|
||||
stop.status.code().unwrap_or(-1)
|
||||
);
|
||||
}
|
||||
// The SCM returns as soon as the stop is *pending*; the file stays locked until the process
|
||||
// actually exits. Polling is the only way to know, and a fixed sleep would be either too short
|
||||
// or a delay on every run.
|
||||
for _ in 0..30 {
|
||||
if windows_service_state() == "STOPPED" {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
anyhow::bail!(
|
||||
"{WINDOWS_SERVICE} did not stop within 15 seconds. Stop it by hand and re-run: \
|
||||
sc.exe stop {WINDOWS_SERVICE}"
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared entry points ──────────────────────────────────────────────────────
|
||||
|
||||
/// Stops a running service so its binary can be replaced.
|
||||
///
|
||||
/// Called only when the installed binary differs from the bundle's. On Windows the file is locked
|
||||
/// while the service runs; on Linux replacing it under a running process is permitted but leaves
|
||||
/// the old code serving until something restarts it, which is a worse outcome than a brief gap.
|
||||
pub fn stop_for_replacement(manager: &Manager) -> Result<()> {
|
||||
match manager {
|
||||
#[cfg(unix)]
|
||||
Manager::Systemd => {
|
||||
// Not `run_ok`: a unit that is not loaded yet (first install) exits non-zero, and that
|
||||
// is the normal case rather than an error.
|
||||
let _ = run("systemctl", &["stop", SYSTEMD_UNIT]);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
Manager::WindowsScm => {
|
||||
if windows_service_exists() {
|
||||
stop_windows_service()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers, enables and starts the service — or explains why it did not.
|
||||
///
|
||||
/// `binary_changed` decides restart versus start: a replaced binary under an already-running
|
||||
/// service must be restarted, or the host keeps running the code that was just replaced.
|
||||
pub fn register(
|
||||
prepared: &Prepared,
|
||||
layout: &crate::paths::Layout,
|
||||
binary_changed: bool,
|
||||
) -> Result<Outcome> {
|
||||
let config = layout.sidecar_config();
|
||||
let db = layout.sidecar_db();
|
||||
|
||||
match &prepared.manager {
|
||||
Manager::Unavailable(reason) => Ok(Outcome::Skipped {
|
||||
reason: reason.clone(),
|
||||
manual: manual_steps(&layout.sidecar_bin, &config, &db, !layout.relocated),
|
||||
}),
|
||||
#[cfg(unix)]
|
||||
Manager::Systemd => register_systemd(
|
||||
prepared,
|
||||
&layout.sidecar_bin,
|
||||
&config,
|
||||
&db,
|
||||
&layout.systemd_unit(),
|
||||
binary_changed,
|
||||
),
|
||||
#[cfg(windows)]
|
||||
Manager::WindowsScm => register_windows(&layout.sidecar_bin, &config, binary_changed),
|
||||
// The manager this build cannot drive because it was compiled for the other platform. Only
|
||||
// reachable if a Manager is constructed by hand; the detector never produces it.
|
||||
#[allow(unreachable_patterns)]
|
||||
other => Ok(Outcome::Skipped {
|
||||
reason: format!("{} is not supported by this build", other.kind()),
|
||||
manual: manual_steps(&layout.sidecar_bin, &config, &db, !layout.relocated),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// What to do by hand when no service was registered. The whole point of degrading rather than
|
||||
/// failing: the operator ends the run with a working binary and the exact commands.
|
||||
///
|
||||
/// `config_protected` says whether the run already locked the config file down. It must not be
|
||||
/// guessed: a relocated run deliberately leaves the permissions alone (see [`protect_config`]), and
|
||||
/// a printed recipe that claims a token file is already protected when it is not is worse than one
|
||||
/// that simply tells the operator to protect it.
|
||||
pub fn manual_steps(binary: &Path, config: &Path, db: &Path, config_protected: bool) -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// `binPath=` is wrapped in *single* quotes, which is what makes this line pasteable into
|
||||
// PowerShell: the value itself contains the double quotes the SCM needs around a path with
|
||||
// spaces, and PowerShell would otherwise eat them. The `sc.exe` convention of a space after
|
||||
// each `=` is not a typo either — the key and the value are separate arguments.
|
||||
format!(
|
||||
" From an elevated PowerShell:\n\n \
|
||||
sc.exe create {WINDOWS_SERVICE} binPath= '{bin_path}' obj= '{account}' start= auto\n \
|
||||
sc.exe failure {WINDOWS_SERVICE} reset= 86400 actions= restart/5000\n \
|
||||
icacls '{config}' /grant '{account}:(R)'\n \
|
||||
icacls '{data_dir}' /grant '{account}:(OI)(CI)M'\n \
|
||||
sc.exe start {WINDOWS_SERVICE}\n\n{token_note}",
|
||||
bin_path = windows_bin_path(binary, config),
|
||||
account = windows_service_account(),
|
||||
config = config.display(),
|
||||
data_dir = db.parent().unwrap_or(db).display(),
|
||||
token_note = if config_protected {
|
||||
" That config file's own permissions have already been restricted to \
|
||||
Administrators\n and SYSTEM, because it holds the auth token. The two grants \
|
||||
above are what the\n service account needs once `sc create` has created it.\n"
|
||||
.to_string()
|
||||
} else {
|
||||
format!(
|
||||
" That config file holds the auth token, and this run did NOT restrict its\n \
|
||||
permissions. Lock it down too:\n\n \
|
||||
icacls '{config}' /inheritance:r /grant:r '*S-1-5-18:(F)' /grant:r \
|
||||
'*S-1-5-32-544:(F)'\n",
|
||||
config = config.display(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// The chown lines are printed whether or not the file has already been chmod'ed: on this
|
||||
// platform `protect_config` restricts the mode but can only hand the file to a user that
|
||||
// exists, and reaching here means one does not.
|
||||
let _ = config_protected;
|
||||
format!(
|
||||
" Write this to /etc/systemd/system/{SYSTEMD_UNIT}:\n\n{unit}\n \
|
||||
Then:\n\n \
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin {SERVICE_USER}\n \
|
||||
chown {SERVICE_USER} {config}\n \
|
||||
chown -R {SERVICE_USER} {data_dir}\n \
|
||||
systemctl daemon-reload\n \
|
||||
systemctl enable --now {SYSTEMD_UNIT}\n\n \
|
||||
On a host without systemd, run the binary under whatever supervisor it does have —\n \
|
||||
the only requirements are that it starts {binary} with UOLINK_CONFIG={config}\n \
|
||||
and UOLINK_DB_PATH={db}, as an unprivileged user that can write the database.\n",
|
||||
unit = indent(&systemd_unit_text(binary, config, db, SERVICE_USER)),
|
||||
config = config.display(),
|
||||
db = db.display(),
|
||||
data_dir = db.parent().unwrap_or(db).display(),
|
||||
binary = binary.display(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn indent(text: &str) -> String {
|
||||
text.lines()
|
||||
.map(|l| format!(" {l}\n"))
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
/// Locks down the files holding the auth token, **before** the service is registered.
|
||||
///
|
||||
/// `sidecar.toml` holds the token, and neither default location protects it on its own: `/etc` is
|
||||
/// world-readable, and `%ProgramData%` grants `Users` read by inheritance. This runs as early as the
|
||||
/// platform allows so the file is never sitting there readable while the rest of the run happens.
|
||||
///
|
||||
/// On Linux that is the whole job — the service user already exists, so the file can be handed to it
|
||||
/// here. On Windows the service account does not exist until `sc create` creates it, so this step
|
||||
/// only shuts everyone else out and [`grant_service_access`] does the rest afterwards.
|
||||
///
|
||||
/// `relocated` is not a courtesy flag. On Windows this replaces the file's ACL with SYSTEM and
|
||||
/// Administrators, which is right for a real install — the installer runs elevated and a service
|
||||
/// account is about to be granted read — and wrong for a relocated test run, where there is no
|
||||
/// service account and the operator is explicitly *not* an administrator. Hardening a scratch file
|
||||
/// against the only person who will ever read it just makes the next test run fail.
|
||||
pub fn protect_config(
|
||||
config: &Path,
|
||||
data_dir: &Path,
|
||||
user: Option<&str>,
|
||||
relocated: bool,
|
||||
) -> Result<()> {
|
||||
protect_config_platform(config, data_dir, user, relocated)
|
||||
}
|
||||
|
||||
/// Grants the registered service account access to what it must read and write.
|
||||
///
|
||||
/// A no-op on Linux, where the account was known before the config existed and `protect_config`
|
||||
/// already handed both to it. On Windows this is the second half of that job, and it can only run
|
||||
/// once the SCM has created the virtual account.
|
||||
pub fn grant_service_access(config: &Path, data_dir: &Path, outcome: &Outcome) -> Result<()> {
|
||||
grant_service_access_platform(config, data_dir, outcome)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn grant_service_access_platform(
|
||||
_config: &Path,
|
||||
_data_dir: &Path,
|
||||
_outcome: &Outcome,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn protect_config_platform(
|
||||
config: &Path,
|
||||
data_dir: &Path,
|
||||
user: Option<&str>,
|
||||
_relocated: bool,
|
||||
) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
std::fs::set_permissions(config, std::fs::Permissions::from_mode(0o600))
|
||||
.with_context(|| format!("cannot restrict permissions on {}", config.display()))?;
|
||||
|
||||
if let Some(user) = user {
|
||||
// `chown` the command rather than a libc call: this crate has no libc dependency, the
|
||||
// operation happens once per run, and a failure here has to be reported with the exact
|
||||
// command anyway. Group is deliberately not set — `useradd --system` creates a matching
|
||||
// group on most distros but not all, and naming one that does not exist fails the chown.
|
||||
run_ok("chown", &[user, &config.to_string_lossy()])?;
|
||||
run_ok("chown", &["-R", user, &data_dir.to_string_lossy()])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn protect_config_platform(
|
||||
config: &Path,
|
||||
_data_dir: &Path,
|
||||
_user: Option<&str>,
|
||||
relocated: bool,
|
||||
) -> Result<()> {
|
||||
if relocated {
|
||||
return Ok(());
|
||||
}
|
||||
// Well-known SIDs, not display names: `Administrators` and `SYSTEM` are localized, and an
|
||||
// icacls line naming them fails on a non-English Windows.
|
||||
// *S-1-5-18 NT AUTHORITY\SYSTEM
|
||||
// *S-1-5-32-544 BUILTIN\Administrators
|
||||
// Removing inheritance is the entire point: %ProgramData% grants `Users` read by inheritance,
|
||||
// and the auth token is in this file.
|
||||
run_ok(
|
||||
"icacls",
|
||||
&[
|
||||
config.to_string_lossy().into_owned(),
|
||||
"/inheritance:r".to_string(),
|
||||
"/grant:r".to_string(),
|
||||
"*S-1-5-18:(F)".to_string(),
|
||||
"/grant:r".to_string(),
|
||||
"*S-1-5-32-544:(F)".to_string(),
|
||||
],
|
||||
)
|
||||
.context("cannot restrict access to the sidecar config, which holds the auth token")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn grant_service_access_platform(config: &Path, data_dir: &Path, outcome: &Outcome) -> Result<()> {
|
||||
// Nothing to grant when nothing was registered: the account only exists because `sc create`
|
||||
// made it, and a skipped registration leaves the config locked to Administrators — which is the
|
||||
// right resting state for a host where no service is going to read it.
|
||||
if !outcome.registered() {
|
||||
return Ok(());
|
||||
}
|
||||
let account = windows_service_account();
|
||||
|
||||
run_ok(
|
||||
"icacls",
|
||||
&[
|
||||
config.to_string_lossy().into_owned(),
|
||||
"/grant".to_string(),
|
||||
format!("{account}:(R)"),
|
||||
],
|
||||
)
|
||||
.context("cannot grant the service account read access to its config")?;
|
||||
|
||||
// The service creates the database — and SQLite's journal and WAL files beside it — so the
|
||||
// grant is on the directory: `(OI)(CI)M` = modify, inherited by files and subdirectories. On
|
||||
// Windows that directory also holds `install.json`, because §3 puts both under
|
||||
// `%ProgramData%\RunicGateway`; the config file is unaffected, since removing its inheritance
|
||||
// above is what stops a directory grant from reaching it.
|
||||
run_ok(
|
||||
"icacls",
|
||||
&[
|
||||
data_dir.to_string_lossy().into_owned(),
|
||||
"/grant".to_string(),
|
||||
format!("{account}:(OI)(CI)M"),
|
||||
],
|
||||
)
|
||||
.context("cannot grant the service account write access to its database directory")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_unit_pins_both_paths_and_an_unprivileged_user() {
|
||||
// Every line here is load-bearing: an unpinned config path resolves against the service's
|
||||
// working directory (PLAN.md §2.3), and User= is the whole reason the installer creates an
|
||||
// account at all.
|
||||
let unit = systemd_unit_text(
|
||||
Path::new("/usr/bin/runicgateway-link"),
|
||||
Path::new("/etc/runicgateway/sidecar.toml"),
|
||||
Path::new("/var/lib/runicgateway/uo-link.db"),
|
||||
SERVICE_USER,
|
||||
);
|
||||
assert!(unit.contains("Environment=UOLINK_CONFIG=/etc/runicgateway/sidecar.toml"));
|
||||
assert!(unit.contains("Environment=UOLINK_DB_PATH=/var/lib/runicgateway/uo-link.db"));
|
||||
assert!(unit.contains("ExecStart=/usr/bin/runicgateway-link"));
|
||||
assert!(unit.contains(&format!("User={SERVICE_USER}")));
|
||||
assert!(!unit.contains("User=root"), "{unit}");
|
||||
assert!(unit.contains("Restart=on-failure"));
|
||||
assert!(unit.contains("WantedBy=multi-user.target"));
|
||||
// The drop-in pointer, so an operator with local changes is not fighting the installer.
|
||||
assert!(unit.contains("systemctl edit"), "{unit}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_windows_bin_path_quotes_both_paths() {
|
||||
// Both default Windows paths contain a space ("Program Files", and any relocated tree can).
|
||||
// An unquoted binPath is the classic Windows service bug: the SCM would try to run
|
||||
// C:\Program.exe with "Files\..." as an argument.
|
||||
let line = windows_bin_path(
|
||||
Path::new(r"C:\Program Files\RunicGateway\uo-link-sidecar.exe"),
|
||||
Path::new(r"C:\ProgramData\RunicGateway\sidecar.toml"),
|
||||
);
|
||||
assert_eq!(
|
||||
line,
|
||||
"\"C:\\Program Files\\RunicGateway\\uo-link-sidecar.exe\" \
|
||||
--config \"C:\\ProgramData\\RunicGateway\\sidecar.toml\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_service_account_is_the_virtual_one() {
|
||||
// Not LocalSystem. The Linux half runs as an unprivileged user and this is its counterpart.
|
||||
assert_eq!(windows_service_account(), "NT SERVICE\\RunicGatewayLink");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relocated_run_registers_nothing() {
|
||||
// There is no such thing as a relocated system service, so a test run must not create one.
|
||||
let prepared = prepare(true);
|
||||
assert!(matches!(prepared.manager, Manager::Unavailable(_)));
|
||||
assert!(prepared.user.is_none());
|
||||
match &prepared.manager {
|
||||
Manager::Unavailable(reason) => {
|
||||
assert!(reason.contains(crate::paths::STATE_DIR_ENV), "{reason}")
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_manual_steps_are_a_complete_recipe() {
|
||||
// This text is all an operator gets on a host the installer cannot drive, so it has to name
|
||||
// the binary, the config and the service — not merely gesture at the documentation.
|
||||
let steps = manual_steps(
|
||||
Path::new("/usr/bin/runicgateway-link"),
|
||||
Path::new("/etc/runicgateway/sidecar.toml"),
|
||||
Path::new("/var/lib/runicgateway/uo-link.db"),
|
||||
true,
|
||||
);
|
||||
assert!(steps.contains("sidecar.toml"), "{steps}");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert!(steps.contains("sc.exe create"), "{steps}");
|
||||
assert!(steps.contains("NT SERVICE\\RunicGatewayLink"), "{steps}");
|
||||
// The binPath value carries its own double quotes, so the argument around it must be
|
||||
// single-quoted or PowerShell strips them and the SCM gets an unquoted path.
|
||||
assert!(steps.contains("binPath= '\""), "{steps}");
|
||||
assert!(!steps.contains("binPath= \"\""), "{steps}");
|
||||
// The write grant belongs to the database directory, which is not always the config's.
|
||||
assert!(steps.contains("/var/lib/runicgateway'"), "{steps}");
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
assert!(steps.contains("systemctl enable --now"), "{steps}");
|
||||
assert!(
|
||||
steps.contains("ExecStart=/usr/bin/runicgateway-link"),
|
||||
"{steps}"
|
||||
);
|
||||
// Non-systemd hosts get the requirements, not just a unit they cannot use.
|
||||
assert!(steps.contains("without systemd"), "{steps}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_recipe_never_claims_a_protection_the_run_did_not_apply() {
|
||||
// A relocated run leaves the token file's permissions alone. Telling the operator it is
|
||||
// already locked down would be the one sentence here that could cost them the token.
|
||||
let args = (
|
||||
Path::new("/usr/bin/runicgateway-link"),
|
||||
Path::new("/etc/runicgateway/sidecar.toml"),
|
||||
Path::new("/var/lib/runicgateway/uo-link.db"),
|
||||
);
|
||||
let protected = manual_steps(args.0, args.1, args.2, true);
|
||||
let unprotected = manual_steps(args.0, args.1, args.2, false);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert_ne!(protected, unprotected);
|
||||
assert!(protected.contains("already been restricted"), "{protected}");
|
||||
assert!(unprotected.contains("did NOT restrict"), "{unprotected}");
|
||||
assert!(unprotected.contains("/inheritance:r"), "{unprotected}");
|
||||
}
|
||||
// On Linux the recipe is the same either way, and correct either way: `protect_config`
|
||||
// restricts the mode but cannot hand the file to a user that does not exist yet, so the
|
||||
// chown lines are needed regardless.
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
assert_eq!(protected, unprotected);
|
||||
assert!(protected.contains("chown runicgateway"), "{protected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_skipped_registration_still_hands_over_the_recipe() {
|
||||
let layout = crate::paths::Layout {
|
||||
state_dir: PathBuf::from("/etc/runicgateway"),
|
||||
data_dir: PathBuf::from("/var/lib/runicgateway"),
|
||||
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
|
||||
relocated: true,
|
||||
};
|
||||
let outcome = register(&prepare(true), &layout, false).unwrap();
|
||||
match outcome {
|
||||
Outcome::Skipped { reason, manual } => {
|
||||
assert!(reason.contains("test run"), "{reason}");
|
||||
assert!(!manual.trim().is_empty());
|
||||
}
|
||||
other => panic!("expected a skip, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
465
src/sidecar.rs
Normal file
465
src/sidecar.rs
Normal file
@@ -0,0 +1,465 @@
|
||||
//! The uo-link sidecar: install the binary, provision its config, read the token back.
|
||||
//!
|
||||
//! This is the half of the deployment that makes the website work at all. The overlay puts code in
|
||||
//! the ServUO tree; nothing reaches a website until a sidecar is listening on `127.0.0.1:7788` for
|
||||
//! the shard to dial out to, and until the website has been given its address, protocol version and
|
||||
//! token (PLAN.md §2.4 calls that missing handoff the largest "I installed it and nothing happened"
|
||||
//! failure mode).
|
||||
//!
|
||||
//! Three rules govern this module:
|
||||
//!
|
||||
//! - **Every value in the handoff comes from asking the installed binary**, via
|
||||
//! `--print-config --config <the pinned path>`. Not from the log, not from re-reading the TOML,
|
||||
//! and not from the installer's own idea of what it wrote. That single call also *provisions* —
|
||||
//! it writes the config file if absent and generates the token if blank — which is why PLAN.md
|
||||
//! §5 requires it to run **before** the service is registered: the service must never start
|
||||
//! against a config that does not exist yet.
|
||||
//! - **The token is printed and never stored.** It goes to the operator's terminal and into
|
||||
//! `sidecar.toml`, and nowhere else — not into `install.json`, not into an error message, not
|
||||
//! into the output of a failed command (PLAN.md §6). That is why `--print-config` is run through
|
||||
//! [`crate::util::run`] and handled here rather than through `run_ok`, which quotes what a
|
||||
//! command printed.
|
||||
//! - **A binary in place is not a working sidecar.** Nothing here claims more than "the file is
|
||||
//! installed and it answered `--print-config`"; whether the shard ever dials in is `doctor`'s
|
||||
//! question (Phase 4).
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::bundle::Asset;
|
||||
use crate::util::{run, sha256_file};
|
||||
|
||||
/// The document `uo-link-sidecar --print-config` prints (`link/sidecar/src/config.rs::describe`).
|
||||
///
|
||||
/// Unknown fields are ignored on purpose: a newer sidecar that adds a key must not break an
|
||||
/// installer that does not know about it, and every field read here has been in the document since
|
||||
/// the CLI was introduced in link v1.1.0.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ConfigDoc {
|
||||
pub component: String,
|
||||
pub version: String,
|
||||
pub protocol: u32,
|
||||
pub config_path: String,
|
||||
/// This run created the config file. False on every re-run — which is how the installer knows
|
||||
/// not to report a token as newly minted when it is simply being read back.
|
||||
pub config_created: bool,
|
||||
pub token_generated: bool,
|
||||
pub shard: ShardDoc,
|
||||
pub web: WebDoc,
|
||||
pub store: StoreDoc,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ShardDoc {
|
||||
pub bind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct WebDoc {
|
||||
pub bind: String,
|
||||
pub ws_path: String,
|
||||
pub auth_required: bool,
|
||||
/// **A secret.** Printed in the handoff block and never recorded anywhere else.
|
||||
pub auth_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct StoreDoc {
|
||||
/// Absolute, already resolved by the sidecar against its config file's directory.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// What installing the binary would do, decided by hash before anything is downloaded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BinaryAction {
|
||||
/// Nothing is installed at the target path yet.
|
||||
Install,
|
||||
/// Something is, and it is not what the bundle names.
|
||||
Replace,
|
||||
/// The bundle's binary is already in place, byte for byte.
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
impl BinaryAction {
|
||||
pub fn writes(self) -> bool {
|
||||
!matches!(self, Self::Unchanged)
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Install => "install",
|
||||
Self::Replace => "replace",
|
||||
Self::Unchanged => "unchanged",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares what is installed against what the bundle names.
|
||||
///
|
||||
/// Hash rather than version string: the version an installed binary reports costs a process launch
|
||||
/// to obtain and would still not distinguish two builds of the same version. The bundle records the
|
||||
/// SHA256 CI computed from the asset it verified (PLAN.md §7.1 gate 2), so this comparison is
|
||||
/// against the same value the download will be checked against.
|
||||
pub fn decide(asset: &Asset, dest: &Path) -> Result<BinaryAction> {
|
||||
if !dest.exists() {
|
||||
return Ok(BinaryAction::Install);
|
||||
}
|
||||
let installed = sha256_file(dest)?;
|
||||
if installed.eq_ignore_ascii_case(asset.sha256.trim()) {
|
||||
Ok(BinaryAction::Unchanged)
|
||||
} else {
|
||||
Ok(BinaryAction::Replace)
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads the sidecar binary and puts it at `dest`, executable.
|
||||
///
|
||||
/// The download lands in the scratch directory and is checksum-verified there, then copied to a
|
||||
/// `.new` sibling of the target and renamed over it. The two-step matters on both platforms for
|
||||
/// different reasons: on Windows the target is locked while the service runs (the caller stops it
|
||||
/// first), and on either, a copy interrupted halfway would otherwise leave a truncated binary at
|
||||
/// exactly the path a service is about to execute.
|
||||
pub fn place(asset: &Asset, dest: &Path, scratch: &Path) -> Result<String> {
|
||||
let staged = scratch.join(&asset.name);
|
||||
crate::net::download_verified(&asset.url, &staged, &asset.sha256)?;
|
||||
|
||||
let parent = dest
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("{} has no parent directory", dest.display()))?;
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"cannot create {} — run as root/Administrator",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let pending = pending_path(dest);
|
||||
fs::copy(&staged, &pending).with_context(|| format!("cannot write {}", pending.display()))?;
|
||||
set_executable(&pending)?;
|
||||
|
||||
// Windows will not rename onto an existing file. The old binary goes first; the replacement is
|
||||
// already complete on disk by this point, so the window is a rename wide.
|
||||
if dest.exists() {
|
||||
fs::remove_file(dest).with_context(|| {
|
||||
format!(
|
||||
"cannot replace {} — if a service is running it, stop it first",
|
||||
dest.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::rename(&pending, dest)
|
||||
.with_context(|| format!("cannot move {} into place", pending.display()))?;
|
||||
|
||||
sha256_file(dest)
|
||||
}
|
||||
|
||||
/// `uo-link-sidecar.exe` → `uo-link-sidecar.new`, in the same directory as the target.
|
||||
///
|
||||
/// Same directory so the final step is a rename rather than a cross-filesystem copy: `/tmp` and
|
||||
/// `/usr/bin` are routinely different mounts, and a rename between them fails.
|
||||
fn pending_path(dest: &Path) -> PathBuf {
|
||||
let mut name = dest.file_name().unwrap_or_default().to_os_string();
|
||||
name.push(".new");
|
||||
dest.with_file_name(name)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_executable(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
|
||||
.with_context(|| format!("cannot make {} executable", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_executable(_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs the installed binary's `--print-config`, provisioning the config and returning the token.
|
||||
///
|
||||
/// `db_path` is passed as `UOLINK_DB_PATH` **only when it is not already where the sidecar would
|
||||
/// put it** — that is, on Linux, where the config lives in `/etc` and the database in `/var/lib`.
|
||||
/// On Windows both are `%ProgramData%\RunicGateway`, the sidecar anchors a relative `[store].path`
|
||||
/// to its config's directory, and passing the variable would buy nothing while implying the service
|
||||
/// needs a machine-wide environment variable it does not (see [`crate::paths`]).
|
||||
///
|
||||
/// **This function must not print, log or attach the child's stdout to an error.** It is the one
|
||||
/// place in the installer where a secret crosses a process boundary.
|
||||
pub fn print_config(binary: &Path, config: &Path, db_path: Option<&Path>) -> Result<ConfigDoc> {
|
||||
let mut command = std::process::Command::new(binary);
|
||||
command.arg("--print-config").arg("--config").arg(config);
|
||||
if let Some(db) = db_path {
|
||||
command.env("UOLINK_DB_PATH", db);
|
||||
}
|
||||
|
||||
let output = command.output().with_context(|| {
|
||||
format!(
|
||||
"cannot run {} --print-config. The binary was just installed, so this usually means it \
|
||||
cannot execute here — a 32/64-bit or libc mismatch, or a filesystem mounted noexec.",
|
||||
binary.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
// stderr only. stdout is the document, and the document contains the auth token.
|
||||
let reason = String::from_utf8_lossy(&output.stderr)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.unwrap_or("(nothing on stderr)")
|
||||
.to_string();
|
||||
bail!(
|
||||
"{} --print-config --config {} failed with {}: {reason}",
|
||||
binary.display(),
|
||||
config.display(),
|
||||
match output.status.code() {
|
||||
Some(code) => format!("exit code {code}"),
|
||||
None => "no exit code".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let doc: ConfigDoc = serde_json::from_slice(&output.stdout).context(
|
||||
"the sidecar's --print-config output is not the document this installer expects. \
|
||||
Its contents are not shown here because they would contain the auth token; run the same \
|
||||
command by hand to see it (INSTALL.md Appendix A3).",
|
||||
)?;
|
||||
|
||||
if doc.component != "uo-link-sidecar" {
|
||||
bail!(
|
||||
"the binary at {} identifies itself as {:?}, not uo-link-sidecar",
|
||||
binary.display(),
|
||||
doc.component
|
||||
);
|
||||
}
|
||||
if doc.web.auth_token.trim().is_empty() {
|
||||
// Authentication is always on in the sidecar, so this cannot happen against a real one —
|
||||
// and if it ever did, an unauthenticated web surface must not be reported as a success.
|
||||
bail!(
|
||||
"the sidecar reported an empty auth token from {}. Authentication is always on; refusing \
|
||||
to continue with a config that would leave its web surface unauthenticated.",
|
||||
doc.config_path
|
||||
);
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Asks an installed binary for its version line, `uo-link-sidecar <ver> (protocol <n>)`.
|
||||
///
|
||||
/// Used to report what is already installed on a run that installs nothing. It is deliberately
|
||||
/// tolerant — a binary that cannot answer is described as unknown rather than failing a run whose
|
||||
/// real work has already succeeded.
|
||||
pub fn version_line(binary: &Path) -> Option<String> {
|
||||
let output = run(&binary.to_string_lossy(), &["--version"]).ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
.map(|l| l.trim().to_string())
|
||||
}
|
||||
|
||||
/// The two URLs the website needs, composed from the sidecar's own answers plus a host.
|
||||
///
|
||||
/// The bind address is **not** echoed: `[web] bind` is `127.0.0.1` by default and frequently
|
||||
/// `0.0.0.0`, and neither is something to hand to a website (PLAN.md §6). Only the port is taken
|
||||
/// from it; the host is the one the operator named or the installer detected.
|
||||
pub fn website_urls(doc: &ConfigDoc, host: &str) -> (String, String) {
|
||||
let port = port_of(&doc.web.bind);
|
||||
let ws_path = if doc.web.ws_path.starts_with('/') {
|
||||
doc.web.ws_path.clone()
|
||||
} else {
|
||||
format!("/{}", doc.web.ws_path)
|
||||
};
|
||||
(
|
||||
format!("http://{host}:{port}"),
|
||||
format!("ws://{host}:{port}{ws_path}"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The port half of a bind address.
|
||||
///
|
||||
/// Split on the **last** colon so an IPv6 bind (`[::]:8080`) yields `8080` rather than a fragment
|
||||
/// of the address. A bind with no port at all is not something the sidecar produces, so the whole
|
||||
/// string is handed back rather than guessing a default that would then be wrong everywhere it was
|
||||
/// printed.
|
||||
fn port_of(bind: &str) -> &str {
|
||||
match bind.rsplit_once(':') {
|
||||
Some((_, port)) if !port.is_empty() => port,
|
||||
_ => bind,
|
||||
}
|
||||
}
|
||||
|
||||
/// The end-of-run block from PLAN.md §6 — the one manual step the installer cannot do.
|
||||
///
|
||||
/// Returned as a string rather than printed so it can be tested, and so the caller decides where it
|
||||
/// goes. It goes to stdout. It never goes to a file.
|
||||
pub fn handoff(doc: &ConfigDoc, host: &str, site_url: Option<&str>) -> String {
|
||||
let (base_url, ws_url) = website_urls(doc, host);
|
||||
let site = site_url
|
||||
.map(|s| s.trim_end_matches('/').to_string())
|
||||
.unwrap_or_else(|| "https://<your-site>".to_string());
|
||||
|
||||
format!(
|
||||
"\nRunic Gateway is installed.\n\n\
|
||||
One manual step remains — connect the website to this sidecar:\n\n \
|
||||
Base URL {base_url}\n \
|
||||
WebSocket URL {ws_url}\n \
|
||||
Protocol version {protocol}\n \
|
||||
Auth token {token}\n \
|
||||
(also in {config})\n\n\
|
||||
Paste these into Admin → Shard on your Runic Gateway site:\n \
|
||||
{site}/admin/shard\n\n\
|
||||
The token is write-only once saved — the site will never show it back to you.\n",
|
||||
protocol = doc.protocol,
|
||||
token = doc.web.auth_token,
|
||||
config = doc.config_path,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::TempDir;
|
||||
|
||||
/// The document link v1.1.0 actually prints, copied from INSTALL.md Appendix A3.
|
||||
const PRINT_CONFIG: &str = r#"{
|
||||
"component": "uo-link-sidecar",
|
||||
"version": "1.1.0",
|
||||
"protocol": 3,
|
||||
"config_path": "/etc/runicgateway/sidecar.toml",
|
||||
"config_created": true,
|
||||
"token_generated": true,
|
||||
"shard": { "bind": "127.0.0.1:7788" },
|
||||
"web": {
|
||||
"bind": "127.0.0.1:8080",
|
||||
"ws_path": "/ws",
|
||||
"auth_required": true,
|
||||
"auth_token": "4f9c00112233445566778899aabbccdd"
|
||||
},
|
||||
"store": { "path": "/var/lib/runicgateway/uo-link.db" }
|
||||
}"#;
|
||||
|
||||
fn doc() -> ConfigDoc {
|
||||
serde_json::from_str(PRINT_CONFIG).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_documented_print_config_output_parses() {
|
||||
let doc = doc();
|
||||
assert_eq!(doc.version, "1.1.0");
|
||||
assert_eq!(doc.protocol, 3);
|
||||
assert!(doc.web.auth_required);
|
||||
assert_eq!(doc.store.path, "/var/lib/runicgateway/uo-link.db");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_sidecar_adding_fields_still_parses() {
|
||||
// The sidecar and the installer version independently; a key added to the document must not
|
||||
// strand an installed installer.
|
||||
let body = PRINT_CONFIG.replace(
|
||||
"\"protocol\": 3,",
|
||||
"\"protocol\": 3, \"something_new\": { \"nested\": true },",
|
||||
);
|
||||
assert!(serde_json::from_str::<ConfigDoc>(&body).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_website_urls_use_the_host_not_the_bind_address() {
|
||||
// The whole point of asking for a host: 127.0.0.1 and 0.0.0.0 are both useless to a website.
|
||||
let mut d = doc();
|
||||
let (base, ws) = website_urls(&d, "shard.example.com");
|
||||
assert_eq!(base, "http://shard.example.com:8080");
|
||||
assert_eq!(ws, "ws://shard.example.com:8080/ws");
|
||||
|
||||
d.web.bind = "0.0.0.0:9001".into();
|
||||
let (base, ws) = website_urls(&d, "shard.example.com");
|
||||
assert_eq!(base, "http://shard.example.com:9001");
|
||||
assert_eq!(ws, "ws://shard.example.com:9001/ws");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ipv6_bind_yields_its_port() {
|
||||
// Splitting on the first colon would produce "http://host::" from "[::]:8080".
|
||||
assert_eq!(port_of("[::]:8080"), "8080");
|
||||
assert_eq!(port_of("[::1]:7788"), "7788");
|
||||
assert_eq!(port_of("127.0.0.1:8080"), "8080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handoff_carries_every_value_the_admin_form_asks_for() {
|
||||
// INSTALL.md §5 maps four fields; all four must be in the block, plus where to paste them.
|
||||
let doc = doc();
|
||||
let block = handoff(&doc, "shard.example.com", Some("https://my-site.example/"));
|
||||
assert!(block.contains("http://shard.example.com:8080"), "{block}");
|
||||
assert!(block.contains("ws://shard.example.com:8080/ws"), "{block}");
|
||||
assert!(block.contains("Protocol version 3"), "{block}");
|
||||
assert!(block.contains(&doc.web.auth_token), "{block}");
|
||||
// The trailing slash on the site URL must not produce a double slash in the link.
|
||||
assert!(
|
||||
block.contains("https://my-site.example/admin/shard"),
|
||||
"{block}"
|
||||
);
|
||||
assert!(block.contains("/etc/runicgateway/sidecar.toml"), "{block}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handoff_still_works_without_a_site_url() {
|
||||
// An unattended run has nobody to ask, and the token is far too useful to withhold over a
|
||||
// link the operator does not need.
|
||||
let block = handoff(&doc(), "shard", None);
|
||||
assert!(block.contains("https://<your-site>/admin/shard"), "{block}");
|
||||
assert!(block.contains("4f9c"), "{block}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installed_binary_matching_the_bundle_is_left_alone() {
|
||||
let dir = TempDir::new("rg-test-sidecar").unwrap();
|
||||
let dest = dir.path().join("uo-link-sidecar");
|
||||
assert_eq!(
|
||||
decide(&asset("0".repeat(64)), &dest).unwrap(),
|
||||
BinaryAction::Install
|
||||
);
|
||||
|
||||
fs::write(&dest, b"pretend binary").unwrap();
|
||||
let installed = sha256_file(&dest).unwrap();
|
||||
assert_eq!(
|
||||
decide(&asset(installed.clone()), &dest).unwrap(),
|
||||
BinaryAction::Unchanged
|
||||
);
|
||||
// Hex case must not decide whether a host reinstalls its sidecar on every run.
|
||||
assert_eq!(
|
||||
decide(&asset(installed.to_uppercase()), &dest).unwrap(),
|
||||
BinaryAction::Unchanged
|
||||
);
|
||||
assert_eq!(
|
||||
decide(&asset("a".repeat(64)), &dest).unwrap(),
|
||||
BinaryAction::Replace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_staging_file_sits_beside_its_target() {
|
||||
// /tmp and /usr/bin are routinely different filesystems, and rename across them fails.
|
||||
let dest = Path::new("/usr/bin/runicgateway-link");
|
||||
assert_eq!(pending_path(dest).parent(), dest.parent());
|
||||
assert_eq!(
|
||||
pending_path(Path::new("/usr/bin/x.exe"))
|
||||
.file_name()
|
||||
.unwrap(),
|
||||
"x.exe.new"
|
||||
);
|
||||
}
|
||||
|
||||
fn asset(sha256: String) -> Asset {
|
||||
Asset {
|
||||
name: "uo-link-sidecar-linux-x86_64".into(),
|
||||
url: "https://example/uo-link-sidecar".into(),
|
||||
sha256,
|
||||
}
|
||||
}
|
||||
}
|
||||
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