feat(installer): implement Phase 2 — uo-link install and service
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 1m15s

Adds the sidecar half of a deployment to the same `install` run: download and
verify the bundle's binary, provision its config, register and start a service,
and print the token handoff PLAN.md §6 specifies. `src/sidecar.rs` owns the
binary and the config document; `src/service.rs` owns systemd and the Windows
SCM.

The order is fixed by PLAN.md §5 and matters: stop anything running the old
binary, replace it, then `--print-config` (which writes the config the service
will be pointed at), then register. Registering first points a service at a file
that does not exist yet.

Decisions worth a reviewer's attention:

- Both platforms run the sidecar as a dedicated unprivileged identity. Linux gets
  the `runicgateway` system user the plan already specified; Windows gets a
  virtual service account, `sc create ... obj= "NT SERVICE\RunicGatewayLink"`,
  which the SCM creates itself and which has no password. Plain `sc create` runs
  as LocalSystem — the most privileged local identity there is, for a process
  listening on two TCP ports while its Linux twin deliberately does not run as
  root.
- `sidecar.toml` holds the auth token and neither default location protects it:
  /etc is world-readable and %ProgramData% grants Users read by inheritance, so a
  stock install would leave the shard's token readable by any local account. The
  lockdown straddles registration because it has to — on Windows the service
  account does not exist until `sc create` creates it, so the file is first cut
  down to SYSTEM + Administrators, and the account's read grant comes after.
- Only Linux pins UOLINK_DB_PATH. On Windows config and data share a directory
  and the sidecar anchors a relative [store] path to its config's directory, so
  the pin is redundant — and `sc.exe` has no per-service environment, only a
  machine-wide one that every process inherits and that outlives an uninstall.
  The config path rides in the service's own binPath instead.
- `--verify` runs no part of the sidecar half. `--print-config` provisions: it
  writes the config and mints a token, so a dry run that called it would create
  the state it claims not to. It also carries an existing `link` section of
  install.json through untouched, so a dry run cannot make a service disappear
  from the record.
- The installed binary's protocol version is checked against the bundle before
  the service is registered. Gate 1 read that number from source at the release
  tag; this is the same check applied to the binary that will actually answer the
  website.
- RUNICGATEWAY_STATE_DIR now relocates the sidecar binary as well, and suppresses
  service registration and the file-permission hardening. There is no such thing
  as a relocated systemd unit, and hardening a scratch config against the only
  account that will ever read it just breaks the next test run.
- A host with no systemd, or where the service user cannot be created, still gets
  a working binary and config plus the exact unit and commands. There is no
  fallback to User=root or LocalSystem: a service quietly running with more
  privilege than its documentation promises is worse than one that was not
  registered.
- install.json never records the token. The `link` section carries versions, the
  binary's hash, the config and database paths, and the service's name, unit path
  and account.

Docs half: docs#91.

Tested: cargo fmt --check, clippy --all-targets -D warnings, 72 tests. End to end
on Windows against a relocated layout — bundle sidecar downloaded and verified,
config provisioned, handoff printed with URLs composed from the host rather than
the bind address, second run reporting unchanged with install.json byte-identical,
--verify over an installed host writing nothing and preserving the link section,
and a tampered binary detected by hash and replaced with no staging file left.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-04 15:40:56 -05:00
parent a4f6b7b756
commit 2228e0848b
7 changed files with 1919 additions and 57 deletions

858
src/service.rs Normal file
View 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:?}"),
}
}
}