All checks were successful
PR Checks / rust-gates (pull_request) Successful in 59s
Completes the command surface INSTALL.md §2 published before the binary
existed. With this, `edge` cuts a binary that does everything that guide
describes.
doctor (src/doctor.rs)
Reads only. Every row is answered by asking the thing itself — the
installed binary (--version, --print-config), the service manager, and
the sidecar's /health — because the record says what `install` did,
which is a different question from what is true now. --print-config is
run ONLY when the config already exists: that flag provisions, and a
diagnosis must not create the state it reports on. It is also run under
the environment the service pins (UOLINK_DB_PATH), so the config and
database it names are the ones the service opens, not the ones the
binary would pick on its own.
Exit 1 when any row failed, so a monitoring script can read it; a ⚠
never does that. A stopped shard is therefore a ⚠, not a ✗ — "you have
not started it" and "it is running and the bridge is dead" are
different problems and only the second is broken. Offline is a ⚠ too:
a shard host with no route to Gitea is a supported way to run this.
The patch row re-resolves each recorded patch against the tree from the
cached .patch, so a core upgrade or a restored backup that silently
removed the tier's edits is caught — nothing else here would notice.
update (src/update.rs, install.rs::Mode)
The same pipeline as install, not a second one: PLAN.md describes it as
"re-resolve the bundle, then move both components to it", which is what
an install over an existing deployment already does. Writing it twice
would give the sync rules and the protocol cross-checks two places to
disagree. What differs is small and lives in Mode — a prior record is
required, the tree comes from that record rather than detection, the
patch tier's scope narrows, and the close is a diff instead of a
handoff.
The token is not reprinted: it has not changed and the website has it.
A changed protocol number IS called out, because a stale value in
Admin → Shard is answered with 409 and looks like the shard going
offline.
Tier scope: features an earlier run recorded are re-resolved without
asking again (the record is the evidence of consent, including on an
unsupported ServUO); anything new the release offers is named but not
applied without --patches. A shard that declined stays declined.
uninstall (src/uninstall.rs, service::remove)
Removes the binary, the service and install.json; prints the overlay
files and the exact hunks, rendered from the cached patches with the
rung each landed at. Files edited since deployment are flagged so
nobody deletes their own work blind. The report is also written to a
file in the working directory — it is the only thing still needed after
the command exits, and it arrives at the end of the longest output this
tool produces.
Two deviations from PLAN.md §5, both deliberate:
- The cached patch set and patches/originals/ SURVIVE. That table put
them under "removed", but the report tells the operator to diff
against those originals — advice the same command would have made
impossible to follow. --purge removes them, with the config and the
database.
- --yes means yes here, not "take the default". The prompt defaults to
no (destructive), but the operator typed the verb; reading --yes as
"no" would leave an unattended uninstall unable to express itself,
and a script that appears to succeed while removing nothing is the
worse failure.
Exit 1 if a step could not be carried out — everything else still was.
Verified on this machine against a scratch tree built from the real
ServUO 57.4 files: a healthy doctor (exit 0), one with a deleted overlay
file, an edited one and a reverted patch (all three found, exit 1), a
--verify update that wrote nothing, a real update that repaired all three
and left install.json byte-identical, uninstall with and without --purge,
a second uninstall, and doctor/update on a host with no record. Linux
fmt/clippy/tests run in Docker as well as the Windows host.
Co-Authored-By: Claude <noreply@anthropic.com>
1077 lines
44 KiB
Rust
1077 lines
44 KiB
Rust
//! 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::record::ServiceRecord;
|
|
// `command_line` is used only by the Windows registration path, so it is qualified at its call
|
|
// site rather than imported here — an unconditional import is an unused-import error on Linux.
|
|
use crate::util::{run, run_ok};
|
|
|
|
/// The unit file name, and the systemd service name with its suffix.
|
|
pub const SYSTEMD_UNIT: &str = "runicgateway-link.service";
|
|
/// The Windows service key name (INSTALL.md §3).
|
|
pub const WINDOWS_SERVICE: &str = "RunicGatewayLink";
|
|
/// The dedicated Linux system user.
|
|
pub const SERVICE_USER: &str = "runicgateway";
|
|
/// What both platforms show a human.
|
|
const DISPLAY_NAME: &str = "Runic Gateway uo-link sidecar";
|
|
|
|
/// Which service manager this host has — or why it has none this installer can drive.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Manager {
|
|
Systemd,
|
|
WindowsScm,
|
|
/// No service will be registered. The string is the reason, printed to the operator verbatim.
|
|
Unavailable(String),
|
|
}
|
|
|
|
impl Manager {
|
|
pub fn kind(&self) -> &'static str {
|
|
match self {
|
|
Self::Systemd => "systemd",
|
|
Self::WindowsScm => "windows-scm",
|
|
Self::Unavailable(_) => "none",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Everything decided before the sidecar's config is provisioned: which manager, and which identity
|
|
/// the service will run as. Split from [`register`] because the identity has to exist *before*
|
|
/// `--print-config` writes a config file that then has to be owned by it.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Prepared {
|
|
pub manager: Manager,
|
|
/// The account the service runs as, when this platform names one the installer has to create.
|
|
/// `None` on Windows, where the SCM creates the virtual account itself as part of registration.
|
|
pub user: Option<String>,
|
|
/// This run created that account — recorded so `uninstall` (Phase 4) knows whether removing it
|
|
/// is its business or somebody else's.
|
|
pub user_created: bool,
|
|
}
|
|
|
|
/// What registration ended up doing.
|
|
#[derive(Debug, Clone)]
|
|
pub enum Outcome {
|
|
Registered {
|
|
kind: &'static str,
|
|
name: String,
|
|
unit_path: Option<PathBuf>,
|
|
user: Option<String>,
|
|
user_created: bool,
|
|
/// `running, enabled` — read back from the manager, not assumed from the exit codes.
|
|
state: String,
|
|
},
|
|
/// Nothing was registered. `reason` says why in one line; `manual` is the full set of steps.
|
|
Skipped { reason: String, manual: String },
|
|
}
|
|
|
|
impl Outcome {
|
|
pub fn registered(&self) -> bool {
|
|
matches!(self, Self::Registered { .. })
|
|
}
|
|
}
|
|
|
|
/// Detects the service manager and makes sure the service identity exists.
|
|
///
|
|
/// Never returns `Err`: everything that can go wrong here is a reason to skip service registration
|
|
/// and say so, not a reason to fail an install whose binary and config are already correct.
|
|
pub fn prepare(relocated: bool) -> Prepared {
|
|
if relocated {
|
|
return unavailable(format!(
|
|
"{} is set, so this is a test run",
|
|
crate::paths::STATE_DIR_ENV
|
|
));
|
|
}
|
|
prepare_platform()
|
|
}
|
|
|
|
fn unavailable(reason: String) -> Prepared {
|
|
Prepared {
|
|
manager: Manager::Unavailable(reason),
|
|
user: None,
|
|
user_created: false,
|
|
}
|
|
}
|
|
|
|
// ── Linux ────────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(unix)]
|
|
fn prepare_platform() -> Prepared {
|
|
// The canonical "was this host booted with systemd" test. `systemctl` being on PATH is not the
|
|
// same question — it is installed in plenty of containers where PID 1 is not systemd, and
|
|
// `systemctl enable` there fails with a message about the D-Bus socket rather than anything an
|
|
// operator can act on.
|
|
if !Path::new("/run/systemd/system").is_dir() {
|
|
return unavailable(
|
|
"this host is not running systemd (/run/systemd/system does not exist)".to_string(),
|
|
);
|
|
}
|
|
match ensure_user(SERVICE_USER) {
|
|
Ok(created) => Prepared {
|
|
manager: Manager::Systemd,
|
|
user: Some(SERVICE_USER.to_string()),
|
|
user_created: created,
|
|
},
|
|
// No fallback to User=root. A service that quietly runs with more privilege than its own
|
|
// documentation promises is worse than one that was not registered.
|
|
Err(error) => unavailable(format!(
|
|
"the {SERVICE_USER} service user does not exist and could not be created ({error})"
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Creates the dedicated system user if it is not already there. Returns whether it created it.
|
|
#[cfg(unix)]
|
|
fn ensure_user(user: &str) -> Result<bool> {
|
|
if run("id", &["-u", user]).is_ok_and(|o| o.status.success()) {
|
|
return Ok(false);
|
|
}
|
|
// `useradd` is the near-universal spelling; `adduser` is the fallback for Debian's wrapper and
|
|
// for busybox, whose `adduser` is the only one present on a minimal image.
|
|
let useradd = run_ok(
|
|
"useradd",
|
|
&[
|
|
"--system",
|
|
"--no-create-home",
|
|
"--shell",
|
|
"/usr/sbin/nologin",
|
|
user,
|
|
],
|
|
);
|
|
if useradd.is_ok() {
|
|
return Ok(true);
|
|
}
|
|
run_ok("adduser", &["--system", "--no-create-home", user])
|
|
.map(|_| true)
|
|
.map_err(|adduser_error| {
|
|
anyhow::anyhow!(
|
|
"{}; and {}",
|
|
useradd.unwrap_err().to_string().replace('\n', " "),
|
|
adduser_error.to_string().replace('\n', " ")
|
|
)
|
|
})
|
|
}
|
|
|
|
/// The unit file. Pure, so its content is a test rather than something only a Linux host can check.
|
|
///
|
|
/// Deliberately close to the hand-written unit in INSTALL.md Appendix A4 — an operator who set this
|
|
/// up by hand and later runs the installer should recognize what replaces their file.
|
|
pub fn systemd_unit_text(binary: &Path, config: &Path, db: &Path, user: &str) -> String {
|
|
format!(
|
|
"# {DISPLAY_NAME}\n\
|
|
#\n\
|
|
# Generated by the Runic Gateway installer {installer}. It is rewritten by `install` and\n\
|
|
# `update` whenever its content changes, so local edits belong in a drop-in instead:\n\
|
|
# systemctl edit {SYSTEMD_UNIT}\n\
|
|
\n\
|
|
[Unit]\n\
|
|
Description={DISPLAY_NAME}\n\
|
|
After=network.target\n\
|
|
\n\
|
|
[Service]\n\
|
|
Type=simple\n\
|
|
User={user}\n\
|
|
Environment=UOLINK_CONFIG={config}\n\
|
|
Environment=UOLINK_DB_PATH={db}\n\
|
|
ExecStart={binary}\n\
|
|
Restart=on-failure\n\
|
|
RestartSec=5\n\
|
|
\n\
|
|
[Install]\n\
|
|
WantedBy=multi-user.target\n",
|
|
installer = env!("CARGO_PKG_VERSION"),
|
|
config = config.display(),
|
|
db = db.display(),
|
|
binary = binary.display(),
|
|
)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn register_systemd(
|
|
prepared: &Prepared,
|
|
binary: &Path,
|
|
config: &Path,
|
|
db: &Path,
|
|
unit_path: &Path,
|
|
restart: bool,
|
|
) -> Result<Outcome> {
|
|
let user = prepared.user.clone().unwrap_or_else(|| "root".into());
|
|
let text = systemd_unit_text(binary, config, db, &user);
|
|
|
|
// An unchanged unit is not rewritten: daemon-reload is not free, and an mtime that moves on
|
|
// every run is a change an operator watching /etc has to investigate and then dismiss.
|
|
let current = std::fs::read_to_string(unit_path).unwrap_or_default();
|
|
if current != text {
|
|
crate::util::write_atomic(unit_path, text.as_bytes())
|
|
.with_context(|| format!("cannot write {}", unit_path.display()))?;
|
|
run_ok("systemctl", &["daemon-reload"])?;
|
|
}
|
|
|
|
run_ok("systemctl", &["enable", SYSTEMD_UNIT])?;
|
|
if restart {
|
|
// The binary underneath a running service has just been replaced; `start` on an already
|
|
// active unit is a no-op and would leave the old code running.
|
|
run_ok("systemctl", &["restart", SYSTEMD_UNIT])?;
|
|
} else {
|
|
run_ok("systemctl", &["start", SYSTEMD_UNIT])?;
|
|
}
|
|
|
|
Ok(Outcome::Registered {
|
|
kind: "systemd",
|
|
name: SYSTEMD_UNIT.to_string(),
|
|
unit_path: Some(unit_path.to_path_buf()),
|
|
user: prepared.user.clone(),
|
|
user_created: prepared.user_created,
|
|
state: systemd_state(),
|
|
})
|
|
}
|
|
|
|
/// Reads the unit's state back rather than inferring it from the exit codes above. `systemctl
|
|
/// start` succeeding and the service still being up a second later are different claims.
|
|
#[cfg(unix)]
|
|
fn systemd_state() -> String {
|
|
let active = one_word(run("systemctl", &["is-active", SYSTEMD_UNIT]));
|
|
let enabled = one_word(run("systemctl", &["is-enabled", SYSTEMD_UNIT]));
|
|
format!("{active}, {enabled}")
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn one_word(result: Result<std::process::Output>) -> String {
|
|
result
|
|
.ok()
|
|
.and_then(|o| {
|
|
String::from_utf8_lossy(&o.stdout)
|
|
.lines()
|
|
.map(str::trim)
|
|
.find(|l| !l.is_empty())
|
|
.map(str::to_string)
|
|
})
|
|
.unwrap_or_else(|| "unknown".to_string())
|
|
}
|
|
|
|
// ── Windows ──────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(windows)]
|
|
fn prepare_platform() -> Prepared {
|
|
// The SCM creates the virtual account as part of `sc create obj= "NT SERVICE\<name>"`, so
|
|
// there is nothing to provision here — and nothing that can fail before the config exists.
|
|
Prepared {
|
|
manager: Manager::WindowsScm,
|
|
user: None,
|
|
user_created: false,
|
|
}
|
|
}
|
|
|
|
/// The virtual service account the SCM creates for this service. Locale-independent: the `NT
|
|
/// SERVICE\<name>` form is a well-known prefix, unlike `BUILTIN\Administrators`, whose display name
|
|
/// is translated.
|
|
pub fn windows_service_account() -> String {
|
|
format!("NT SERVICE\\{WINDOWS_SERVICE}")
|
|
}
|
|
|
|
/// The `binPath=` value: the executable and the `--config` it must always be started with.
|
|
///
|
|
/// Pure and tested on both platforms because it is the single string that decides whether an
|
|
/// installed service reads the config the installer wrote, or whatever `sidecar.toml` happens to be
|
|
/// in the service's working directory — which, for a Windows service, is `%SystemRoot%\System32`.
|
|
pub fn windows_bin_path(binary: &Path, config: &Path) -> String {
|
|
format!("\"{}\" --config \"{}\"", binary.display(), config.display())
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outcome> {
|
|
let bin_path = windows_bin_path(binary, config);
|
|
let account = windows_service_account();
|
|
|
|
if windows_service_exists() {
|
|
// `config` rather than delete-and-recreate: recreating would drop the failure actions and,
|
|
// more to the point, would briefly leave a host with no service if the create half failed.
|
|
run_ok(
|
|
"sc.exe",
|
|
&[
|
|
"config",
|
|
WINDOWS_SERVICE,
|
|
"binPath=",
|
|
&bin_path,
|
|
"start=",
|
|
"auto",
|
|
"obj=",
|
|
&account,
|
|
],
|
|
)?;
|
|
} else {
|
|
run_ok(
|
|
"sc.exe",
|
|
&[
|
|
"create",
|
|
WINDOWS_SERVICE,
|
|
"binPath=",
|
|
&bin_path,
|
|
"start=",
|
|
"auto",
|
|
"obj=",
|
|
&account,
|
|
"DisplayName=",
|
|
DISPLAY_NAME,
|
|
],
|
|
)
|
|
.context(
|
|
"could not register the Windows service. The account is a virtual service account \
|
|
(no password); if this host's policy forbids them, register the service by hand — \
|
|
INSTALL.md Appendix A4.",
|
|
)?;
|
|
let _ = run("sc.exe", &["description", WINDOWS_SERVICE, DISPLAY_NAME]);
|
|
}
|
|
|
|
// Restart on failure, matching systemd's Restart=on-failure / RestartSec=5. `reset= 86400`
|
|
// means the failure count goes back to zero after a quiet day, so a service that crashes once
|
|
// a month keeps being restarted.
|
|
run_ok(
|
|
"sc.exe",
|
|
&[
|
|
"failure",
|
|
WINDOWS_SERVICE,
|
|
"reset=",
|
|
"86400",
|
|
"actions=",
|
|
"restart/5000",
|
|
],
|
|
)?;
|
|
|
|
if restart && windows_service_state().contains("RUNNING") {
|
|
stop_windows_service()?;
|
|
}
|
|
// 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure.
|
|
let start = run("sc.exe", &["start", WINDOWS_SERVICE])?;
|
|
if !start.status.success() && start.status.code() != Some(1056) {
|
|
anyhow::bail!(
|
|
"`{}` failed with exit code {}. Check the Windows event log; a service that exits \
|
|
immediately usually cannot read its config: {}",
|
|
crate::util::command_line("sc.exe", &["start", WINDOWS_SERVICE]),
|
|
start.status.code().unwrap_or(-1),
|
|
config.display()
|
|
);
|
|
}
|
|
|
|
Ok(Outcome::Registered {
|
|
kind: "windows-scm",
|
|
name: WINDOWS_SERVICE.to_string(),
|
|
unit_path: None,
|
|
user: Some(account),
|
|
user_created: false,
|
|
state: format!("{}, automatic start", windows_service_state()),
|
|
})
|
|
}
|
|
|
|
/// 1060 is ERROR_SERVICE_DOES_NOT_EXIST. Anything else — including an access-denied — is treated as
|
|
/// "it exists", so the caller reconfigures rather than trying to create a service that is there.
|
|
#[cfg(windows)]
|
|
fn windows_service_exists() -> bool {
|
|
match run("sc.exe", &["query", WINDOWS_SERVICE]) {
|
|
Ok(output) => output.status.code() != Some(1060),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn windows_service_state() -> String {
|
|
let Ok(output) = run("sc.exe", &["query", WINDOWS_SERVICE]) else {
|
|
return "unknown".to_string();
|
|
};
|
|
let text = String::from_utf8_lossy(&output.stdout);
|
|
for line in text.lines() {
|
|
if line.trim_start().starts_with("STATE") {
|
|
// " STATE : 4 RUNNING"
|
|
if let Some(word) = line.split_whitespace().last() {
|
|
return word.to_string();
|
|
}
|
|
}
|
|
}
|
|
"unknown".to_string()
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn stop_windows_service() -> Result<()> {
|
|
// 1062 is ERROR_SERVICE_NOT_ACTIVE — already the state being asked for.
|
|
let stop = run("sc.exe", &["stop", WINDOWS_SERVICE])?;
|
|
if !stop.status.success() && stop.status.code() != Some(1062) {
|
|
anyhow::bail!(
|
|
"cannot stop {WINDOWS_SERVICE} (exit code {}). The sidecar binary is locked while the \
|
|
service runs, so the install cannot replace it.",
|
|
stop.status.code().unwrap_or(-1)
|
|
);
|
|
}
|
|
// The SCM returns as soon as the stop is *pending*; the file stays locked until the process
|
|
// actually exits. Polling is the only way to know, and a fixed sleep would be either too short
|
|
// or a delay on every run.
|
|
for _ in 0..30 {
|
|
if windows_service_state() == "STOPPED" {
|
|
return Ok(());
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
}
|
|
anyhow::bail!(
|
|
"{WINDOWS_SERVICE} did not stop within 15 seconds. Stop it by hand and re-run: \
|
|
sc.exe stop {WINDOWS_SERVICE}"
|
|
)
|
|
}
|
|
|
|
// ── Shared entry points ──────────────────────────────────────────────────────
|
|
|
|
/// Stops a running service so its binary can be replaced.
|
|
///
|
|
/// Called only when the installed binary differs from the bundle's. On Windows the file is locked
|
|
/// while the service runs; on Linux replacing it under a running process is permitted but leaves
|
|
/// the old code serving until something restarts it, which is a worse outcome than a brief gap.
|
|
pub fn stop_for_replacement(manager: &Manager) -> Result<()> {
|
|
match manager {
|
|
#[cfg(unix)]
|
|
Manager::Systemd => {
|
|
// Not `run_ok`: a unit that is not loaded yet (first install) exits non-zero, and that
|
|
// is the normal case rather than an error.
|
|
let _ = run("systemctl", &["stop", SYSTEMD_UNIT]);
|
|
Ok(())
|
|
}
|
|
#[cfg(windows)]
|
|
Manager::WindowsScm => {
|
|
if windows_service_exists() {
|
|
stop_windows_service()?;
|
|
}
|
|
Ok(())
|
|
}
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// What the service manager says about a registered service, read without changing anything.
|
|
///
|
|
/// Every field is answered by asking the manager rather than by trusting `install.json`: the record
|
|
/// says what registration *did*, and `doctor`'s job is to find out what is true now. A service an
|
|
/// operator disabled by hand is exactly the case a record cannot know about.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Status {
|
|
pub present: bool,
|
|
pub running: bool,
|
|
pub enabled: bool,
|
|
/// The one-line form for a `doctor` row — `running, enabled`, `stopped, enabled`, `not found`.
|
|
pub detail: String,
|
|
}
|
|
|
|
impl Status {
|
|
fn absent(detail: impl Into<String>) -> Self {
|
|
Self {
|
|
present: false,
|
|
running: false,
|
|
enabled: false,
|
|
detail: detail.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reads back the state of the service `install.json` recorded, changing nothing.
|
|
///
|
|
/// `kind` is taken from the record rather than from this platform so that a record written on the
|
|
/// other OS produces an honest "this host has no such manager" instead of a confident answer from
|
|
/// the wrong tool.
|
|
pub fn observe(kind: &str, name: &str) -> Status {
|
|
observe_platform(kind, name)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn observe_platform(kind: &str, name: &str) -> Status {
|
|
if kind != "systemd" {
|
|
return Status::absent(format!("recorded as {kind}, which this host does not run"));
|
|
}
|
|
let active = one_word(run("systemctl", &["is-active", name]));
|
|
// `is-enabled` on an absent unit fails with an empty stdout, which `one_word` reports as
|
|
// "unknown" — so a unit that is neither known nor active is one systemd has never heard of.
|
|
let enabled = one_word(run("systemctl", &["is-enabled", name]));
|
|
if enabled == "unknown" && active != "active" {
|
|
return Status::absent("not found by systemd".to_string());
|
|
}
|
|
Status {
|
|
present: true,
|
|
running: active == "active",
|
|
enabled: enabled == "enabled",
|
|
detail: format!("{active}, {enabled}"),
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn observe_platform(kind: &str, name: &str) -> Status {
|
|
if kind != "windows-scm" {
|
|
return Status::absent(format!("recorded as {kind}, which this host does not run"));
|
|
}
|
|
// Only the service this installer registers is queried by name; anything else would be reading
|
|
// another product's service out of a hand-edited record.
|
|
if name != WINDOWS_SERVICE || !windows_service_exists() {
|
|
return Status::absent("not registered with the service manager".to_string());
|
|
}
|
|
let state = windows_service_state();
|
|
let start = windows_start_type();
|
|
Status {
|
|
present: true,
|
|
running: state.contains("RUNNING"),
|
|
enabled: start.contains("AUTO_START"),
|
|
detail: format!("{}, {}", state.to_lowercase(), start.to_lowercase()),
|
|
}
|
|
}
|
|
|
|
/// `sc qc` reports the start type; `sc query` does not. Read separately so a service that exists but
|
|
/// was set to manual start is reported as such rather than as healthy.
|
|
#[cfg(windows)]
|
|
fn windows_start_type() -> String {
|
|
let Ok(output) = run("sc.exe", &["qc", 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("START_TYPE") {
|
|
// " START_TYPE : 2 AUTO_START"
|
|
if let Some((_, value)) = line.split_once(':') {
|
|
return value.split_whitespace().last().unwrap_or("unknown").into();
|
|
}
|
|
}
|
|
}
|
|
"unknown".to_string()
|
|
}
|
|
|
|
/// What removing a service actually managed to do.
|
|
///
|
|
/// Never an `Err`: `uninstall` has usually already removed something by the time this runs, so a
|
|
/// step that fails must be *reported* and the rest carried out. Ending halfway with an error would
|
|
/// leave a host in a state neither the record nor the operator can describe.
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct Removal {
|
|
pub done: Vec<String>,
|
|
pub problems: Vec<String>,
|
|
}
|
|
|
|
/// Stops, disables and deletes the service recorded in `install.json`.
|
|
///
|
|
/// The service account is removed only when the record says **this installer created it**
|
|
/// (PLAN.md §5): deleting an account that was already on the host is not this tool's business, and
|
|
/// on Windows there is nothing to delete — the SCM's virtual account goes with the service.
|
|
pub fn remove(record: &ServiceRecord) -> Removal {
|
|
remove_platform(record)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn remove_platform(record: &ServiceRecord) -> Removal {
|
|
let mut out = Removal::default();
|
|
if record.kind != "systemd" {
|
|
out.problems.push(format!(
|
|
"the record describes a {} service, which this host does not run — remove it from the \
|
|
host that has it",
|
|
record.kind
|
|
));
|
|
return out;
|
|
}
|
|
|
|
// Neither stop nor disable is `run_ok`: a unit that is already stopped, already disabled, or
|
|
// gone entirely exits non-zero, and all three are the desired end state rather than failures.
|
|
let _ = run("systemctl", &["stop", &record.name]);
|
|
let _ = run("systemctl", &["disable", &record.name]);
|
|
out.done
|
|
.push(format!("stopped and disabled {}", record.name));
|
|
|
|
if let Some(unit) = &record.unit_path {
|
|
let path = Path::new(unit);
|
|
match std::fs::remove_file(path) {
|
|
Ok(()) => out.done.push(format!("removed {unit}")),
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
out.done.push(format!("{unit} was already gone"))
|
|
}
|
|
Err(error) => out.problems.push(format!("cannot remove {unit}: {error}")),
|
|
}
|
|
}
|
|
let _ = run("systemctl", &["daemon-reload"]);
|
|
// A unit that failed before being removed stays listed as failed until this is run.
|
|
let _ = run("systemctl", &["reset-failed", &record.name]);
|
|
|
|
if let (Some(user), true) = (record.user.as_deref(), record.user_created) {
|
|
match run_ok("userdel", &[user]).or_else(|_| run_ok("deluser", &[user])) {
|
|
Ok(_) => out.done.push(format!("removed the {user} service user")),
|
|
Err(error) => out.problems.push(format!(
|
|
"cannot remove the {user} service user ({}); remove it by hand if you want it gone",
|
|
error.to_string().replace('\n', " ")
|
|
)),
|
|
}
|
|
} else if let Some(user) = record.user.as_deref() {
|
|
out.done.push(format!(
|
|
"left the {user} account alone — this installer did not create it"
|
|
));
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn remove_platform(record: &ServiceRecord) -> Removal {
|
|
let mut out = Removal::default();
|
|
if record.kind != "windows-scm" {
|
|
out.problems.push(format!(
|
|
"the record describes a {} service, which this host does not run — remove it from the \
|
|
host that has it",
|
|
record.kind
|
|
));
|
|
return out;
|
|
}
|
|
if !windows_service_exists() {
|
|
out.done
|
|
.push(format!("{} was already unregistered", record.name));
|
|
return out;
|
|
}
|
|
|
|
// Stopping first is not politeness: `sc delete` on a running service only marks it for deletion,
|
|
// and the service — and its lock on the binary this uninstall is about to remove — survives
|
|
// until the process exits.
|
|
if let Err(error) = stop_windows_service() {
|
|
out.problems
|
|
.push(error.to_string().replace('\n', " ").to_string());
|
|
} else {
|
|
out.done.push(format!("stopped {}", record.name));
|
|
}
|
|
|
|
match run("sc.exe", &["delete", &record.name]) {
|
|
// 1072 is ERROR_SERVICE_MARKED_FOR_DELETE: something still holds a handle (an open
|
|
// services.msc is the usual culprit) and the entry goes when it is released.
|
|
Ok(output) if output.status.success() => {
|
|
out.done
|
|
.push(format!("deleted the {} service", record.name));
|
|
}
|
|
Ok(output) if output.status.code() == Some(1072) => out.done.push(format!(
|
|
"{} is marked for deletion — it disappears once whatever has it open (services.msc?) \
|
|
is closed",
|
|
record.name
|
|
)),
|
|
Ok(output) => out.problems.push(format!(
|
|
"sc.exe delete {} failed with exit code {}",
|
|
record.name,
|
|
output.status.code().unwrap_or(-1)
|
|
)),
|
|
Err(error) => out
|
|
.problems
|
|
.push(format!("cannot run sc.exe delete: {error}")),
|
|
}
|
|
|
|
// The virtual account exists only as long as the service does, so there is nothing to remove.
|
|
out
|
|
}
|
|
|
|
/// Registers, enables and starts the service — or explains why it did not.
|
|
///
|
|
/// `binary_changed` decides restart versus start: a replaced binary under an already-running
|
|
/// service must be restarted, or the host keeps running the code that was just replaced.
|
|
pub fn register(
|
|
prepared: &Prepared,
|
|
layout: &crate::paths::Layout,
|
|
binary_changed: bool,
|
|
) -> Result<Outcome> {
|
|
let config = layout.sidecar_config();
|
|
let db = layout.sidecar_db();
|
|
|
|
match &prepared.manager {
|
|
Manager::Unavailable(reason) => Ok(Outcome::Skipped {
|
|
reason: reason.clone(),
|
|
manual: manual_steps(&layout.sidecar_bin, &config, &db, !layout.relocated),
|
|
}),
|
|
#[cfg(unix)]
|
|
Manager::Systemd => register_systemd(
|
|
prepared,
|
|
&layout.sidecar_bin,
|
|
&config,
|
|
&db,
|
|
&layout.systemd_unit(),
|
|
binary_changed,
|
|
),
|
|
#[cfg(windows)]
|
|
Manager::WindowsScm => register_windows(&layout.sidecar_bin, &config, binary_changed),
|
|
// The manager this build cannot drive because it was compiled for the other platform. Only
|
|
// reachable if a Manager is constructed by hand; the detector never produces it.
|
|
#[allow(unreachable_patterns)]
|
|
other => Ok(Outcome::Skipped {
|
|
reason: format!("{} is not supported by this build", other.kind()),
|
|
manual: manual_steps(&layout.sidecar_bin, &config, &db, !layout.relocated),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// What to do by hand when no service was registered. The whole point of degrading rather than
|
|
/// failing: the operator ends the run with a working binary and the exact commands.
|
|
///
|
|
/// `config_protected` says whether the run already locked the config file down. It must not be
|
|
/// guessed: a relocated run deliberately leaves the permissions alone (see [`protect_config`]), and
|
|
/// a printed recipe that claims a token file is already protected when it is not is worse than one
|
|
/// that simply tells the operator to protect it.
|
|
pub fn manual_steps(binary: &Path, config: &Path, db: &Path, config_protected: bool) -> String {
|
|
#[cfg(windows)]
|
|
{
|
|
// `binPath=` is wrapped in *single* quotes, which is what makes this line pasteable into
|
|
// PowerShell: the value itself contains the double quotes the SCM needs around a path with
|
|
// spaces, and PowerShell would otherwise eat them. The `sc.exe` convention of a space after
|
|
// each `=` is not a typo either — the key and the value are separate arguments.
|
|
format!(
|
|
" From an elevated PowerShell:\n\n \
|
|
sc.exe create {WINDOWS_SERVICE} binPath= '{bin_path}' obj= '{account}' start= auto\n \
|
|
sc.exe failure {WINDOWS_SERVICE} reset= 86400 actions= restart/5000\n \
|
|
icacls '{config}' /grant '{account}:(R)'\n \
|
|
icacls '{data_dir}' /grant '{account}:(OI)(CI)M'\n \
|
|
sc.exe start {WINDOWS_SERVICE}\n\n{token_note}",
|
|
bin_path = windows_bin_path(binary, config),
|
|
account = windows_service_account(),
|
|
config = config.display(),
|
|
data_dir = db.parent().unwrap_or(db).display(),
|
|
token_note = if config_protected {
|
|
" That config file's own permissions have already been restricted to \
|
|
Administrators\n and SYSTEM, because it holds the auth token. The two grants \
|
|
above are what the\n service account needs once `sc create` has created it.\n"
|
|
.to_string()
|
|
} else {
|
|
format!(
|
|
" That config file holds the auth token, and this run did NOT restrict its\n \
|
|
permissions. Lock it down too:\n\n \
|
|
icacls '{config}' /inheritance:r /grant:r '*S-1-5-18:(F)' /grant:r \
|
|
'*S-1-5-32-544:(F)'\n",
|
|
config = config.display(),
|
|
)
|
|
},
|
|
)
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
// The chown lines are printed whether or not the file has already been chmod'ed: on this
|
|
// platform `protect_config` restricts the mode but can only hand the file to a user that
|
|
// exists, and reaching here means one does not.
|
|
let _ = config_protected;
|
|
format!(
|
|
" Write this to /etc/systemd/system/{SYSTEMD_UNIT}:\n\n{unit}\n \
|
|
Then:\n\n \
|
|
useradd --system --no-create-home --shell /usr/sbin/nologin {SERVICE_USER}\n \
|
|
chown {SERVICE_USER} {config}\n \
|
|
chown -R {SERVICE_USER} {data_dir}\n \
|
|
systemctl daemon-reload\n \
|
|
systemctl enable --now {SYSTEMD_UNIT}\n\n \
|
|
On a host without systemd, run the binary under whatever supervisor it does have —\n \
|
|
the only requirements are that it starts {binary} with UOLINK_CONFIG={config}\n \
|
|
and UOLINK_DB_PATH={db}, as an unprivileged user that can write the database.\n",
|
|
unit = indent(&systemd_unit_text(binary, config, db, SERVICE_USER)),
|
|
config = config.display(),
|
|
db = db.display(),
|
|
data_dir = db.parent().unwrap_or(db).display(),
|
|
binary = binary.display(),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn indent(text: &str) -> String {
|
|
text.lines()
|
|
.map(|l| format!(" {l}\n"))
|
|
.collect::<String>()
|
|
}
|
|
|
|
/// Locks down the files holding the auth token, **before** the service is registered.
|
|
///
|
|
/// `sidecar.toml` holds the token, and neither default location protects it on its own: `/etc` is
|
|
/// world-readable, and `%ProgramData%` grants `Users` read by inheritance. This runs as early as the
|
|
/// platform allows so the file is never sitting there readable while the rest of the run happens.
|
|
///
|
|
/// On Linux that is the whole job — the service user already exists, so the file can be handed to it
|
|
/// here. On Windows the service account does not exist until `sc create` creates it, so this step
|
|
/// only shuts everyone else out and [`grant_service_access`] does the rest afterwards.
|
|
///
|
|
/// `relocated` is not a courtesy flag. On Windows this replaces the file's ACL with SYSTEM and
|
|
/// Administrators, which is right for a real install — the installer runs elevated and a service
|
|
/// account is about to be granted read — and wrong for a relocated test run, where there is no
|
|
/// service account and the operator is explicitly *not* an administrator. Hardening a scratch file
|
|
/// against the only person who will ever read it just makes the next test run fail.
|
|
pub fn protect_config(
|
|
config: &Path,
|
|
data_dir: &Path,
|
|
user: Option<&str>,
|
|
relocated: bool,
|
|
) -> Result<()> {
|
|
protect_config_platform(config, data_dir, user, relocated)
|
|
}
|
|
|
|
/// Grants the registered service account access to what it must read and write.
|
|
///
|
|
/// A no-op on Linux, where the account was known before the config existed and `protect_config`
|
|
/// already handed both to it. On Windows this is the second half of that job, and it can only run
|
|
/// once the SCM has created the virtual account.
|
|
pub fn grant_service_access(config: &Path, data_dir: &Path, outcome: &Outcome) -> Result<()> {
|
|
grant_service_access_platform(config, data_dir, outcome)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn grant_service_access_platform(
|
|
_config: &Path,
|
|
_data_dir: &Path,
|
|
_outcome: &Outcome,
|
|
) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn protect_config_platform(
|
|
config: &Path,
|
|
data_dir: &Path,
|
|
user: Option<&str>,
|
|
_relocated: bool,
|
|
) -> Result<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
std::fs::set_permissions(config, std::fs::Permissions::from_mode(0o600))
|
|
.with_context(|| format!("cannot restrict permissions on {}", config.display()))?;
|
|
|
|
if let Some(user) = user {
|
|
// `chown` the command rather than a libc call: this crate has no libc dependency, the
|
|
// operation happens once per run, and a failure here has to be reported with the exact
|
|
// command anyway. Group is deliberately not set — `useradd --system` creates a matching
|
|
// group on most distros but not all, and naming one that does not exist fails the chown.
|
|
run_ok("chown", &[user, &config.to_string_lossy()])?;
|
|
run_ok("chown", &["-R", user, &data_dir.to_string_lossy()])?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn protect_config_platform(
|
|
config: &Path,
|
|
_data_dir: &Path,
|
|
_user: Option<&str>,
|
|
relocated: bool,
|
|
) -> Result<()> {
|
|
if relocated {
|
|
return Ok(());
|
|
}
|
|
// Well-known SIDs, not display names: `Administrators` and `SYSTEM` are localized, and an
|
|
// icacls line naming them fails on a non-English Windows.
|
|
// *S-1-5-18 NT AUTHORITY\SYSTEM
|
|
// *S-1-5-32-544 BUILTIN\Administrators
|
|
// Removing inheritance is the entire point: %ProgramData% grants `Users` read by inheritance,
|
|
// and the auth token is in this file.
|
|
run_ok(
|
|
"icacls",
|
|
&[
|
|
config.to_string_lossy().into_owned(),
|
|
"/inheritance:r".to_string(),
|
|
"/grant:r".to_string(),
|
|
"*S-1-5-18:(F)".to_string(),
|
|
"/grant:r".to_string(),
|
|
"*S-1-5-32-544:(F)".to_string(),
|
|
],
|
|
)
|
|
.context("cannot restrict access to the sidecar config, which holds the auth token")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn grant_service_access_platform(config: &Path, data_dir: &Path, outcome: &Outcome) -> Result<()> {
|
|
// Nothing to grant when nothing was registered: the account only exists because `sc create`
|
|
// made it, and a skipped registration leaves the config locked to Administrators — which is the
|
|
// right resting state for a host where no service is going to read it.
|
|
if !outcome.registered() {
|
|
return Ok(());
|
|
}
|
|
let account = windows_service_account();
|
|
|
|
run_ok(
|
|
"icacls",
|
|
&[
|
|
config.to_string_lossy().into_owned(),
|
|
"/grant".to_string(),
|
|
format!("{account}:(R)"),
|
|
],
|
|
)
|
|
.context("cannot grant the service account read access to its config")?;
|
|
|
|
// The service creates the database — and SQLite's journal and WAL files beside it — so the
|
|
// grant is on the directory: `(OI)(CI)M` = modify, inherited by files and subdirectories. On
|
|
// Windows that directory also holds `install.json`, because §3 puts both under
|
|
// `%ProgramData%\RunicGateway`; the config file is unaffected, since removing its inheritance
|
|
// above is what stops a directory grant from reaching it.
|
|
run_ok(
|
|
"icacls",
|
|
&[
|
|
data_dir.to_string_lossy().into_owned(),
|
|
"/grant".to_string(),
|
|
format!("{account}:(OI)(CI)M"),
|
|
],
|
|
)
|
|
.context("cannot grant the service account write access to its database directory")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_unit_pins_both_paths_and_an_unprivileged_user() {
|
|
// Every line here is load-bearing: an unpinned config path resolves against the service's
|
|
// working directory (PLAN.md §2.3), and User= is the whole reason the installer creates an
|
|
// account at all.
|
|
let unit = systemd_unit_text(
|
|
Path::new("/usr/bin/runicgateway-link"),
|
|
Path::new("/etc/runicgateway/sidecar.toml"),
|
|
Path::new("/var/lib/runicgateway/uo-link.db"),
|
|
SERVICE_USER,
|
|
);
|
|
assert!(unit.contains("Environment=UOLINK_CONFIG=/etc/runicgateway/sidecar.toml"));
|
|
assert!(unit.contains("Environment=UOLINK_DB_PATH=/var/lib/runicgateway/uo-link.db"));
|
|
assert!(unit.contains("ExecStart=/usr/bin/runicgateway-link"));
|
|
assert!(unit.contains(&format!("User={SERVICE_USER}")));
|
|
assert!(!unit.contains("User=root"), "{unit}");
|
|
assert!(unit.contains("Restart=on-failure"));
|
|
assert!(unit.contains("WantedBy=multi-user.target"));
|
|
// The drop-in pointer, so an operator with local changes is not fighting the installer.
|
|
assert!(unit.contains("systemctl edit"), "{unit}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_windows_bin_path_quotes_both_paths() {
|
|
// Both default Windows paths contain a space ("Program Files", and any relocated tree can).
|
|
// An unquoted binPath is the classic Windows service bug: the SCM would try to run
|
|
// C:\Program.exe with "Files\..." as an argument.
|
|
let line = windows_bin_path(
|
|
Path::new(r"C:\Program Files\RunicGateway\uo-link-sidecar.exe"),
|
|
Path::new(r"C:\ProgramData\RunicGateway\sidecar.toml"),
|
|
);
|
|
assert_eq!(
|
|
line,
|
|
"\"C:\\Program Files\\RunicGateway\\uo-link-sidecar.exe\" \
|
|
--config \"C:\\ProgramData\\RunicGateway\\sidecar.toml\""
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_service_account_is_the_virtual_one() {
|
|
// Not LocalSystem. The Linux half runs as an unprivileged user and this is its counterpart.
|
|
assert_eq!(windows_service_account(), "NT SERVICE\\RunicGatewayLink");
|
|
}
|
|
|
|
#[test]
|
|
fn a_relocated_run_registers_nothing() {
|
|
// There is no such thing as a relocated system service, so a test run must not create one.
|
|
let prepared = prepare(true);
|
|
assert!(matches!(prepared.manager, Manager::Unavailable(_)));
|
|
assert!(prepared.user.is_none());
|
|
match &prepared.manager {
|
|
Manager::Unavailable(reason) => {
|
|
assert!(reason.contains(crate::paths::STATE_DIR_ENV), "{reason}")
|
|
}
|
|
other => panic!("{other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_manual_steps_are_a_complete_recipe() {
|
|
// This text is all an operator gets on a host the installer cannot drive, so it has to name
|
|
// the binary, the config and the service — not merely gesture at the documentation.
|
|
let steps = manual_steps(
|
|
Path::new("/usr/bin/runicgateway-link"),
|
|
Path::new("/etc/runicgateway/sidecar.toml"),
|
|
Path::new("/var/lib/runicgateway/uo-link.db"),
|
|
true,
|
|
);
|
|
assert!(steps.contains("sidecar.toml"), "{steps}");
|
|
#[cfg(windows)]
|
|
{
|
|
assert!(steps.contains("sc.exe create"), "{steps}");
|
|
assert!(steps.contains("NT SERVICE\\RunicGatewayLink"), "{steps}");
|
|
// The binPath value carries its own double quotes, so the argument around it must be
|
|
// single-quoted or PowerShell strips them and the SCM gets an unquoted path.
|
|
assert!(steps.contains("binPath= '\""), "{steps}");
|
|
assert!(!steps.contains("binPath= \"\""), "{steps}");
|
|
// The write grant belongs to the database directory, which is not always the config's.
|
|
assert!(steps.contains("/var/lib/runicgateway'"), "{steps}");
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
assert!(steps.contains("systemctl enable --now"), "{steps}");
|
|
assert!(
|
|
steps.contains("ExecStart=/usr/bin/runicgateway-link"),
|
|
"{steps}"
|
|
);
|
|
// Non-systemd hosts get the requirements, not just a unit they cannot use.
|
|
assert!(steps.contains("without systemd"), "{steps}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_recipe_never_claims_a_protection_the_run_did_not_apply() {
|
|
// A relocated run leaves the token file's permissions alone. Telling the operator it is
|
|
// already locked down would be the one sentence here that could cost them the token.
|
|
let args = (
|
|
Path::new("/usr/bin/runicgateway-link"),
|
|
Path::new("/etc/runicgateway/sidecar.toml"),
|
|
Path::new("/var/lib/runicgateway/uo-link.db"),
|
|
);
|
|
let protected = manual_steps(args.0, args.1, args.2, true);
|
|
let unprotected = manual_steps(args.0, args.1, args.2, false);
|
|
#[cfg(windows)]
|
|
{
|
|
assert_ne!(protected, unprotected);
|
|
assert!(protected.contains("already been restricted"), "{protected}");
|
|
assert!(unprotected.contains("did NOT restrict"), "{unprotected}");
|
|
assert!(unprotected.contains("/inheritance:r"), "{unprotected}");
|
|
}
|
|
// On Linux the recipe is the same either way, and correct either way: `protect_config`
|
|
// restricts the mode but cannot hand the file to a user that does not exist yet, so the
|
|
// chown lines are needed regardless.
|
|
#[cfg(not(windows))]
|
|
{
|
|
assert_eq!(protected, unprotected);
|
|
assert!(protected.contains("chown runicgateway"), "{protected}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_skipped_registration_still_hands_over_the_recipe() {
|
|
let layout = crate::paths::Layout {
|
|
state_dir: PathBuf::from("/etc/runicgateway"),
|
|
data_dir: PathBuf::from("/var/lib/runicgateway"),
|
|
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
|
|
relocated: true,
|
|
};
|
|
let outcome = register(&prepare(true), &layout, false).unwrap();
|
|
match outcome {
|
|
Outcome::Skipped { reason, manual } => {
|
|
assert!(reason.contains("test run"), "{reason}");
|
|
assert!(!manual.trim().is_empty());
|
|
}
|
|
other => panic!("expected a skip, got {other:?}"),
|
|
}
|
|
}
|
|
}
|