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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
152
neode-ui/src/views/server/FipsNetworkCard.vue
Normal file
152
neode-ui/src/views/server/FipsNetworkCard.vue
Normal 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>
|
||||
Reference in New Issue
Block a user