//! 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, /// 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, user: Option, 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 { 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 { 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) -> 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\"`, 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\` 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 { 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) -> 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, pub problems: Vec, } /// 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 { 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::() } /// 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:?}"), } } }