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>
This commit is contained in:
@@ -100,6 +100,7 @@ impl RpcHandler {
|
||||
"tor_connected": diag.tor_connected,
|
||||
"dns_working": diag.dns_working,
|
||||
"recommendations": diag.recommendations,
|
||||
"wifi_ssid": diag.wifi_ssid,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ pub const UPSTREAM_REPO: &str = "jmcorgan/fips";
|
||||
/// Default UDP port the daemon listens on.
|
||||
pub const DEFAULT_UDP_PORT: u16 = 8668;
|
||||
|
||||
/// Upstream systemd unit shipped by the `fips` debian package. Archipelago
|
||||
/// prefers its own supervision (`archipelago-fips.service`) but respects an
|
||||
/// already-running upstream unit so legacy/dev nodes — where no seed-derived
|
||||
/// key exists — still report FIPS as active in the UI.
|
||||
pub const UPSTREAM_SERVICE_UNIT: &str = "fips.service";
|
||||
|
||||
/// Aggregated runtime status of the FIPS subsystem, surfaced to the dashboard.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FipsStatus {
|
||||
@@ -61,12 +67,18 @@ pub struct FipsStatus {
|
||||
/// `systemctl is-active archipelago-fips.service` result: "active",
|
||||
/// "inactive", "failed", "masked", "unknown".
|
||||
pub service_state: String,
|
||||
/// True iff service_state == "active".
|
||||
/// State of the upstream `fips.service` (shipped by the debian package).
|
||||
pub upstream_service_state: String,
|
||||
/// True if either the archipelago-managed or upstream unit is active.
|
||||
pub service_active: bool,
|
||||
/// Whether the seed-derived FIPS key has been materialised on disk.
|
||||
/// The service cannot start meaningfully until this is true.
|
||||
/// The archipelago-managed service cannot start meaningfully until
|
||||
/// this is true; legacy nodes may still report FIPS active via the
|
||||
/// upstream unit without this file.
|
||||
pub key_present: bool,
|
||||
/// Local FIPS npub (bech32), present only once the key is on disk.
|
||||
/// Local FIPS npub (bech32). Prefers the seed-derived key when
|
||||
/// present; falls back to the upstream daemon's own key on legacy
|
||||
/// nodes where `/etc/fips/fips.pub` is readable.
|
||||
pub npub: Option<String>,
|
||||
}
|
||||
|
||||
@@ -80,16 +92,23 @@ impl FipsStatus {
|
||||
None
|
||||
};
|
||||
let service_state = service::unit_state(SERVICE_UNIT).await;
|
||||
let service_active = service_state == "active";
|
||||
let upstream_service_state = service::unit_state(UPSTREAM_SERVICE_UNIT).await;
|
||||
let service_active =
|
||||
service_state == "active" || upstream_service_state == "active";
|
||||
let key_present = crate::identity::fips_key_exists(identity_dir);
|
||||
let npub = crate::identity::fips_npub(identity_dir)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
// Prefer the seed-derived npub; otherwise read the daemon's own
|
||||
// key file at /etc/fips/fips.pub (world-readable per debian pkg).
|
||||
let npub = match crate::identity::fips_npub(identity_dir).await {
|
||||
Ok(Some(n)) => Some(n),
|
||||
_ => service::read_upstream_npub().await.ok().flatten(),
|
||||
};
|
||||
|
||||
Self {
|
||||
installed,
|
||||
version,
|
||||
service_state,
|
||||
upstream_service_state,
|
||||
service_active,
|
||||
key_present,
|
||||
npub,
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
//! 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 {
|
||||
@@ -100,6 +103,31 @@ pub async fn mask(unit: &str) -> Result<()> {
|
||||
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::*;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! User-triggered FIPS upgrade from upstream `main`.
|
||||
//! User-triggered FIPS upgrade from the upstream default branch.
|
||||
//!
|
||||
//! Flow (no auto-update, no background polling — user clicks a button):
|
||||
//! 1. Query GitHub for the latest commit on `main` of jmcorgan/fips.
|
||||
//! 1. Query GitHub for the upstream repo's default branch, then the
|
||||
//! latest commit on it. (jmcorgan/fips default is `master`, not
|
||||
//! `main` — we resolve it dynamically so a future rename Just Works.)
|
||||
//! 2. Compare with the installed daemon version reported by
|
||||
//! `fipsctl --version`. If identical, report "up to date".
|
||||
//! 3. Fetch the built .deb artefact for that commit + its SHA256.
|
||||
@@ -9,10 +11,11 @@
|
||||
//! 5. `sudo dpkg -i` the .deb, `sudo systemctl restart` the service.
|
||||
//!
|
||||
//! The artefact URL / SHA256 source is not yet fixed — upstream doesn't
|
||||
//! publish stable release assets for `main` builds. This module currently
|
||||
//! implements steps 1–2 (the "is there anything newer?" query) and stubs
|
||||
//! out 3–5 so the RPC/UI can wire through. The apply path returns a
|
||||
//! clear "not yet available" error until the artefact source is decided.
|
||||
//! publish stable release assets for per-commit builds. This module
|
||||
//! currently implements steps 1–2 (the "is there anything newer?" query)
|
||||
//! and stubs out 3–5 so the RPC/UI can wire through. The apply path
|
||||
//! returns a clear "not yet available" error until the artefact source
|
||||
//! is decided.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -35,12 +38,18 @@ pub struct UpdateCheck {
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
/// Query GitHub for the latest commit on `main` and compare to the
|
||||
/// installed version. Never errors on "no package installed" — that is
|
||||
/// itself a valid state where an update is available (install needed).
|
||||
/// Query GitHub for the latest commit on the upstream default branch and
|
||||
/// compare to the installed version. Never errors on "no package installed"
|
||||
/// — that is itself a valid state where an update is available.
|
||||
pub async fn check() -> Result<UpdateCheck> {
|
||||
let current = service::daemon_version().await.ok();
|
||||
let latest = fetch_latest_main_sha().await?;
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.context("Build HTTP client")?;
|
||||
let branch = fetch_default_branch(&client).await?;
|
||||
let latest = fetch_head_sha(&client, &branch).await?;
|
||||
let short = latest.chars().take(7).collect::<String>();
|
||||
|
||||
let update_available = match ¤t {
|
||||
@@ -50,12 +59,13 @@ pub async fn check() -> Result<UpdateCheck> {
|
||||
|
||||
let notes = if update_available {
|
||||
format!(
|
||||
"Upstream main is at {}; installed: {}",
|
||||
"Upstream {} is at {}; installed: {}",
|
||||
branch,
|
||||
short,
|
||||
current.as_deref().unwrap_or("not installed")
|
||||
)
|
||||
} else {
|
||||
format!("Up to date ({})", short)
|
||||
format!("Up to date ({} @ {})", branch, short)
|
||||
};
|
||||
|
||||
Ok(UpdateCheck {
|
||||
@@ -77,13 +87,26 @@ pub async fn apply() -> Result<()> {
|
||||
)
|
||||
}
|
||||
|
||||
async fn fetch_latest_main_sha() -> Result<String> {
|
||||
let url = format!("{}/repos/{}/commits/main", GITHUB_API, UPSTREAM_REPO);
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.context("Build HTTP client")?;
|
||||
async fn fetch_default_branch(client: &reqwest::Client) -> Result<String> {
|
||||
let url = format!("{}/repos/{}", GITHUB_API, UPSTREAM_REPO);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await
|
||||
.context("GitHub repo API")?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("GitHub repo API returned {}", resp.status());
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.context("Parse repo JSON")?;
|
||||
body.get("default_branch")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("GitHub repo response missing default_branch"))
|
||||
}
|
||||
|
||||
async fn fetch_head_sha(client: &reqwest::Client, branch: &str) -> Result<String> {
|
||||
let url = format!("{}/repos/{}/commits/{}", GITHUB_API, UPSTREAM_REPO, branch);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
@@ -91,14 +114,17 @@ async fn fetch_latest_main_sha() -> Result<String> {
|
||||
.await
|
||||
.context("GitHub commits API")?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("GitHub API returned {}", resp.status());
|
||||
anyhow::bail!(
|
||||
"GitHub commits API returned {} for branch {}",
|
||||
resp.status(),
|
||||
branch
|
||||
);
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.context("Parse commits JSON")?;
|
||||
let sha = body
|
||||
.get("sha")
|
||||
body.get("sha")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("GitHub commits response missing sha field"))?;
|
||||
Ok(sha.to_string())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("GitHub commits response missing sha field"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -203,6 +203,35 @@ pub struct NetworkDiagnostics {
|
||||
pub tor_connected: bool,
|
||||
pub dns_working: bool,
|
||||
pub recommendations: Vec<String>,
|
||||
/// SSID of the currently-active WiFi connection, or None if the node is on
|
||||
/// wired / no WiFi adapter / NetworkManager isn't around.
|
||||
pub wifi_ssid: Option<String>,
|
||||
}
|
||||
|
||||
/// Ask NetworkManager for the active WiFi SSID. Returns None silently if
|
||||
/// nmcli is unavailable or no WiFi device is connected.
|
||||
async fn active_wifi_ssid() -> Option<String> {
|
||||
let out = tokio::process::Command::new("nmcli")
|
||||
.args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
for line in stdout.lines() {
|
||||
// DEVICE:TYPE:STATE:CONNECTION — colons inside fields are escaped by nmcli -t
|
||||
let mut parts = line.split(':');
|
||||
let _dev = parts.next()?;
|
||||
let typ = parts.next().unwrap_or("");
|
||||
let state = parts.next().unwrap_or("");
|
||||
let conn = parts.next().unwrap_or("");
|
||||
if typ == "wifi" && state == "connected" && !conn.is_empty() {
|
||||
return Some(conn.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Run a comprehensive network diagnostic check.
|
||||
@@ -211,6 +240,7 @@ pub async fn run_diagnostics() -> Result<NetworkDiagnostics> {
|
||||
let upnp_available = check_upnp_available().await;
|
||||
let tor_connected = check_tor_connectivity().await;
|
||||
let dns_working = check_dns().await;
|
||||
let wifi_ssid = active_wifi_ssid().await;
|
||||
|
||||
let nat_type = if wan_ip.is_some() {
|
||||
if upnp_available {
|
||||
@@ -246,6 +276,7 @@ pub async fn run_diagnostics() -> Result<NetworkDiagnostics> {
|
||||
tor_connected,
|
||||
dns_working,
|
||||
recommendations,
|
||||
wifi_ssid,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user