From 6b42bfd503839cc263596d9d9672a1e9a10c9fda Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 19 Apr 2026 00:42:56 -0400 Subject: [PATCH] fix(fips): fall back to upstream daemon npub on legacy/dev nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- core/archipelago/src/api/rpc/router.rs | 1 + core/archipelago/src/fips/mod.rs | 33 +++- core/archipelago/src/fips/service.rs | 28 +++ core/archipelago/src/fips/update.rs | 74 +++++--- core/archipelago/src/network/router.rs | 31 ++++ neode-ui/src/views/Home.vue | 26 +++ neode-ui/src/views/Server.vue | 48 +++++- neode-ui/src/views/home/HomeNetworkCard.vue | 160 ------------------ neode-ui/src/views/server/FipsNetworkCard.vue | 152 +++++++++++++++++ 9 files changed, 356 insertions(+), 197 deletions(-) delete mode 100644 neode-ui/src/views/home/HomeNetworkCard.vue create mode 100644 neode-ui/src/views/server/FipsNetworkCard.vue diff --git a/core/archipelago/src/api/rpc/router.rs b/core/archipelago/src/api/rpc/router.rs index 90012d34..a077b466 100644 --- a/core/archipelago/src/api/rpc/router.rs +++ b/core/archipelago/src/api/rpc/router.rs @@ -100,6 +100,7 @@ impl RpcHandler { "tor_connected": diag.tor_connected, "dns_working": diag.dns_working, "recommendations": diag.recommendations, + "wifi_ssid": diag.wifi_ssid, })) } diff --git a/core/archipelago/src/fips/mod.rs b/core/archipelago/src/fips/mod.rs index 53d61f64..e853bf9e 100644 --- a/core/archipelago/src/fips/mod.rs +++ b/core/archipelago/src/fips/mod.rs @@ -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, } @@ -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, diff --git a/core/archipelago/src/fips/service.rs b/core/archipelago/src/fips/service.rs index 56603346..94845ed2 100644 --- a/core/archipelago/src/fips/service.rs +++ b/core/archipelago/src/fips/service.rs @@ -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 ` → "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> { + 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::*; diff --git a/core/archipelago/src/fips/update.rs b/core/archipelago/src/fips/update.rs index f0605c17..83e68218 100644 --- a/core/archipelago/src/fips/update.rs +++ b/core/archipelago/src/fips/update.rs @@ -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 { 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::(); let update_available = match ¤t { @@ -50,12 +59,13 @@ pub async fn check() -> Result { 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 { - 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 { + 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 { + 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 { .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)] diff --git a/core/archipelago/src/network/router.rs b/core/archipelago/src/network/router.rs index 45a2ab8c..a8620842 100644 --- a/core/archipelago/src/network/router.rs +++ b/core/archipelago/src/network/router.rs @@ -203,6 +203,35 @@ pub struct NetworkDiagnostics { pub tor_connected: bool, pub dns_working: bool, pub recommendations: Vec, + /// 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, +} + +/// 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 { + 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 { 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 { tor_connected, dns_working, recommendations, + wifi_ssid, }) } diff --git a/neode-ui/src/views/Home.vue b/neode-ui/src/views/Home.vue index 011ee21e..54c45d1a 100644 --- a/neode-ui/src/views/Home.vue +++ b/neode-ui/src/views/Home.vue @@ -151,6 +151,10 @@
Bitcoin
{{ bitcoinSyncDisplay }} +
+
FIPS
+ {{ fipsStatusLabel }} +
{{ t('home.manageNetwork') }} @@ -313,6 +317,27 @@ const torConnected = computed(() => { }) const vpnStatus = ref({ connected: false, provider: '' }) const vpnConnected = computed(() => vpnStatus.value.connected || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running)) +const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null) +const fipsDotClass = computed(() => { + const s = fipsStatus.value + if (!s || !s.installed) return 'bg-white/40' + if (s.service_active) return 'bg-green-400' + return 'bg-white/40' +}) +const fipsTextClass = computed(() => { + const s = fipsStatus.value + if (!s || !s.installed) return 'text-white/40' + if (s.service_active) return 'text-green-400' + return 'text-white/40' +}) +const fipsStatusLabel = computed(() => { + const s = fipsStatus.value + if (!s) return '…' + if (!s.installed) return 'Not installed' + if (s.service_active) return 'Active' + if (!s.key_present) return 'Awaiting seed' + return 'Inactive' +}) const bitcoinSyncDisplay = computed(() => { if (!systemStats.bitcoinAvailable) return 'Not running' if (systemStats.bitcoinSyncPercent >= 99.9) return 'Synced' @@ -349,6 +374,7 @@ onMounted(async () => { try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ } loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 30000); checkUpdateStatus(); loadWeb5Status() rpcClient.vpnStatus().then(s => { vpnStatus.value = { connected: s.connected, provider: s.provider ?? '' } }).catch(() => {}) + rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean }>({ method: 'fips.status' }).then(s => { fipsStatus.value = s }).catch(() => {}) }) // Wallet modals diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue index e7bad830..ee4d3bdb 100644 --- a/neode-ui/src/views/Server.vue +++ b/neode-ui/src/views/Server.vue @@ -89,10 +89,10 @@
- - WiFi Networks + + WiFi
- {{ networkData.wifiCount }} + {{ networkData.wifiSsid || 'Not connected' }}
@@ -126,6 +126,13 @@ {{ dnsDisplayLabel }} +
+
+ + FIPS Mesh +
+ {{ fipsRowLabel }} +
@@ -160,6 +167,11 @@
+ +
+ +
+
@@ -377,6 +389,7 @@ import { useAppStore } from '@/stores/app' import QuickActionsCard from './server/QuickActionsCard.vue' import TorServicesCard from './server/TorServicesCard.vue' import ServerModals from './server/ServerModals.vue' +import FipsNetworkCard from './server/FipsNetworkCard.vue' import type { TorServiceInfo } from './server/TorServicesCard.vue' const appStore = useAppStore() @@ -401,11 +414,34 @@ const logCount = ref(0) // Network data const networkLoading = ref(true) const networkData = ref({ - wifiCount: 'N/A', torConnected: false, forwardCount: 'N/A', + wifiCount: 'N/A', wifiSsid: null as string | null, torConnected: false, forwardCount: 'N/A', vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0, dnsProvider: 'system', dnsServers: [] as string[], dnsDoH: false, }) +// FIPS status row for the Local Network card. Full FIPS card lives below. +const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null) +const fipsRowLabel = computed(() => { + const s = fipsSummary.value + if (!s) return '…' + if (!s.installed) return 'Not installed' + // Service-active wins even on legacy nodes with no seed-derived key. + if (s.service_active) return 'Active' + if (!s.key_present) return 'Awaiting seed' + return 'Inactive' +}) +const fipsRowTextClass = computed(() => { + const s = fipsSummary.value + if (!s || !s.installed) return 'text-white/40' + if (s.service_active) return 'text-green-400' + return 'text-white/60' +}) +async function loadFipsSummary() { + try { + fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean }>({ method: 'fips.status' }) + } catch { /* backend too old */ } +} + async function loadNetworkData() { networkLoading.value = true try { @@ -415,7 +451,7 @@ async function loadNetworkData() { rpcClient.vpnStatus(), rpcClient.dnsStatus(), ]) - if (diagRes.status === 'fulfilled') { networkData.value.torConnected = diagRes.value.tor_connected; networkData.value.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A' } + if (diagRes.status === 'fulfilled') { networkData.value.torConnected = diagRes.value.tor_connected; networkData.value.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A'; networkData.value.wifiSsid = (diagRes.value as { wifi_ssid?: string | null }).wifi_ssid ?? null } if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; networkData.value.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` } if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); networkData.value.wgIp = vpnRes.value.wg_ip ?? ''; networkData.value.wgPubkey = (vpnRes.value as Record).wg_pubkey as string ?? '' } if (dnsRes.status === 'fulfilled') { networkData.value.dnsProvider = dnsRes.value.provider; networkData.value.dnsServers = dnsRes.value.resolv_conf_servers ?? []; networkData.value.dnsDoH = dnsRes.value.doh_enabled } @@ -672,7 +708,7 @@ async function createService(name: string, port: number | null) { catch (e) { addServiceError.value = e instanceof Error ? e.message : 'Failed to create service' } finally { addingService.value = false } } -onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers() }) +onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers(); loadFipsSummary() }) // Poll VPN status every 15s so IP updates after pairing const vpnPollInterval = setInterval(async () => { diff --git a/neode-ui/src/views/home/HomeNetworkCard.vue b/neode-ui/src/views/home/HomeNetworkCard.vue deleted file mode 100644 index 703194a7..00000000 --- a/neode-ui/src/views/home/HomeNetworkCard.vue +++ /dev/null @@ -1,160 +0,0 @@ - - - diff --git a/neode-ui/src/views/server/FipsNetworkCard.vue b/neode-ui/src/views/server/FipsNetworkCard.vue new file mode 100644 index 00000000..099b00a0 --- /dev/null +++ b/neode-ui/src/views/server/FipsNetworkCard.vue @@ -0,0 +1,152 @@ + + +