crates/gunnar-cli/src/service.rs
//! `gunnar service`: the `systemd` integration. **Off by default.**
//!
//! Skidbladnir's systemd installer, taken as an optional dependency, copying
//! modgunn's shape (`modgunn/src/service.rs`): declare a
//! [`ServiceSpec`](skidbladnir::service::ServiceSpec), drive it through
//! [`Lifecycle`](skidbladnir::service::Lifecycle). That is the `Layout`-free
//! API. `product::SystemdInstaller` demands a version-dated layout and a
//! verified airgap bundle, and `product::registry()` hardcodes
//! holger/tunnr/nornir_server/knut_flight, so it is the wrong layer for gunnar.
//!
//! What it buys: unit rendering plus pure-Rust D-Bus control of
//! `org.freedesktop.systemd1`. **No `systemctl` subprocess**, which is the
//! only reason it is admissible at all under gunnar's zero-shell rule.
//!
//! # Three costs, recorded honestly
//!
//! RE-MEASURED 2026-08-04 (`cargo tree -e normal`, deduplicated by package, the
//! same counting `xtask`'s guard prints): `-p gunnar-cli --no-default-features
//! --features server` resolves **278** packages and `…server,systemd` **324**,
//! so the feature costs **+46**. The earlier "48 new crates / 32 / 16" split was
//! taken before the `fs` backend moved off gix porcelain and is superseded.
//!
//! 1. **A second async runtime, 28 of the 46.** `zbus` is non-optional in
//! skidbladnir and resolves `async-io`, not tokio, so the smol stack
//! (`zbus*`, `zvariant*`, `async-executor`, `polling`, `blocking`,
//! `event-listener*`, …) sits beside gunnar's tokio.
//! 2. **12 crates of dead weight**: `tar`, `zstd*`, `blake3`, `uuid`, `xattr`,
//! `filetime` are the airgap *packer*, which gunnar never calls. `zstd-sys`
//! means this feature needs a C compiler and pkg-config — which the appliance
//! shape, notably, does not.
//! 3. **Tension with Brokkr's "minimal dependencies"**: a sixth of the
//! tree for a convenience. Justified only because it is default-off and the
//! two shapes with no service manager never pay it. That is no longer a
//! promise in prose: `xtask/src/guard.rs` bans `skidbladnir` outright from
//! the appliance graph and witnesses the ban from its `service` profile.
//!
//! # Which shape this is
//!
//! `.nornir/build-variants.toml` names one cell per deployment shape, and this
//! module is compiled for exactly one of them — `gunnar-service`
//! (`--no-default-features --features server,systemd`, `kind = "service"`). The
//! container (`gunnar-image`) has no init to register with, and the OS-less
//! appliance (`gunnar-appliance-iso`) *is* the init; both chain the
//! `gunnar-server` artifact, which the guard proves systemd-free.
//!
//! # The version pin, and the uninstall that is thinner than it should be
//!
//! **0.1.12**, the newest publishable version. Local Skidbladnir is 0.1.13 and
//! depends on `nornir-escape`, which is not on crates.io, and gunnar must
//! build standalone from a fresh clone, so a path dep on `../Skidbladnir` is
//! not allowed even though all five existing consumers use one. gunnar is the
//! first registry-only consumer of this crate.
//!
//! 0.1.12 has **no `uninstall.rs`**: the `DataRoot` / `UninstallReport` /
//! guarded-delete purge law exists only in the unpublished 0.1.13. So
//! [`ServiceCommand::Uninstall`](crate::cli::ServiceCommand::Uninstall) is the
//! thinner `Result<()>`: it removes the unit and reports nothing about data.
use std::path::PathBuf;
use anyhow::{Context, Result};
use skidbladnir::service::{HealthProbe, Lifecycle, ServiceSpec, Socket};
use skidbladnir::systemd::{RestartPolicy, Scope};
use crate::cli::{ServiceArgs, ServiceCommand};
/// The unit name, and the dedicated system user the system-scope unit runs as.
pub const SERVICE_NAME: &str = "gunnar";
/// Where a **system-scope** unit keeps its data when the operator names no root.
///
/// `/var/lib/<name>` is the FHS answer and, more importantly, it is a CONSTANT
/// rather than a guess about an account. See [`resolved_data_root`].
pub const SYSTEM_DATA_ROOT: &str = "/var/lib/gunnar";
/// **The data root the unit will really use — always known, never implied.**
///
/// ⚠ This function exists because of a MEASURED failure, and the failure is not
/// gunnar's alone. `--data-root` is optional, and until 2026-08-04 an install
/// without it rendered a unit that named no root at all:
///
/// ```text
/// User=gunnar
/// Group=gunnar
/// WorkingDirectory=/
/// ExecStart=/usr/local/bin/gunnar serve --ssh-bind … --grpc-bind …
/// ```
///
/// Nothing there says where the repositories go, so the answer came from
/// `cli::default_data_root()` inside the *running* process: `$GUNNAR_DATA_ROOT`,
/// else `$HOME/.local/share/gunnar`, else the relative path `./gunnar-data`.
/// Under that unit both fall-throughs are wrong in a way that cannot be seen:
///
/// * with a `$HOME` for the `gunnar` account, the data lands in that account's
/// home, and NOTHING in the unit, the install output or `gunnar service unit`
/// mentions it;
/// * with no `$HOME` — which is what a system account made with
/// `--home /nonexistent` has — `./gunnar-data` resolves against
/// `WorkingDirectory=/` and gunnar writes **`/gunnar-data`**, at the root of
/// the filesystem.
///
/// This is korp's bug in a different repo: korp's `service_home` read the SCOPE
/// but never the ACCOUNT, so `--uninstall` looked in `/home/korp/.korp` for a
/// unit that had written 113 GB into the operator's own `~/.korp`. The output was
/// honest and the root was wrong. The general shape both share: **a capability
/// missing from the declaration becomes an assumption made somewhere else.**
///
/// The fix is by construction, not by a guard watching two answers agree — the
/// spec ALWAYS carries a root, so there is no second place for one to come from.
///
/// # Why system scope does not resolve `$HOME`
///
/// It would be resolving the WRONG home. `gunnar service install` runs as the
/// operator (or as root); the unit runs as `--run-as`. Reading `$HOME` here and
/// baking it into the unit is precisely how korp put a service's 113 GB into a
/// login account's home. Resolving `--run-as` out of `/etc/passwd` instead
/// (nornir's `service_home_in`) would be *correct*, but it makes the rendered
/// unit depend on the state of the box it was rendered on, and `gunnar service
/// unit` is meant to be printable anywhere. A constant under `/var/lib` is
/// declarative, is the same on every box, and is what `--data-root` exists to
/// override when an operator wants something else.
///
/// User scope is the opposite case and gets the opposite answer: a `--user` unit
/// really does run as the caller, so the caller's `$HOME` IS the right home, and
/// the resolver is reused from `cli` rather than restated here (one implementation).
pub fn resolved_data_root(args: &ServiceArgs) -> PathBuf {
if let Some(root) = &args.data_root {
return root.clone();
}
if args.user {
crate::cli::default_data_root()
} else {
PathBuf::from(SYSTEM_DATA_ROOT)
}
}
/// **The `RUST_LOG` the unit will really run with — always known, never
/// implied.**
///
/// The same shape as [`resolved_data_root`] and for the same reason: a
/// capability missing from the declaration becomes an assumption made
/// somewhere else. An `Environment=RUST_LOG=` the unit does not carry is not
/// "the operator's choice"; it is `logging::init`'s fall-through to a bare
/// `info`, decided inside the running process, recorded nowhere, and unable to
/// select a target whose level happens to sit below it.
pub fn resolved_log_filter(args: &ServiceArgs) -> String {
args.log_filter
.clone()
.filter(|filter| !filter.trim().is_empty())
.unwrap_or_else(gunnar_core::observe::service_log_filter)
}
/// How long `install` waits for the started unit to answer on its SSH port.
const HEALTH_TIMEOUT_MS: u64 = 5_000;
/// The drop-in file that carries [`HARDENING`] into the installed unit.
///
/// A drop-in rather than the unit body because the unit body is not gunnar's to
/// write: `Lifecycle::install` renders it from the spec and `std::fs::write`s it
/// itself. `<unit>.d/*.conf` is systemd's own mechanism for adding directives to
/// a unit somebody else renders, so it needs no fork of Skidbladnir and no
/// second renderer to drift from the first.
pub const HARDENING_DROPIN: &str = "10-hardening.conf";
/// **What the installed unit may do — the block `deploy/systemd/gunnar.service`
/// carries and Skidbladnir 0.1.12 cannot render.**
///
/// The shipped unit says of itself: *"`gunnar service install` writes the same
/// unit over D-Bus. This file is what it writes."* Until this block existed that
/// sentence was false, and falsifiably so: Skidbladnir's
/// `SystemdServiceSpec` has exactly ONE hardening field — `no_new_privileges`,
/// rendered as `NoNewPrivileges=true` — and no escape hatch for a directive it
/// does not model. So an operator who read `deploy/systemd/gunnar.service` and
/// then ran `gunnar service install` got a unit with **one** of the twenty-four
/// directives they had just read, and nothing said which.
///
/// That is the whole of the difference: an install with no `ProtectSystem=`
/// writes anywhere its uid can, an install with a non-empty
/// `CapabilityBoundingSet=` keeps every capability systemd hands a service, and
/// an install with no `SystemCallFilter=` can call `@privileged`. None of those
/// is a bug in gunnar's code; all of them are the deployment the repository
/// documents not being the deployment the repository installs.
///
/// The list is a `&[(key, value)]` and not a string so a duplicate key is
/// expressible (`SystemCallFilter=` is set twice, allow-list then subtraction,
/// which is systemd's own idiom) and so a test can iterate it.
pub const HARDENING: &[(&str, &str)] = &[
("NoNewPrivileges", "yes"),
("PrivateTmp", "yes"),
("PrivateDevices", "yes"),
("ProtectSystem", "strict"),
("ProtectHome", "yes"),
("ProtectKernelTunables", "yes"),
("ProtectKernelModules", "yes"),
("ProtectKernelLogs", "yes"),
("ProtectControlGroups", "yes"),
("ProtectClock", "yes"),
("ProtectHostname", "yes"),
("ProtectProc", "invisible"),
("RestrictSUIDSGID", "yes"),
("RestrictRealtime", "yes"),
// Gunnar is only a git server: no build sandbox, so no namespaces either.
("RestrictNamespaces", "yes"),
("LockPersonality", "yes"),
("MemoryDenyWriteExecute", "yes"),
("RemoveIPC", "yes"),
("UMask", "0077"),
("CapabilityBoundingSet", ""),
("AmbientCapabilities", ""),
("SystemCallArchitectures", "native"),
("SystemCallFilter", "@system-service"),
("SystemCallFilter", "~@privileged @resources @obsolete"),
("RestrictAddressFamilies", "AF_INET AF_INET6 AF_UNIX"),
];
/// The hardening drop-in's text for `args`, including the one directive the
/// shipped unit does not need and this one does.
///
/// # `ReadWritePaths=`, and why it is derived rather than listed
///
/// `ProtectSystem=strict` makes the entire hierarchy read-only, and the shipped
/// unit gets its one writable place back from `StateDirectory=gunnar`, which
/// only ever means `/var/lib/gunnar`. `gunnar service install` takes
/// `--data-root`, so the writable place is whatever [`resolved_data_root`]
/// resolved — the same answer the unit's `Environment=GUNNAR_DATA_ROOT=` and
/// `WorkingDirectory=` already carry, taken from the same function rather than
/// guessed a second time. The directory exists by the time the unit starts:
/// Skidbladnir's `ensure_system_user` creates and chowns the spec's working
/// directory before the D-Bus `StartUnit`.
///
/// # Why `--user` scope drops `ProtectHome=`
///
/// A `--user` unit runs as the caller and [`resolved_data_root`] deliberately
/// answers with the caller's own `$HOME` there — that is the whole point of the
/// scope split. `ProtectHome=yes` makes `/home` inaccessible to the service, and
/// `ReadWritePaths=` does not unmask a path `ProtectHome=` has already hidden,
/// so keeping the line would install a unit that cannot reach its own
/// repositories. Every other directive is scope-neutral and stays.
pub fn hardening_dropin(args: &ServiceArgs) -> String {
let mut text = String::from(
"# Written by `gunnar service install`. The hardening half of\n\
# deploy/systemd/gunnar.service, which the unit renderer cannot express.\n\
[Service]\n",
);
for (key, value) in HARDENING {
if args.user && *key == "ProtectHome" {
continue;
}
text.push_str(key);
text.push('=');
text.push_str(value);
text.push('\n');
}
text.push_str(&format!(
"ReadWritePaths={}\n",
resolved_data_root(args).display()
));
text
}
/// Where the hardening drop-in goes, for the scope this install is in.
///
/// The same two directories Skidbladnir's own installer writes the unit into
/// (`systemd.rs:760-767`), because a drop-in systemd does not see beside its
/// unit is a file, not a policy.
pub fn dropin_dir(args: &ServiceArgs) -> Result<PathBuf> {
if args.user {
let home = std::env::var_os("HOME").map(PathBuf::from).ok_or_else(|| {
anyhow::anyhow!("$HOME is not set, and a --user unit's drop-in lives under it")
})?;
Ok(home
.join(".config/systemd/user")
.join(format!("{SERVICE_NAME}.service.d")))
} else {
Ok(PathBuf::from("/etc/systemd/system").join(format!("{SERVICE_NAME}.service.d")))
}
}
/// Write the hardening drop-in, returning where it landed.
///
/// **Before** `Lifecycle::install`, never after: `install` ends in a D-Bus
/// `StartUnit`, so a drop-in written afterwards would not apply to the process
/// that install just started — it would apply at the next restart, i.e. at the
/// next reboot, with nobody watching. That is the same shape as the `203`
/// failure [`preflight_exec`] exists to catch.
///
/// Mode 0644, deliberately: it carries no secret (the KEK lives in the 0600
/// `EnvironmentFile`, and this file names no environment at all), and systemd
/// reads it as root.
pub fn write_hardening_dropin(args: &ServiceArgs) -> Result<PathBuf> {
let dir = dropin_dir(args)?;
std::fs::create_dir_all(&dir)
.with_context(|| format!("creating the drop-in directory {}", dir.display()))?;
let path = dir.join(HARDENING_DROPIN);
std::fs::write(&path, hardening_dropin(args))
.with_context(|| format!("writing the hardening drop-in {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
.with_context(|| format!("setting the mode on {}", path.display()))?;
}
Ok(path)
}
/// The binary the unit will run, with the same default [`spec`] uses.
pub fn exec_path(args: &ServiceArgs) -> PathBuf {
args.exec
.clone()
.unwrap_or_else(|| PathBuf::from("/usr/local/bin/gunnar"))
}
/// **`ExecStart` must exist and be executable before a unit naming it is
/// written.**
///
/// Nothing in Skidbladnir 0.1.12 stats this path: `SystemdServiceSpec::validate`
/// checks only that a spec carrying secrets also carries an `EnvironmentFile=`.
/// So a typo in `--exec` renders a valid unit, installs it, enables it and
/// reports success — and systemd's `StartUnit` is a queued job, so even the
/// start does not come back with the failure.
///
/// MEASURED 2026-08-06 with a deliberately wrong path: `✓ updated
/// gunnar systemd --user service`, exit 0, `is-active` = `active`. It failed at
/// the next restart with `ExecMainStatus=203`, `Result=exit-code` — i.e. it
/// would have failed at the next reboot, with nobody watching.
pub fn preflight_exec(exec: &std::path::Path) -> Result<()> {
let meta = std::fs::metadata(exec).with_context(|| {
format!(
"the unit's ExecStart would be {}, and nothing is there. A unit naming a path \
that does not exist installs cleanly, enables cleanly, and dies at the next \
restart with status 203. Build and copy the binary first, or pass --exec.",
exec.display()
)
})?;
if !meta.is_file() {
anyhow::bail!(
"the unit's ExecStart would be {}, which is not a file",
exec.display()
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
if meta.permissions().mode() & 0o111 == 0 {
anyhow::bail!(
"the unit's ExecStart would be {}, which exists but carries no executable \
bit ({:o}). systemd would fail it with 203, the same way a missing file does",
exec.display(),
meta.permissions().mode() & 0o7777
);
}
}
Ok(())
}
/// Poll the declared [`HealthProbe`] until it answers, or the timeout expires.
///
/// A `bool`, not a `Result`: "the probe could not be run" and "the probe said
/// no" are the same answer to the only question the caller has, which is
/// whether it may tell an operator the service is up.
fn wait_until_healthy(lifecycle: &Lifecycle) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(HEALTH_TIMEOUT_MS);
loop {
if lifecycle.health().unwrap_or(false) {
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
/// The TCP port out of a `host:port`, or `fallback`.
pub fn addr_port(addr: &str, fallback: u16) -> u16 {
addr.rsplit(':')
.next()
.and_then(|port| port.parse().ok())
.unwrap_or(fallback)
}
/// Declare gunnar's service.
///
/// The health probe is the **SSH** port, not the gRPC one. gRPC is optional
/// (`--no-grpc`) and `Admin.Health` now needs a credential of its own (the
/// allow-all hole is closed); SSH is the listener that has to be up for gunnar
/// to be doing its job at all, so it is the one whose absence should mark the
/// service unhealthy.
pub fn spec(args: &ServiceArgs) -> ServiceSpec {
let exec = args
.exec
.clone()
.unwrap_or_else(|| PathBuf::from("/usr/local/bin/gunnar"));
let scope = if args.user {
Scope::User
} else {
Scope::System
};
let ssh_port = addr_port(&args.ssh_bind, 2222);
let root = resolved_data_root(args);
let root_s = root.to_string_lossy().into_owned();
let mut spec = ServiceSpec::new(
SERVICE_NAME,
"gunnar — a git server over SSH, with a gRPC control plane",
exec.to_string_lossy().as_ref(),
)
.scope(scope)
.restart(RestartPolicy::OnFailure)
.socket(Socket::tcp(ssh_port))
.health(HealthProbe::Tcp(ssh_port))
// Both of these, and both unconditionally. `Environment=` is what gunnar
// actually reads; `WorkingDirectory=` is what makes the answer safe even if
// it ever stopped reading it, because `cli::default_data_root`'s last
// fall-through is the RELATIVE path `./gunnar-data` and the renderer's
// default working directory is `/`. One of them tells gunnar where to go;
// the other makes the place it would otherwise land somewhere survivable
// instead of the root of the filesystem. See [`resolved_data_root`].
.env("GUNNAR_DATA_ROOT", root_s.as_str())
// ── the log, which a unit with no `Environment=RUST_LOG=` does not have ──
//
// A journal an operator cannot read is a server that is not observable, and
// "no filter" is not neutral: `logging::init` falls through to a bare
// `info`, which selects gunnar's targets only by accident of their level.
// The 2026-08-05 `"gunnar.lfs"` finding is what that costs — seventeen call
// sites emitting on a target no documented filter selected, silently.
//
// So the unit always CARRIES the filter, and the filter is derived from
// `observe::TARGETS` rather than typed out here (one implementation).
.env("RUST_LOG", resolved_log_filter(args).as_str())
.working_dir(root_s.as_str());
spec.args = vec![
"serve".to_owned(),
"--ssh-bind".to_owned(),
args.ssh_bind.clone(),
"--grpc-bind".to_owned(),
args.grpc_bind.clone(),
];
// A `--user` unit runs as whoever installed it; naming a user there is
// meaningless and systemd rejects it.
if !args.user {
spec = spec.user(&args.run_as);
}
spec
}
/// The arguments every verb shares. Exhaustive by construction, so a new
/// variant is a compile error here too.
fn args_of(command: &ServiceCommand) -> &ServiceArgs {
match command {
ServiceCommand::Install(a)
| ServiceCommand::Start(a)
| ServiceCommand::Stop(a)
| ServiceCommand::Status(a)
| ServiceCommand::Uninstall(a)
| ServiceCommand::Unit(a) => a,
}
}
/// Run a `gunnar service …` verb.
///
/// **The dispatch matches the enum, not a `&str` derived from it**.
/// It used to lower the command to a verb string and re-match on that, with an
/// `unreachable!` on the fallthrough, so adding a variant and routing it in
/// the lowering step, which is the natural way to add one, compiled clean and
/// aborted at runtime. That was witnessed: a `Reload` variant reached
/// `unreachable!("unhandled service verb reload")`. Matching the enum with no
/// wildcard makes the same mistake a compile error, which is where it belongs.
/// Do not reintroduce a catch-all arm.
pub fn run(command: ServiceCommand) -> Result<()> {
let lifecycle = Lifecycle::new(spec(args_of(&command)));
match command {
ServiceCommand::Unit(args) => {
// Both halves, because both halves are installed. Printing only the
// rendered unit is what made `deploy/systemd/gunnar.service`'s "this
// file is what it writes" unfalsifiable from the CLI.
print!("{}", lifecycle.unit_text());
println!(
"\n# {}",
dropin_dir(&args)
.map(|dir| dir.join(HARDENING_DROPIN).display().to_string())
.unwrap_or_else(|why| format!("(drop-in path unknown: {why})"))
);
print!("{}", hardening_dropin(&args));
Ok(())
}
ServiceCommand::Install(args) => {
// ── 1. the path must exist BEFORE anything is written ───────────
preflight_exec(&exec_path(&args))?;
// ── 2. stop first, so `start` cannot be a no-op ─────────────────
//
// MEASURED 2026-08-06 and this is the whole reason the
// three steps are here. `install()` ends in D-Bus `StartUnit`, and
// `StartUnit` on a unit that is ALREADY active does nothing. So an
// install over a running gunnar wrote a new unit, reloaded the
// manager, printed `✓ installed`, exited 0 — and left the OLD
// process running the OLD unit. With a deliberately wrong
// `ExecStart` the output was still `✓ updated … installed
// gunnar.service`, `systemctl is-active` still said `active`, and
// `gunnar service status` still said `active` and exited 0. The
// damage only appears at the next restart, i.e. at the next reboot,
// as `ExecMainStatus=203` in a `Restart=on-failure` loop.
//
// `stop` is best-effort on purpose: a first install has no unit to
// stop, and D-Bus says so as an error rather than as a no-op.
let _ = lifecycle.stop();
// ── 2b. what the process may do, before it is allowed to do it ──
//
// See [`write_hardening_dropin`]: `install()` ends in `StartUnit`,
// so this has to be on disk first or the process it starts runs
// unhardened until the next reboot.
let dropin = write_hardening_dropin(&args)?;
lifecycle.install().context("installing the gunnar unit")?;
println!("hardened by {}", dropin.display());
// ── 3. and it has to be ANSWERING, not merely `active` ──────────
//
// `HealthProbe::Tcp` on the SSH port: a real connect to the
// listener. `active` is systemd's opinion about a process it
// spawned; this is gunnar's own socket accepting.
let healthy = wait_until_healthy(&lifecycle);
println!("installed {}", lifecycle.unit());
if !healthy {
anyhow::bail!(
"{} was installed and started, but nothing is answering on its SSH port \
after {HEALTH_TIMEOUT_MS} ms. The unit is on disk and enabled; the \
process is not serving. Read the journal before trusting the install: \
`journalctl --user -u {} -n 50`",
lifecycle.unit(),
lifecycle.unit()
);
}
println!(
"{} is answering on {} — the running process, not just the unit file",
lifecycle.unit(),
args.ssh_bind
);
Ok(())
}
ServiceCommand::Start(_) => {
lifecycle.start().context("starting gunnar")?;
println!("started {}", lifecycle.unit());
Ok(())
}
ServiceCommand::Stop(_) => {
lifecycle.stop().context("stopping gunnar")?;
println!("stopped {}", lifecycle.unit());
Ok(())
}
ServiceCommand::Status(_) => {
let active = lifecycle.status().context("querying gunnar's status")?;
println!(
"{} {}",
lifecycle.unit(),
if active { "active" } else { "inactive" }
);
// **The answer is the exit code too.** It printed `inactive` and
// exited 0 until 2026-08-06, which is the same shape as a failing
// command piped to `tail`: a script asking "is gunnar up?" got a
// clean result from a dead server. MEASURED while the unit
// was in a 203 crash loop: `gunnar.service inactive` / `exit=0`.
// `systemctl is-active` has exited non-zero for this since 2010.
if active {
Ok(())
} else {
anyhow::bail!(
"{} is not active. `journalctl --user -u {} -n 50` says why",
lifecycle.unit(),
lifecycle.unit()
)
}
}
ServiceCommand::Uninstall(args) => {
// Skidbladnir 0.1.12: a bare `Result<()>`. No UninstallReport, no
// guarded purge; those are 0.1.13, which cannot be published.
lifecycle.uninstall().context("removing the gunnar unit")?;
// The drop-in is gunnar's file, so gunnar removes it. Best-effort
// for the same reason the install's `stop` is: on a box that never
// had one there is nothing to remove, and that is not a failure.
if let Ok(dir) = dropin_dir(&args) {
let _ = std::fs::remove_file(dir.join(HARDENING_DROPIN));
let _ = std::fs::remove_dir(&dir);
}
println!(
"removed {}; gunnar's data root was NOT touched (0.1.12 has no purge law)",
lifecycle.unit()
);
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::ServiceArgs;
fn args() -> ServiceArgs {
ServiceArgs {
exec: Some(PathBuf::from("/usr/local/bin/gunnar")),
ssh_bind: "0.0.0.0:2222".to_owned(),
grpc_bind: "127.0.0.1:50051".to_owned(),
user: false,
run_as: "gunnar".to_owned(),
data_root: Some(PathBuf::from("/var/lib/gunnar")),
log_filter: None,
}
}
#[test]
fn addr_port_parses_or_falls_back() {
assert_eq!(addr_port("0.0.0.0:2222", 1), 2222);
assert_eq!(addr_port("127.0.0.1:50051", 1), 50051);
assert_eq!(addr_port("garbage", 2222), 2222);
}
/// The rendered unit must carry gunnar's identity, its dedicated user, and
/// the arguments that make it actually serve. A unit that renders but omits
/// `serve` installs cleanly and does nothing.
#[test]
fn the_system_unit_renders_what_it_must() {
let unit = Lifecycle::new(spec(&args())).unit_text();
assert!(unit.contains("[Unit]"), "not a unit file:\n{unit}");
assert!(
unit.contains("ExecStart=/usr/local/bin/gunnar"),
"no ExecStart:\n{unit}"
);
assert!(unit.contains("serve"), "the unit does not serve:\n{unit}");
assert!(
unit.contains("--grpc-bind 127.0.0.1:50051"),
"the unit must keep gRPC on loopback:\n{unit}"
);
assert!(
unit.contains("User=gunnar"),
"gunnar runs as its own system user:\n{unit}"
);
assert!(
unit.contains("Environment=GUNNAR_DATA_ROOT=/var/lib/gunnar"),
"the unit must tell gunnar where its data is:\n{unit}"
);
}
/// The identity-value guard for the test above: a `--user` unit must NOT
/// carry `User=`, so the assertion there is discriminating rather than
/// something the renderer emits unconditionally.
#[test]
fn a_user_scope_unit_names_no_user() {
let mut args = args();
args.user = true;
let unit = Lifecycle::new(spec(&args)).unit_text();
assert!(
!unit.contains("User=gunnar"),
"a --user unit runs as the caller and must not name a user:\n{unit}"
);
}
/// **An install with no `--data-root` must still say where the data goes.**
///
/// The regression this locks down was real and is written up on
/// [`resolved_data_root`]: the unit rendered `WorkingDirectory=/`, no
/// `Environment=GUNNAR_DATA_ROOT=`, and `User=gunnar`, so the running
/// process fell through `cli::default_data_root` to the account's `$HOME`
/// — unmentioned anywhere — or, with no `$HOME`, to the relative
/// `./gunnar-data` against `/`, i.e. **`/gunnar-data`**.
///
/// Both keys are asserted because they fail independently: `Environment=`
/// is what gunnar reads, `WorkingDirectory=` is what the fall-through would
/// resolve against if it ever stopped reading it.
#[test]
fn an_install_without_a_data_root_still_declares_one() {
let mut a = args();
a.data_root = None;
let unit = Lifecycle::new(spec(&a)).unit_text();
assert!(
unit.contains("Environment=GUNNAR_DATA_ROOT=/var/lib/gunnar"),
"a default system install names no data root, so gunnar picks one at \
runtime and nothing records which:\n{unit}"
);
assert!(
unit.contains("WorkingDirectory=/var/lib/gunnar"),
"`./gunnar-data` would resolve against this, and `/` makes that \
`/gunnar-data`:\n{unit}"
);
}
/// The identity-value guard for the test above.
///
/// `/var/lib/gunnar` is a constant, and a test that only ever sees a
/// constant cannot tell a resolver that works from one that returns the
/// same thing whatever it is asked. So this asserts the OTHER scope gets a
/// DIFFERENT answer — the caller's own home — which is the whole
/// distinction [`resolved_data_root`] exists to make. A `--user` unit runs
/// as the caller, so the caller's `$HOME` is the right home there and the
/// wrong one in system scope; if this test ever reads `/var/lib/gunnar`,
/// the scope split has collapsed and the test above is proving nothing.
#[test]
fn user_scope_resolves_the_callers_home_and_not_the_system_root() {
let mut a = args();
a.data_root = None;
a.user = true;
let root = resolved_data_root(&a);
assert_ne!(
root,
PathBuf::from(SYSTEM_DATA_ROOT),
"a --user unit must not be pinned to the system state directory"
);
assert_eq!(
root,
crate::cli::default_data_root(),
"a --user unit runs as the caller, so it must reuse the caller's own \
resolver rather than a second copy of it"
);
}
/// **A path that is not there must be refused before a unit names it.**
///
/// The green half of this is the identity-value guard: without it,
/// `preflight_exec` could return `Err` unconditionally and the two refusals
/// below would both still pass.
#[test]
fn an_exec_that_cannot_run_is_refused_and_a_real_one_is_not() {
let dir = tempfile::tempdir().expect("a temp dir");
// ── absent ───────────────────────────────────────────────────────────
let absent = dir.path().join("not-here");
let err = preflight_exec(&absent).expect_err("an absent ExecStart must be refused");
let text = format!("{err:#}");
assert!(
text.contains("not-here") && text.contains("203"),
"the refusal must name the path and what systemd would do with it: {text}"
);
// ── present, but not executable ──────────────────────────────────────
let plain = dir.path().join("not-executable");
std::fs::write(&plain, b"#!/bin/sh\n").expect("write");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o644))
.expect("chmod");
let err =
preflight_exec(&plain).expect_err("a non-executable ExecStart must be refused");
let text = format!("{err:#}");
assert!(
text.contains("executable bit"),
"a file with no +x is a different failure from an absent one, and must say \
so: {text}"
);
}
// ── a real executable ────────────────────────────────────────────────
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755))
.expect("chmod");
}
preflight_exec(&plain).expect("an executable file must be accepted");
}
/// The default `--exec` must be the same path in the preflight and in the
/// rendered unit, or the check guards a path the unit does not name.
#[test]
fn the_preflight_checks_the_path_the_unit_will_actually_name() {
let mut a = args();
a.exec = None;
let unit = Lifecycle::new(spec(&a)).unit_text();
let checked = exec_path(&a);
assert!(
unit.contains(&format!("ExecStart={}", checked.display())),
"the preflight would stat {} while the unit runs something else:\n{unit}",
checked.display()
);
}
/// **The unit must carry a log filter, and it must name gunnar's targets.**
///
/// An installed service whose journal is empty and one that is not running
/// look identical from outside. A unit with no `Environment=RUST_LOG=`
/// leaves the answer to `logging::init`'s fall-through, where nothing
/// records it and no target is named.
#[test]
fn the_unit_carries_a_log_filter_naming_every_gunnar_target() {
let unit = Lifecycle::new(spec(&args())).unit_text();
assert!(
unit.contains("Environment=RUST_LOG="),
"the unit names no log filter, so what it logs is decided inside the process \
and written down nowhere:\n{unit}"
);
for target in gunnar_core::observe::TARGETS {
assert!(
unit.contains(&format!("{target}=")),
"the unit's filter does not select {target}; that is the 2026-08-05 \
`gunnar.lfs` failure, one deployment later:\n{unit}"
);
}
}
/// The identity-value guard for the test above.
///
/// `Environment=RUST_LOG=trace` would satisfy every assertion up there
/// while telling an operator nothing about which targets exist — and
/// `--log-filter` has to reach the unit at all, or the flag is decoration.
#[test]
fn an_explicit_log_filter_replaces_the_derived_one() {
let mut a = args();
a.log_filter = Some("warn,gunnar::audit=trace".to_owned());
let unit = Lifecycle::new(spec(&a)).unit_text();
assert!(
unit.contains("Environment=RUST_LOG=warn,gunnar::audit=trace"),
"--log-filter did not reach the unit:\n{unit}"
);
assert!(
!unit.contains(gunnar_core::observe::TARGET_GIT),
"the operator's filter was merged with the default instead of replacing it, \
so they cannot turn a target OFF:\n{unit}"
);
// …and an empty one is not a filter. Without this, `--log-filter ''`
// installs a unit whose `RUST_LOG=` is the empty string, which
// `EnvFilter` reads as "off".
a.log_filter = Some(" ".to_owned());
let unit = Lifecycle::new(spec(&a)).unit_text();
assert!(
unit.contains(&format!(
"Environment=RUST_LOG={}",
gunnar_core::observe::service_log_filter()
)),
"a blank --log-filter must fall back to the derived one, not install an empty \
RUST_LOG:\n{unit}"
);
}
/// The hardening directives the repository SHIPS, read out of
/// `deploy/systemd/gunnar.service` itself.
///
/// Selected by subtraction, not by a second list of key names: a directive
/// is "hardening" unless it is one of the ones that says who the unit is,
/// where it lives or when it runs — which are exactly the ones the spec
/// renderer owns. So a line added to the shipped unit tomorrow enters this
/// set by itself, and a test that would otherwise only ever check the lines
/// somebody remembered to copy here cannot go quietly stale.
///
/// `EnvironmentFile=` is excluded on purpose: the KEK channel is not
/// hardening, `gunnar service install` has never written one, and giving it
/// one is a behaviour change rather than this finding.
fn shipped_hardening() -> Vec<String> {
const RENDERED_BY_THE_SPEC: &[&str] = &[
"Description",
"Documentation",
"After",
"Wants",
"BindsTo",
"Type",
"ExecStart",
"EnvironmentFile",
"Environment",
"User",
"Group",
"DynamicUser",
"StateDirectory",
"StateDirectoryMode",
"WorkingDirectory",
"Restart",
"RestartSec",
"WantedBy",
];
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../deploy/systemd/gunnar.service");
let shipped = std::fs::read_to_string(&path)
.unwrap_or_else(|why| panic!("reading {}: {why}", path.display()));
shipped
.lines()
.map(str::trim)
.filter(|line| {
line.starts_with(|c: char| c.is_ascii_alphabetic()) && line.contains('=')
})
.filter(|line| {
let key = line.split('=').next().unwrap_or_default();
!RENDERED_BY_THE_SPEC.contains(&key)
})
.map(str::to_owned)
.collect()
}
/// **`gunnar service install` must install the hardening the repository
/// documents — every line of it.**
///
/// `deploy/systemd/gunnar.service` says, in its own header, *"`gunnar
/// service install` writes the same unit over D-Bus. This file is what it
/// writes."* This test is that sentence, made falsifiable. MEASURED with the
/// drop-in emptied: **all 25** of the shipped unit's hardening directives
/// are missing from what the renderer alone produces. Even
/// `NoNewPrivileges` — the single hardening field Skidbladnir 0.1.12's
/// `SystemdServiceSpec` models — misses, because it renders `=true` where
/// the shipped unit says `=yes`. Same setting, second spelling, and a second
/// spelling is exactly what a parity test must not accept quietly.
#[test]
fn the_install_carries_every_hardening_line_the_repository_ships() {
let shipped = shipped_hardening();
assert!(
shipped.len() >= 20,
"the shipped unit yielded only {} directives, so the subtraction above is \
swallowing the block this test exists to compare against: {shipped:?}",
shipped.len()
);
let a = args();
let installed = format!(
"{}\n{}",
Lifecycle::new(spec(&a)).unit_text(),
hardening_dropin(&a)
);
let missing: Vec<&String> = shipped
.iter()
.filter(|line| !installed.contains(line.as_str()))
.collect();
assert!(
missing.is_empty(),
"an operator who read deploy/systemd/gunnar.service and then ran `gunnar \
service install` would not get {missing:?}:\n{installed}"
);
}
/// The identity-value guard for the test above, twice over.
///
/// A drop-in that simply echoed the shipped file would satisfy it while
/// installing a unit that cannot run. Two directives have to differ from a
/// verbatim copy, and they fail independently:
///
/// * `ReadWritePaths=` must name the root THIS install resolved, or
/// `ProtectSystem=strict` leaves a service with `--data-root` nowhere to
/// write — the shipped unit gets its one writable place from
/// `StateDirectory=`, which only ever means `/var/lib/gunnar`;
/// * a `--user` unit must NOT carry `ProtectHome=`, whose whole effect is
/// to hide the `$HOME` that [`resolved_data_root`] just answered with.
#[test]
fn the_dropin_is_not_a_copy_of_the_shipped_unit() {
let mut a = args();
a.data_root = Some(PathBuf::from("/srv/gunnar"));
let dropin = hardening_dropin(&a);
assert!(
dropin.contains("ReadWritePaths=/srv/gunnar"),
"ProtectSystem=strict with no writable data root is a service that cannot \
serve:\n{dropin}"
);
assert!(
!dropin.contains("ReadWritePaths=/var/lib/gunnar"),
"the writable path is the shipped unit's constant, not the root this install \
resolved:\n{dropin}"
);
a.user = true;
a.data_root = None;
let dropin = hardening_dropin(&a);
assert!(
!dropin.contains("ProtectHome"),
"a --user unit's data root IS the caller's home, and ProtectHome= hides \
it:\n{dropin}"
);
assert!(
dropin.contains("CapabilityBoundingSet=\n"),
"dropping ProtectHome must not drop the rest of the block:\n{dropin}"
);
assert!(
dropin.contains(&format!(
"ReadWritePaths={}",
crate::cli::default_data_root().display()
)),
"a --user unit must be able to write the root it was told to use:\n{dropin}"
);
}
/// The drop-in has to be somewhere systemd reads it — beside the unit, in
/// the same directory Skidbladnir writes the unit into, with the `.d`
/// suffix systemd looks for. A correct file in the wrong place hardens
/// nothing and looks exactly like a correct one from the CLI's output.
#[test]
fn the_dropin_lands_beside_the_unit_systemd_will_read() {
let a = args();
let system = dropin_dir(&a).expect("a system-scope drop-in needs no environment");
assert_eq!(
system,
PathBuf::from("/etc/systemd/system/gunnar.service.d"),
"systemd reads <unit>.d/ next to the unit, and Skidbladnir writes the \
system unit into /etc/systemd/system"
);
let mut user = a;
user.user = true;
let dir = dropin_dir(&user).expect("$HOME is set in the test environment");
assert!(
dir.ends_with(".config/systemd/user/gunnar.service.d"),
"a --user unit's drop-in must sit beside the --user unit, not in /etc: {}",
dir.display()
);
}
/// An explicit `--data-root` outranks both defaults, in both scopes. Without
/// this, [`resolved_data_root`] could ignore the flag entirely and every
/// other test here would still pass.
#[test]
fn an_explicit_data_root_wins_in_either_scope() {
let mut a = args();
a.data_root = Some(PathBuf::from("/srv/gunnar"));
assert_eq!(resolved_data_root(&a), PathBuf::from("/srv/gunnar"));
a.user = true;
assert_eq!(resolved_data_root(&a), PathBuf::from("/srv/gunnar"));
}
}