Dorian becdb1af5a fix(fips): fall back to upstream daemon npub on legacy/dev nodes
Nodes without a seed-derived FIPS key (legacy deploys, fresh pre-onboarding
installs) were reporting "Awaiting seed" in the dashboard even when the
upstream fips.service was running — status.npub was None unless
/data/identity/fips_key.pub existed.

- fips/service.rs: new read_upstream_npub() reads /etc/fips/fips.pub
  (bech32 text or raw 32 bytes) from the debian package.
- fips/mod.rs: FipsStatus::current() prefers the seed-derived npub,
  falls back to the upstream key. service_active is now TRUE if either
  archipelago-fips.service OR upstream fips.service is active; adds
  upstream_service_state to the status payload.
- fips/update.rs: resolve the upstream default branch from the GitHub
  repo API (jmcorgan/fips is on `master`, not `main`) instead of
  hardcoding — future repo rename just works.
- network/router.rs + api/rpc/router.rs: diagnostics gain wifi_ssid from
  `nmcli -t device` so the Network card can show the connected SSID.
- UI: Home.vue adds a FIPS row to the Local Network card; Server.vue
  mounts the new FipsNetworkCard and shows SSID + FIPS Mesh rows;
  HomeNetworkCard.vue removed (superseded by the inline rows).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:42:56 -04:00

149 lines
5.0 KiB
Rust

//! 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 <unit>` → "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<String> {
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 <verb> <unit>` — 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<Option<String>> {
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;
}
}