feat(installer): implement Phase 2 — uo-link install and service
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 1m15s
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 1m15s
Adds the sidecar half of a deployment to the same `install` run: download and verify the bundle's binary, provision its config, register and start a service, and print the token handoff PLAN.md §6 specifies. `src/sidecar.rs` owns the binary and the config document; `src/service.rs` owns systemd and the Windows SCM. The order is fixed by PLAN.md §5 and matters: stop anything running the old binary, replace it, then `--print-config` (which writes the config the service will be pointed at), then register. Registering first points a service at a file that does not exist yet. Decisions worth a reviewer's attention: - Both platforms run the sidecar as a dedicated unprivileged identity. Linux gets the `runicgateway` system user the plan already specified; Windows gets a virtual service account, `sc create ... obj= "NT SERVICE\RunicGatewayLink"`, which the SCM creates itself and which has no password. Plain `sc create` runs as LocalSystem — the most privileged local identity there is, for a process listening on two TCP ports while its Linux twin deliberately does not run as root. - `sidecar.toml` holds the auth token and neither default location protects it: /etc is world-readable and %ProgramData% grants Users read by inheritance, so a stock install would leave the shard's token readable by any local account. The lockdown straddles registration because it has to — on Windows the service account does not exist until `sc create` creates it, so the file is first cut down to SYSTEM + Administrators, and the account's read grant comes after. - Only Linux pins UOLINK_DB_PATH. On Windows config and data share a directory and the sidecar anchors a relative [store] path to its config's directory, so the pin is redundant — and `sc.exe` has no per-service environment, only a machine-wide one that every process inherits and that outlives an uninstall. The config path rides in the service's own binPath instead. - `--verify` runs no part of the sidecar half. `--print-config` provisions: it writes the config and mints a token, so a dry run that called it would create the state it claims not to. It also carries an existing `link` section of install.json through untouched, so a dry run cannot make a service disappear from the record. - The installed binary's protocol version is checked against the bundle before the service is registered. Gate 1 read that number from source at the release tag; this is the same check applied to the binary that will actually answer the website. - RUNICGATEWAY_STATE_DIR now relocates the sidecar binary as well, and suppresses service registration and the file-permission hardening. There is no such thing as a relocated systemd unit, and hardening a scratch config against the only account that will ever read it just breaks the next test run. - A host with no systemd, or where the service user cannot be created, still gets a working binary and config plus the exact unit and commands. There is no fallback to User=root or LocalSystem: a service quietly running with more privilege than its documentation promises is worse than one that was not registered. - install.json never records the token. The `link` section carries versions, the binary's hash, the config and database paths, and the service's name, unit path and account. Docs half: docs#91. Tested: cargo fmt --check, clippy --all-targets -D warnings, 72 tests. End to end on Windows against a relocated layout — bundle sidecar downloaded and verified, config provisioned, handoff printed with URLs composed from the host rather than the bind address, second run reporting unchanged with install.json byte-identical, --verify over an installed host writing nothing and preserving the link section, and a tampered binary detected by hash and replaced with no staging file left. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
325
src/install.rs
325
src/install.rs
@@ -1,10 +1,24 @@
|
|||||||
//! The `install` command.
|
//! The `install` command.
|
||||||
//!
|
//!
|
||||||
//! Phase 1 of `docs/installer/PLAN.md` — the installer core: resolve the bundle, validate the
|
//! Phases 1 and 2 of `docs/installer/PLAN.md`: resolve the bundle, validate the ServUO root, sync
|
||||||
//! ServUO root, sync the overlay, record what was deployed. The sidecar and its service (Phase 2)
|
//! the overlay, install the sidecar and register its service, record what was deployed, and print
|
||||||
//! and the patch tier (Phase 3) are not in this build, and the run says so in as many words rather
|
//! the values the website needs. The patch tier (Phase 3) is not in this build, and the run says so
|
||||||
//! than ending on a success line that would read as a finished install. An operator who cannot tell
|
//! in as many words rather than ending on a success line that would read as a finished install. An
|
||||||
//! which half ran is the failure this whole tool exists to remove.
|
//! 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};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -12,11 +26,12 @@ use anyhow::{bail, Context, Result};
|
|||||||
|
|
||||||
use crate::cli::{Cli, PatchChoice};
|
use crate::cli::{Cli, PatchChoice};
|
||||||
use crate::record::{
|
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::servuo::ServUoRoot;
|
||||||
use crate::util::TempDir;
|
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<()> {
|
pub fn run(cli: &Cli) -> Result<()> {
|
||||||
let layout = paths::layout();
|
let layout = paths::layout();
|
||||||
@@ -60,7 +75,7 @@ pub fn run(cli: &Cli) -> Result<()> {
|
|||||||
ui::row(
|
ui::row(
|
||||||
"Sidecar",
|
"Sidecar",
|
||||||
&format!(
|
&format!(
|
||||||
"{:<24} protocol {} (Phase 2 — not installed by this build)",
|
"{:<24} protocol {}",
|
||||||
format!("uo-link {}", bundle.link.tag),
|
format!("uo-link {}", bundle.link.tag),
|
||||||
bundle.link.protocol
|
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 ─────────────────────────────────────────
|
// ── Fetch and unpack the overlay ─────────────────────────────────────────
|
||||||
let scratch = TempDir::new("runicgateway-installer")?;
|
let scratch = TempDir::new("runicgateway-installer")?;
|
||||||
let tarball = scratch.path().join(&bundle.overlay.asset.name);
|
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 ──────────────────────────────────────────
|
// ── What this build does not do ──────────────────────────────────────────
|
||||||
report_patch_tier(cli);
|
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 ───────────────────────────────────────────────────────────────
|
// ── Record ───────────────────────────────────────────────────────────────
|
||||||
let record = build_record(
|
let record = build_record(
|
||||||
@@ -182,6 +206,7 @@ pub fn run(cli: &Cli) -> Result<()> {
|
|||||||
&root,
|
&root,
|
||||||
&manifest,
|
&manifest,
|
||||||
&planned,
|
&planned,
|
||||||
|
sidecar.as_ref(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if cli.verify {
|
if cli.verify {
|
||||||
@@ -218,14 +243,24 @@ pub fn run(cli: &Cli) -> Result<()> {
|
|||||||
} else if cli.verify {
|
} else if cli.verify {
|
||||||
println!("Nothing was written. Re-run without --verify to deploy.");
|
println!("Nothing was written. Re-run without --verify to deploy.");
|
||||||
} else {
|
} 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!(
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -297,29 +332,240 @@ fn report_patch_tier(cli: &Cli) {
|
|||||||
println!(" Without it: no vendor.sale events, no in-game moderation audit forwarding.");
|
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) {
|
/// 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!();
|
println!();
|
||||||
ui::warn(&format!(
|
ui::warn(&format!(
|
||||||
"uo-link NOT INSTALLED — the sidecar and its service arrive in Phase 2.\n \
|
"service NOT REGISTERED — {reason}.\n \
|
||||||
Without it the shard has nothing to dial out to and your website stays offline.\n \
|
The binary and its config are in place; nothing is running them. Do this by hand:"
|
||||||
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,
|
|
||||||
));
|
));
|
||||||
|
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(
|
fn build_record(
|
||||||
@@ -329,6 +575,7 @@ fn build_record(
|
|||||||
root: &ServUoRoot,
|
root: &ServUoRoot,
|
||||||
manifest: &overlay::Manifest,
|
manifest: &overlay::Manifest,
|
||||||
planned: &[overlay::PlannedFile],
|
planned: &[overlay::PlannedFile],
|
||||||
|
sidecar: Option<&SidecarOutcome>,
|
||||||
) -> InstallRecord {
|
) -> InstallRecord {
|
||||||
InstallRecord {
|
InstallRecord {
|
||||||
schema: SCHEMA,
|
schema: SCHEMA,
|
||||||
@@ -353,10 +600,14 @@ fn build_record(
|
|||||||
protocol: manifest.protocol,
|
protocol: manifest.protocol,
|
||||||
files: overlay::file_records(planned),
|
files: overlay::file_records(planned),
|
||||||
}),
|
}),
|
||||||
// Sections this build does not own are carried through verbatim, so a Phase 1 binary
|
// A `--verify` run installs no sidecar and must not erase the record of one that is
|
||||||
// re-running on a fully installed host cannot make a service or a set of applied patch
|
// already there; the same reasoning keeps the patch section (Phase 3) and any field a
|
||||||
// hunks disappear from the record that documents them.
|
// newer installer wrote intact. A record that forgot a running service would make
|
||||||
link: prior.and_then(|p| p.link.clone()),
|
// `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(),
|
patches: prior.map(|p| p.patches.clone()).unwrap_or_default(),
|
||||||
extra: prior.map(|p| p.extra.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
|
//! record is `docs/installer/PLAN.md`; the operator-facing contract, written before this binary
|
||||||
//! existed, is `docs/installer/INSTALL.md`.
|
//! existed, is `docs/installer/INSTALL.md`.
|
||||||
//!
|
//!
|
||||||
//! **This build implements Phase 1 (installer core):** bundle resolution, ServUO detection and
|
//! **This build implements Phases 1 and 2:** bundle resolution, ServUO detection and validation,
|
||||||
//! validation, the overlay sync, and `install.json`. The uo-link sidecar and its service (Phase 2),
|
//! 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
|
//! 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.
|
//! 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
|
//! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same
|
||||||
//! convention as the sidecar's CLI.
|
//! convention as the sidecar's CLI.
|
||||||
@@ -33,7 +33,9 @@ pub mod net;
|
|||||||
pub mod overlay;
|
pub mod overlay;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
pub mod record;
|
pub mod record;
|
||||||
|
pub mod service;
|
||||||
pub mod servuo;
|
pub mod servuo;
|
||||||
|
pub mod sidecar;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
pub mod util;
|
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
|
//! 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 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.
|
//! 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
|
//! The installer therefore owns the layout and pins the config path into the service definition.
|
||||||
//! `UOLINK_DB_PATH` into the service definition.
|
|
||||||
//!
|
//!
|
||||||
//! Phase 1 only needs the state directory — `install.json` and the cached patch set — but the whole
|
//! **How the database path is pinned differs by platform, and that is not an inconsistency.** The
|
||||||
//! layout is declared here so Phase 2 and 3 add nothing new to argue about.
|
//! 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::env;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -21,10 +25,13 @@ pub const STATE_DIR_ENV: &str = "RUNICGATEWAY_STATE_DIR";
|
|||||||
pub struct Layout {
|
pub struct Layout {
|
||||||
/// `/etc/runicgateway` — `install.json`, `sidecar.toml`, `patches/`.
|
/// `/etc/runicgateway` — `install.json`, `sidecar.toml`, `patches/`.
|
||||||
pub state_dir: PathBuf,
|
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,
|
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,
|
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 {
|
impl Layout {
|
||||||
@@ -39,20 +46,40 @@ impl Layout {
|
|||||||
pub fn sidecar_db(&self) -> PathBuf {
|
pub fn sidecar_db(&self) -> PathBuf {
|
||||||
self.data_dir.join("uo-link.db")
|
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`].
|
/// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`].
|
||||||
///
|
///
|
||||||
/// The override moves the *state* and *data* directories together. Splitting them under an override
|
/// The override moves **everything the installer would write**: state, data, and the sidecar
|
||||||
/// would make a test run write half its files into the real system location, which is exactly the
|
/// binary. Phase 1 left the binary alone because nothing wrote it; Phase 2 does, and a run that
|
||||||
/// accident the override exists to avoid. The binary path is left alone: nothing in Phase 1 writes
|
/// relocated its config while still dropping a binary into `/usr/bin` would be exactly the
|
||||||
/// it, and a relocated binary would not be what the service definition names.
|
/// 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 {
|
pub fn layout() -> Layout {
|
||||||
let mut layout = platform_layout();
|
let mut layout = platform_layout();
|
||||||
if let Some(dir) = env::var_os(STATE_DIR_ENV).filter(|v| !v.is_empty()) {
|
if let Some(dir) = env::var_os(STATE_DIR_ENV).filter(|v| !v.is_empty()) {
|
||||||
let root = PathBuf::from(dir);
|
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.data_dir = root.join("data");
|
||||||
|
layout.sidecar_bin = root.join("bin").join(bin_name);
|
||||||
layout.state_dir = root;
|
layout.state_dir = root;
|
||||||
|
layout.relocated = true;
|
||||||
}
|
}
|
||||||
layout
|
layout
|
||||||
}
|
}
|
||||||
@@ -76,6 +103,7 @@ fn platform_layout() -> Layout {
|
|||||||
sidecar_bin: program_files
|
sidecar_bin: program_files
|
||||||
.join("RunicGateway")
|
.join("RunicGateway")
|
||||||
.join("uo-link-sidecar.exe"),
|
.join("uo-link-sidecar.exe"),
|
||||||
|
relocated: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +113,7 @@ fn platform_layout() -> Layout {
|
|||||||
state_dir: PathBuf::from("/etc/runicgateway"),
|
state_dir: PathBuf::from("/etc/runicgateway"),
|
||||||
data_dir: PathBuf::from("/var/lib/runicgateway"),
|
data_dir: PathBuf::from("/var/lib/runicgateway"),
|
||||||
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
|
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.state_dir.is_absolute(), "{:?}", l.state_dir);
|
||||||
assert!(l.data_dir.is_absolute(), "{:?}", l.data_dir);
|
assert!(l.data_dir.is_absolute(), "{:?}", l.data_dir);
|
||||||
assert!(l.sidecar_bin.is_absolute(), "{:?}", l.sidecar_bin);
|
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,
|
pub servuo: ServUoRef,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub overlay: Option<OverlayRecord>,
|
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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub link: Option<serde_json::Value>,
|
pub link: Option<serde_json::Value>,
|
||||||
/// Phase 3 (applied patches, with the rung that applied each). Carried through untouched.
|
/// Phase 3 (applied patches, with the rung that applied each). Carried through untouched.
|
||||||
@@ -98,6 +103,53 @@ pub struct FileRecord {
|
|||||||
pub state: String,
|
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 {
|
impl InstallRecord {
|
||||||
/// Whether a re-run would record anything new.
|
/// Whether a re-run would record anything new.
|
||||||
///
|
///
|
||||||
@@ -142,6 +194,15 @@ impl InstallRecord {
|
|||||||
pub fn overlay_files(&self) -> Option<&BTreeMap<String, FileRecord>> {
|
pub fn overlay_files(&self) -> Option<&BTreeMap<String, FileRecord>> {
|
||||||
self.overlay.as_ref().map(|o| &o.files)
|
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 {
|
pub fn now_rfc3339() -> String {
|
||||||
@@ -256,6 +317,48 @@ mod tests {
|
|||||||
assert!(!a.same_deployment_as(&d));
|
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]
|
#[test]
|
||||||
fn timestamps_are_utc_rfc3339() {
|
fn timestamps_are_utc_rfc3339() {
|
||||||
let now = now_rfc3339();
|
let now = now_rfc3339();
|
||||||
|
|||||||
858
src/service.rs
Normal file
858
src/service.rs
Normal file
@@ -0,0 +1,858 @@
|
|||||||
|
//! 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};
|
||||||
|
|
||||||
|
use crate::util::{command_line, 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]).map(|o| o.status.success()) == Ok(true) {
|
||||||
|
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: {}",
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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}");
|
||||||
|
}
|
||||||
|
#[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 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::fs::{self, File};
|
||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Output};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
/// Lower-case hex, written out rather than taken from a crate.
|
/// 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(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -193,6 +267,52 @@ mod tests {
|
|||||||
assert!(!path.exists());
|
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]
|
#[test]
|
||||||
fn atomic_write_replaces_an_existing_file() {
|
fn atomic_write_replaces_an_existing_file() {
|
||||||
let dir = TempDir::new("rg-test-atomic").unwrap();
|
let dir = TempDir::new("rg-test-atomic").unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user