//! systemctl + dpkg-query helpers for the FIPS daemon. //! //! Read-only queries (`is-active`, `--version`, `dpkg-query`) run as the //! archipelago service user. Write operations (`unmask`, `start`, `stop`, //! `restart`) go through `sudo`, matching the pattern established in //! `src/vpn.rs` and `src/api/rpc/vpn.rs`. The sudoers rule shipped in the //! ISO whitelists exactly these invocations. use anyhow::{Context, Result}; use nostr_sdk::ToBech32; use tokio::process::Command; use super::DAEMON_PUB_PATH; /// `systemctl is-active ` → "active" / "inactive" / "failed" / "masked" /// / "unknown". Never errors; returns "unknown" on any failure. pub async fn unit_state(unit: &str) -> String { match Command::new("systemctl") .args(["is-active", unit]) .output() .await { Ok(out) => { let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); if s.is_empty() { "unknown".to_string() } else { s } } Err(_) => "unknown".to_string(), } } /// Whether the `fips` debian package is installed on the host. pub async fn package_installed() -> bool { // dpkg-query -W -f='${Status}' fips → "install ok installed" when present. let out = Command::new("dpkg-query") .args(["-W", "-f=${Status}", "fips"]) .output() .await; match out { Ok(o) if o.status.success() => { String::from_utf8_lossy(&o.stdout).contains("install ok installed") } _ => false, } } /// `fipsctl --version` output stripped of the "fipsctl " prefix if present. pub async fn daemon_version() -> Result { let out = Command::new("fipsctl") .arg("--version") .output() .await .context("fipsctl --version failed to launch")?; if !out.status.success() { anyhow::bail!("fipsctl exited with non-zero status"); } let raw = String::from_utf8_lossy(&out.stdout).trim().to_string(); Ok(raw .strip_prefix("fipsctl ") .map(|s| s.to_string()) .unwrap_or(raw)) } /// `sudo systemctl ` — returns stderr on non-zero exit. async fn sudo_systemctl(verb: &str, unit: &str) -> Result<()> { let out = Command::new("sudo") .args(["systemctl", verb, unit]) .output() .await .with_context(|| format!("sudo systemctl {} {} failed to launch", verb, unit))?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); anyhow::bail!("systemctl {} {}: {}", verb, unit, stderr); } Ok(()) } /// Unmask + start + enable the FIPS service. Idempotent — safe to call /// on every backend startup once the key is on disk. pub async fn activate(unit: &str) -> Result<()> { // Order matters: unmask before enable/start, otherwise enable fails // on a masked unit. sudo_systemctl("unmask", unit).await?; sudo_systemctl("enable", unit).await?; sudo_systemctl("start", unit).await?; Ok(()) } pub async fn stop(unit: &str) -> Result<()> { sudo_systemctl("stop", unit).await } pub async fn restart(unit: &str) -> Result<()> { sudo_systemctl("restart", unit).await } pub async fn mask(unit: &str) -> Result<()> { let _ = sudo_systemctl("stop", unit).await; let _ = sudo_systemctl("disable", unit).await; sudo_systemctl("mask", unit).await } /// Read the upstream daemon's public key at `/etc/fips/fips.pub` and return /// it as a bech32 npub. Returns `Ok(None)` if the file doesn't exist — used /// as a fallback on legacy/dev nodes where no seed-derived key exists. /// /// Upstream writes the key as a bech32 string (`npub1…`); older builds may /// have written 32 raw bytes, so we accept either form. pub async fn read_upstream_npub() -> Result> { let bytes = match tokio::fs::read(DAEMON_PUB_PATH).await { Ok(b) => b, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(e).context("read /etc/fips/fips.pub"), }; if let Ok(s) = std::str::from_utf8(&bytes) { let trimmed = s.trim(); if trimmed.starts_with("npub1") { if let Ok(pk) = nostr_sdk::PublicKey::parse(trimmed) { return Ok(pk.to_bech32().ok()); } } } let pk = nostr_sdk::PublicKey::from_slice(&bytes) .context("parse /etc/fips/fips.pub as secp256k1 public key")?; Ok(pk.to_bech32().ok()) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_unit_state_returns_string_for_bogus_unit() { // Nonexistent unit: systemctl returns "inactive" or "unknown" — we // just care that the helper doesn't panic and returns *something*. let s = unit_state("archipelago-bogus-test.service").await; assert!(!s.is_empty()); } #[tokio::test] async fn test_package_installed_is_bool() { // Must not panic regardless of host state. let _ = package_installed().await; } }