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:
Dorian
2026-04-19 00:42:56 -04:00
parent 30a7f73ead
commit 6b42bfd503
9 changed files with 356 additions and 197 deletions

View File

@@ -100,6 +100,7 @@ impl RpcHandler {
"tor_connected": diag.tor_connected,
"dns_working": diag.dns_working,
"recommendations": diag.recommendations,
"wifi_ssid": diag.wifi_ssid,
}))
}

View File

@@ -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,

View File

@@ -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::*;

View File

@@ -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 12 (the "is there anything newer?" query) and stubs
//! out 35 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 12 (the "is there anything newer?" query)
//! and stubs out 35 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 &current {
@@ -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)]

View File

@@ -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,
})
}

View File

@@ -151,6 +151,10 @@
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="systemStats.bitcoinAvailable ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">Bitcoin</span></div>
<span class="text-sm font-medium" :class="systemStats.bitcoinAvailable ? 'text-orange-400' : 'text-white/40'">{{ bitcoinSyncDisplay }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="fipsDotClass"></div><span class="text-sm text-white/80">FIPS</span></div>
<span class="text-sm font-medium" :class="fipsTextClass">{{ fipsStatusLabel }}</span>
</div>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/server" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.manageNetwork') }}</RouterLink>
@@ -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

View File

@@ -89,10 +89,10 @@
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
<span class="text-white/80 text-sm">WiFi Networks</span>
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" /></svg>
<span class="text-white/80 text-sm">WiFi</span>
</div>
<span class="text-white/60 text-sm">{{ networkData.wifiCount }}</span>
<span class="text-sm" :class="networkData.wifiSsid ? 'text-green-400' : 'text-white/40'">{{ networkData.wifiSsid || 'Not connected' }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
@@ -126,6 +126,13 @@
{{ dnsDisplayLabel }}
</span>
</button>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
<span class="text-white/80 text-sm">FIPS Mesh</span>
</div>
<span class="text-sm" :class="fipsRowTextClass">{{ fipsRowLabel }}</span>
</div>
</template>
</div>
@@ -160,6 +167,11 @@
</div>
</div>
<!-- FIPS Mesh (full card) -->
<div class="mb-8">
<FipsNetworkCard />
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
<!-- VPN Card -->
<div class="glass-card p-6 transition-all hover:-translate-y-1">
@@ -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<string, unknown>).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 () => {

View File

@@ -1,160 +0,0 @@
<template>
<div
data-controller-container
tabindex="0"
class="home-card controller-focusable"
:class="{ 'home-card-animate': animate }"
style="--card-stagger: 5"
>
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">Network</h2>
<p class="text-sm text-white/70">FIPS mesh preferred over Tor for peer traffic</p>
</div>
<div class="flex items-center gap-2" :title="statusLabel">
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
<span class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</span>
</div>
</div>
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Daemon version</p>
<p class="text-sm font-medium text-white break-all">{{ status.version || '—' }}</p>
<p v-if="!status.installed" class="text-xs text-white/40 mt-1">Package not installed</p>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">FIPS npub</p>
<p class="text-sm font-mono text-white break-all">{{ npubDisplay }}</p>
<p v-if="!status.key_present" class="text-xs text-white/40 mt-1">Unlock your seed to derive the FIPS key</p>
</div>
</div>
<div v-if="updateInfo" class="mb-3 p-3 bg-white/5 rounded-lg border-l-2 border-orange-400">
<p class="text-xs text-orange-400 font-medium mb-1">{{ updateInfo.update_available ? 'Update available' : 'Up to date' }}</p>
<p class="text-xs text-white/70 break-all">{{ updateInfo.notes }}</p>
</div>
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<button
class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors"
:disabled="checking"
@click="checkForUpdate"
>{{ checking ? 'Checking…' : 'Check for update' }}</button>
<button
v-if="status.key_present && !status.service_active"
class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors"
:disabled="installing"
@click="installAndActivate"
>{{ installing ? 'Installing…' : 'Activate' }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
defineProps<{ animate: boolean }>()
interface FipsStatus {
installed: boolean
version: string | null
service_state: string
service_active: boolean
key_present: boolean
npub: string | null
}
interface UpdateCheck {
current: string | null
latest_commit: string
update_available: boolean
notes: string
}
const status = ref<FipsStatus>({
installed: false,
version: null,
service_state: 'unknown',
service_active: false,
key_present: false,
npub: null,
})
const updateInfo = ref<UpdateCheck | null>(null)
const checking = ref(false)
const installing = ref(false)
const statusMessage = ref('')
const statusIsError = ref(false)
const statusLabel = computed(() => {
if (!status.value.installed) return 'not installed'
if (!status.value.key_present) return 'awaiting seed'
if (status.value.service_active) return 'active'
return status.value.service_state
})
const statusDotColor = computed(() => {
if (status.value.service_active) return 'bg-green-400'
if (!status.value.installed || !status.value.key_present) return 'bg-white/30'
return 'bg-orange-400'
})
const statusTextColor = computed(() => {
if (status.value.service_active) return 'text-green-400'
if (!status.value.installed || !status.value.key_present) return 'text-white/50'
return 'text-orange-400'
})
const npubDisplay = computed(() => {
const n = status.value.npub
if (!n) return '—'
return n.length > 20 ? `${n.slice(0, 12)}${n.slice(-6)}` : n
})
function flash(msg: string, isError = false) {
statusMessage.value = msg
statusIsError.value = isError
setTimeout(() => { statusMessage.value = '' }, 6000)
}
async function loadStatus() {
try {
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.status' })
} catch (e) {
if (import.meta.env.DEV) console.warn('fips.status failed', e)
}
}
async function checkForUpdate() {
checking.value = true
try {
updateInfo.value = await rpcClient.call<UpdateCheck>({ method: 'fips.check-update' })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Update check failed: ${msg}`, true)
} finally {
checking.value = false
}
}
async function installAndActivate() {
installing.value = true
try {
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
flash('FIPS installed and activated')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Install failed: ${msg}`, true)
} finally {
installing.value = false
}
}
onMounted(loadStatus)
</script>

View File

@@ -0,0 +1,152 @@
<template>
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="flex-1">
<div class="flex items-start justify-between gap-4 mb-2">
<h2 class="text-xl font-semibold text-white">FIPS Mesh</h2>
<div class="flex items-center gap-2" :title="statusLabel">
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
<span class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</span>
</div>
</div>
<p class="text-white/70 text-sm mb-4">Fast Nostr-keyed mesh routing</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3 flex-1 min-h-0">
<div class="p-3 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Daemon version</p>
<p class="text-sm font-medium text-white break-all">{{ status.version || '—' }}</p>
<p v-if="!status.installed" class="text-xs text-white/40 mt-1">Package not installed</p>
</div>
<div class="p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between gap-2 mb-1">
<p class="text-xs text-white/60">FIPS npub</p>
<button
v-if="status.npub"
type="button"
class="text-xs text-white/60 hover:text-white transition-colors flex items-center gap-1"
:title="copied ? 'Copied!' : 'Copy full npub to clipboard'"
@click="copyNpub"
>
<svg v-if="!copied" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
<svg v-else class="w-3.5 h-3.5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
<span :class="{ 'text-green-400': copied }">{{ copied ? 'Copied' : 'Copy' }}</span>
</button>
</div>
<p class="text-sm font-mono text-white break-all select-all">{{ npubDisplay }}</p>
<p v-if="!status.key_present && status.npub" class="text-xs text-white/40 mt-1">Upstream key (not seed-derived)</p>
<p v-else-if="!status.key_present" class="text-xs text-white/40 mt-1">Unlock seed to derive archipelago-managed key</p>
</div>
</div>
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
<div v-if="status.key_present && !status.service_active" class="flex gap-2 mt-auto pt-3 shrink-0">
<button class="flex-1 min-h-[44px] px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors" :disabled="installing" @click="installAndActivate">{{ installing ? 'Installing' : 'Activate' }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { safeClipboardWrite } from '@/views/web5/utils'
interface FipsStatus {
installed: boolean
version: string | null
service_state: string
upstream_service_state: string
service_active: boolean
key_present: boolean
npub: string | null
}
const status = ref<FipsStatus>({
installed: false,
version: null,
service_state: 'unknown',
upstream_service_state: 'unknown',
service_active: false,
key_present: false,
npub: null,
})
const installing = ref(false)
const statusMessage = ref('')
const statusIsError = ref(false)
const copied = ref(false)
async function copyNpub() {
if (!status.value.npub) return
try {
await safeClipboardWrite(status.value.npub)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Copy failed: ${msg}`, true)
}
}
const statusLabel = computed(() => {
if (!status.value.installed) return 'not installed'
// Active takes precedence: the daemon may be running from its own upstream
// key on a legacy/dev node that doesn't have a seed-derived archipelago key.
if (status.value.service_active) return 'active'
if (!status.value.key_present) return 'awaiting seed'
return status.value.service_state
})
const statusDotColor = computed(() => {
if (status.value.service_active) return 'bg-green-400'
if (!status.value.installed || !status.value.key_present) return 'bg-white/30'
return 'bg-orange-400'
})
const statusTextColor = computed(() => {
if (status.value.service_active) return 'text-green-400'
if (!status.value.installed || !status.value.key_present) return 'text-white/50'
return 'text-orange-400'
})
const npubDisplay = computed(() => {
const n = status.value.npub
if (!n) return '—'
return n.length > 20 ? `${n.slice(0, 12)}${n.slice(-6)}` : n
})
function flash(msg: string, isError = false) {
statusMessage.value = msg
statusIsError.value = isError
setTimeout(() => { statusMessage.value = '' }, 6000)
}
async function loadStatus() {
try {
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.status' })
} catch (e) {
if (import.meta.env.DEV) console.warn('fips.status failed', e)
}
}
async function installAndActivate() {
installing.value = true
try {
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
flash('FIPS installed and activated')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Install failed: ${msg}`, true)
} finally {
installing.value = false
}
}
onMounted(loadStatus)
</script>