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:
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user