release(v1.7.21-alpha): operator-editable FIPS seed anchors
Adds a local seed-anchor list at <data_dir>/seed-anchors.json. Each
entry is {npub, address, transport, label}. On archipelago startup
and every 5 minutes the list is pushed into the running fips daemon
via `fipsctl connect <npub> <addr> <transport>`, so a cluster can
anchor itself independently of the global fips.v0l.io. A flaky or
unreachable public anchor no longer strands a fresh install.
New RPCs:
- fips.list-seed-anchors
- fips.add-seed-anchor (validates npub1… + host:port)
- fips.remove-seed-anchor
- fips.apply-seed-anchors (on-demand re-dial)
New standalone UI card at views/server/FipsSeedAnchorsCard.vue. Not
wired into Home.vue / Server.vue — operator places it per the
entry-point convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4d8a9e66e3
commit
e88719df50
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.20-alpha"
|
version = "1.7.21-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.20-alpha"
|
version = "1.7.21-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
authors = ["Archipelago Team"]
|
authors = ["Archipelago Team"]
|
||||||
|
|||||||
@ -414,6 +414,16 @@ impl RpcHandler {
|
|||||||
"fips.install" => self.handle_fips_install().await,
|
"fips.install" => self.handle_fips_install().await,
|
||||||
"fips.restart" => self.handle_fips_restart().await,
|
"fips.restart" => self.handle_fips_restart().await,
|
||||||
"fips.reconnect" => self.handle_fips_reconnect().await,
|
"fips.reconnect" => self.handle_fips_reconnect().await,
|
||||||
|
"fips.list-seed-anchors" => self.handle_fips_list_seed_anchors().await,
|
||||||
|
"fips.add-seed-anchor" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_fips_add_seed_anchor(&p).await
|
||||||
|
}
|
||||||
|
"fips.remove-seed-anchor" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_fips_remove_seed_anchor(&p).await
|
||||||
|
}
|
||||||
|
"fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().await,
|
||||||
|
|
||||||
// System updates
|
// System updates
|
||||||
"update.check" => self.handle_update_check().await,
|
"update.check" => self.handle_update_check().await,
|
||||||
|
|||||||
@ -129,4 +129,66 @@ impl RpcHandler {
|
|||||||
"after": after,
|
"after": after,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List the seed-anchor entries configured on this node.
|
||||||
|
pub(super) async fn handle_fips_list_seed_anchors(&self) -> Result<serde_json::Value> {
|
||||||
|
let list = fips::anchors::load(&self.config.data_dir).await?;
|
||||||
|
Ok(serde_json::json!({ "seed_anchors": list }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add (or update) a seed anchor and immediately push it into the
|
||||||
|
/// running daemon. Params: `{ npub, address, transport?, label? }`.
|
||||||
|
pub(super) async fn handle_fips_add_seed_anchor(
|
||||||
|
&self,
|
||||||
|
params: &serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let anchor: fips::anchors::SeedAnchor = serde_json::from_value(params.clone())
|
||||||
|
.map_err(|e| anyhow::anyhow!("bad seed anchor payload: {}", e))?;
|
||||||
|
if !anchor.npub.starts_with("npub1") {
|
||||||
|
anyhow::bail!("npub must be bech32 (npub1...)");
|
||||||
|
}
|
||||||
|
if !anchor.address.contains(':') {
|
||||||
|
anyhow::bail!("address must be host:port (e.g. 192.168.1.116:8668)");
|
||||||
|
}
|
||||||
|
let list =
|
||||||
|
fips::anchors::add(&self.config.data_dir, anchor.clone()).await?;
|
||||||
|
// Push just the newly-added anchor into the running daemon so
|
||||||
|
// the user sees effect without waiting for the periodic apply.
|
||||||
|
let results = fips::anchors::apply(&[anchor]).await;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"seed_anchors": list,
|
||||||
|
"apply": results.iter().map(|r| {
|
||||||
|
serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message })
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a seed anchor by npub. Params: `{ npub }`. Does NOT tear
|
||||||
|
/// down an already-authenticated peer connection — it only stops
|
||||||
|
/// us from re-dialing the anchor on the next apply cycle.
|
||||||
|
pub(super) async fn handle_fips_remove_seed_anchor(
|
||||||
|
&self,
|
||||||
|
params: &serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let npub = params
|
||||||
|
.get("npub")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing npub"))?;
|
||||||
|
let list = fips::anchors::remove(&self.config.data_dir, npub).await?;
|
||||||
|
Ok(serde_json::json!({ "seed_anchors": list }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-apply all seed anchors to the running daemon. Useful after a
|
||||||
|
/// FIPS restart or when the user wants to force a reconnection
|
||||||
|
/// attempt without waiting for the periodic apply loop.
|
||||||
|
pub(super) async fn handle_fips_apply_seed_anchors(&self) -> Result<serde_json::Value> {
|
||||||
|
let list = fips::anchors::load(&self.config.data_dir).await?;
|
||||||
|
let results = fips::anchors::apply(&list).await;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"applied": results.len(),
|
||||||
|
"results": results.iter().map(|r| {
|
||||||
|
serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message })
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
241
core/archipelago/src/fips/anchors.rs
Normal file
241
core/archipelago/src/fips/anchors.rs
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
//! Seed-anchor management for FIPS bootstrap.
|
||||||
|
//!
|
||||||
|
//! A freshly-installed node can't reach the global mesh via npub
|
||||||
|
//! routing until it's connected to at least one peer that's already in
|
||||||
|
//! the DHT. Upstream `fips` solves this by dialing a public anchor
|
||||||
|
//! (e.g. `fips.v0l.io`) on first start. That's a single point of
|
||||||
|
//! failure and doesn't help nodes behind restrictive firewalls or
|
||||||
|
//! intermittent networks — archipelago operators reported fresh
|
||||||
|
//! installs failing to reach any public anchor.
|
||||||
|
//!
|
||||||
|
//! This module adds a local, operator-editable seed-anchor list. Each
|
||||||
|
//! entry is a `{npub, address, transport}` triple that archipelago
|
||||||
|
//! pushes into the running daemon via `fipsctl connect` on startup and
|
||||||
|
//! periodically thereafter. If one anchor falls over, the next one
|
||||||
|
//! seeds the DHT instead. A well-configured cluster (e.g. a VPS
|
||||||
|
//! running fips in anchor mode + a couple of home nodes) stops
|
||||||
|
//! depending on the global anchor entirely.
|
||||||
|
//!
|
||||||
|
//! The list is persisted at `<data_dir>/seed-anchors.json`. The
|
||||||
|
//! archipelago service user owns that directory, so no sudo is needed
|
||||||
|
//! to read or write it.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
/// On-disk filename under `data_dir/`.
|
||||||
|
const SEED_ANCHORS_FILE: &str = "seed-anchors.json";
|
||||||
|
|
||||||
|
/// Public anchor (`fips.v0l.io`) carried as a default seed for fresh
|
||||||
|
/// installs — the one the upstream daemon dials anyway. Operators can
|
||||||
|
/// remove it from the UI once their own cluster has independent anchors.
|
||||||
|
pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
|
||||||
|
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
|
||||||
|
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "fips.v0l.io:8668";
|
||||||
|
|
||||||
|
/// One seed-anchor entry. `address` must be directly dialable (IP or
|
||||||
|
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
|
||||||
|
/// "tor", "ethernet" (the values upstream `fipsctl connect` accepts).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct SeedAnchor {
|
||||||
|
/// Bech32 `npub1...` of the anchor's FIPS identity.
|
||||||
|
pub npub: String,
|
||||||
|
/// Directly-dialable transport address, e.g. `192.168.1.116:8668`.
|
||||||
|
pub address: String,
|
||||||
|
/// Transport to use — almost always `"udp"`.
|
||||||
|
#[serde(default = "default_transport")]
|
||||||
|
pub transport: String,
|
||||||
|
/// Human-readable note shown in the UI (e.g. "Home anchor", "VPS").
|
||||||
|
#[serde(default)]
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_transport() -> String {
|
||||||
|
"udp".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn anchors_path(data_dir: &Path) -> PathBuf {
|
||||||
|
data_dir.join(SEED_ANCHORS_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the seed-anchor list. Returns an empty list if the file
|
||||||
|
/// doesn't exist yet — a first-boot node with no operator config.
|
||||||
|
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
|
||||||
|
let path = anchors_path(data_dir);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let bytes = tokio::fs::read(&path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("read {}", path.display()))?;
|
||||||
|
let anchors: Vec<SeedAnchor> = serde_json::from_slice(&bytes)
|
||||||
|
.with_context(|| format!("parse {}", path.display()))?;
|
||||||
|
Ok(anchors)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the list. Overwrites atomically via write-then-rename so a
|
||||||
|
/// crashed archipelago never leaves a half-written config.
|
||||||
|
pub async fn save(data_dir: &Path, anchors: &[SeedAnchor]) -> Result<()> {
|
||||||
|
tokio::fs::create_dir_all(data_dir)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("mkdir -p {}", data_dir.display()))?;
|
||||||
|
let path = anchors_path(data_dir);
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
let json = serde_json::to_vec_pretty(anchors).context("serialize seed anchors")?;
|
||||||
|
tokio::fs::write(&tmp, json)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("write {}", tmp.display()))?;
|
||||||
|
tokio::fs::rename(&tmp, &path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add (or update) one anchor, keyed by npub. Returns the resulting list.
|
||||||
|
pub async fn add(data_dir: &Path, anchor: SeedAnchor) -> Result<Vec<SeedAnchor>> {
|
||||||
|
let mut list = load(data_dir).await?;
|
||||||
|
if let Some(existing) = list.iter_mut().find(|a| a.npub == anchor.npub) {
|
||||||
|
*existing = anchor;
|
||||||
|
} else {
|
||||||
|
list.push(anchor);
|
||||||
|
}
|
||||||
|
save(data_dir, &list).await?;
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an anchor by npub. Returns the resulting list.
|
||||||
|
pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
|
||||||
|
let mut list = load(data_dir).await?;
|
||||||
|
list.retain(|a| a.npub != npub);
|
||||||
|
save(data_dir, &list).await?;
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the seed anchors to the running FIPS daemon. For each entry,
|
||||||
|
/// asks `fipsctl connect` to dial the peer. Errors are logged but don't
|
||||||
|
/// fail the whole operation — a single unreachable anchor shouldn't
|
||||||
|
/// block the others.
|
||||||
|
///
|
||||||
|
/// `fipsctl connect` is idempotent-ish: calling it for an already-
|
||||||
|
/// connected peer is a no-op at the protocol layer, so re-applying on
|
||||||
|
/// a timer is safe. Returns a list of per-anchor results for logging.
|
||||||
|
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||||
|
let mut results = Vec::with_capacity(anchors.len());
|
||||||
|
for anchor in anchors {
|
||||||
|
let out = Command::new("fipsctl")
|
||||||
|
.args([
|
||||||
|
"connect",
|
||||||
|
&anchor.npub,
|
||||||
|
&anchor.address,
|
||||||
|
&anchor.transport,
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
let result = match out {
|
||||||
|
Ok(o) if o.status.success() => ApplyResult {
|
||||||
|
npub: anchor.npub.clone(),
|
||||||
|
ok: true,
|
||||||
|
message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||||
|
},
|
||||||
|
Ok(o) => ApplyResult {
|
||||||
|
npub: anchor.npub.clone(),
|
||||||
|
ok: false,
|
||||||
|
message: format!(
|
||||||
|
"fipsctl exited {}: {}",
|
||||||
|
o.status,
|
||||||
|
String::from_utf8_lossy(&o.stderr).trim()
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(e) => ApplyResult {
|
||||||
|
npub: anchor.npub.clone(),
|
||||||
|
ok: false,
|
||||||
|
message: format!("fipsctl launch failed: {}", e),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if result.ok {
|
||||||
|
tracing::debug!(npub = %result.npub, "Seed anchor applied");
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
npub = %result.npub,
|
||||||
|
message = %result.message,
|
||||||
|
"Seed anchor apply failed (non-fatal)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a single `fipsctl connect` call.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApplyResult {
|
||||||
|
pub npub: String,
|
||||||
|
pub ok: bool,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn mk(npub: &str) -> SeedAnchor {
|
||||||
|
SeedAnchor {
|
||||||
|
npub: npub.to_string(),
|
||||||
|
address: "example.test:8668".to_string(),
|
||||||
|
transport: "udp".to_string(),
|
||||||
|
label: "test".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_missing_returns_empty() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let got = load(dir.path()).await.unwrap();
|
||||||
|
assert!(got.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_and_load_roundtrip() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let a = mk("npub1aaa");
|
||||||
|
let b = mk("npub1bbb");
|
||||||
|
save(dir.path(), &[a.clone(), b.clone()]).await.unwrap();
|
||||||
|
let got = load(dir.path()).await.unwrap();
|
||||||
|
assert_eq!(got, vec![a, b]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn add_replaces_existing_by_npub() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut a = mk("npub1aaa");
|
||||||
|
save(dir.path(), &[a.clone()]).await.unwrap();
|
||||||
|
a.address = "newhost:8668".to_string();
|
||||||
|
let list = add(dir.path(), a.clone()).await.unwrap();
|
||||||
|
assert_eq!(list.len(), 1);
|
||||||
|
assert_eq!(list[0].address, "newhost:8668");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remove_by_npub() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
save(
|
||||||
|
dir.path(),
|
||||||
|
&[mk("npub1aaa"), mk("npub1bbb"), mk("npub1ccc")],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let list = remove(dir.path(), "npub1bbb").await.unwrap();
|
||||||
|
assert_eq!(list.len(), 2);
|
||||||
|
assert!(list.iter().all(|a| a.npub != "npub1bbb"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seed_anchor_uses_udp_by_default() {
|
||||||
|
let json = r#"{"npub":"npub1x","address":"h:8668"}"#;
|
||||||
|
let a: SeedAnchor = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(a.transport, "udp");
|
||||||
|
assert_eq!(a.label, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,6 +25,7 @@
|
|||||||
// the module is deliberately API-ready ahead of those call-sites.
|
// the module is deliberately API-ready ahead of those call-sites.
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
pub mod anchors;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod dial;
|
pub mod dial;
|
||||||
pub mod iface;
|
pub mod iface;
|
||||||
|
|||||||
@ -353,6 +353,34 @@ impl Server {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIPS seed-anchor apply loop — every 5 minutes we re-push the
|
||||||
|
// configured seed anchors into the running fips daemon via
|
||||||
|
// `fipsctl connect`. This keeps the mesh bootstrap resilient:
|
||||||
|
// operators add cluster-local anchors in the UI, and a daemon
|
||||||
|
// restart or a flaky public anchor can't strand the node.
|
||||||
|
// First run is delayed 30s so fips has time to come up after
|
||||||
|
// onboarding before we start dialing.
|
||||||
|
{
|
||||||
|
let data_dir = config.data_dir.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
match crate::fips::anchors::load(&data_dir).await {
|
||||||
|
Ok(list) if !list.is_empty() => {
|
||||||
|
let _ = crate::fips::anchors::apply(&list).await;
|
||||||
|
}
|
||||||
|
Ok(_) => { /* no seed anchors configured yet */ }
|
||||||
|
Err(e) => tracing::debug!(
|
||||||
|
"Seed-anchor apply: load failed (non-fatal): {}",
|
||||||
|
e
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// did:dht auto-refresh — re-publish DHT records every 2 hours
|
// did:dht auto-refresh — re-publish DHT records every 2 hours
|
||||||
if config.nostr_discovery_enabled {
|
if config.nostr_discovery_enabled {
|
||||||
let data_dir = config.data_dir.clone();
|
let data_dir = config.data_dir.clone();
|
||||||
|
|||||||
169
neode-ui/src/views/server/FipsSeedAnchorsCard.vue
Normal file
169
neode-ui/src/views/server/FipsSeedAnchorsCard.vue
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<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="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04.054-.09A13.916 13.916 0 0 0 8 11a4 4 0 1 1 8 0c0 1.017-.07 2.019-.203 3M9.497 10.997 14 18m-9.41-3.41L4 18.5" />
|
||||||
|
</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 Seed Anchors</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors disabled:opacity-60"
|
||||||
|
:disabled="applying"
|
||||||
|
:title="applying ? 'Applying…' : 'Re-dial every anchor in the list'"
|
||||||
|
@click="applyAll"
|
||||||
|
>
|
||||||
|
{{ applying ? 'Applying…' : 'Apply now' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-white/70 text-sm mb-4">
|
||||||
|
Peers this node dials to bootstrap the FIPS mesh. A cluster with its own anchors doesn't depend on the global public anchor — if one is down, the next seeds the DHT instead.
|
||||||
|
</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="anchors.length === 0" class="p-4 rounded-lg bg-white/5 text-sm text-white/60 mb-3">
|
||||||
|
<p>No seed anchors configured. The daemon will fall back to whatever the upstream FIPS build dials on its own — usually the single public anchor, which is fine until it isn't.</p>
|
||||||
|
<p class="mt-2 text-white/50">Add at least one known-reachable peer (e.g. your VPS or a home node with port-forwarded UDP 8668) to make this cluster self-anchoring.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="space-y-2 mb-3">
|
||||||
|
<li v-for="a in anchors" :key="a.npub" class="p-3 bg-white/5 rounded-lg flex items-start gap-3">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-white truncate">{{ a.label || 'Unlabeled anchor' }}</p>
|
||||||
|
<p class="text-xs text-white/60 font-mono break-all">{{ a.npub.slice(0, 20) }}…{{ a.npub.slice(-8) }}</p>
|
||||||
|
<p class="text-xs text-white/50 mt-0.5">{{ a.address }} · {{ a.transport }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 text-xs px-2 py-1 rounded-md text-red-300 hover:bg-red-400/10 transition-colors"
|
||||||
|
:title="`Remove ${a.label || a.npub.slice(0, 12)}`"
|
||||||
|
@click="removeAnchor(a.npub)"
|
||||||
|
>Remove</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-auto pt-3 border-t border-white/10 shrink-0" @submit.prevent="addAnchor">
|
||||||
|
<label class="flex flex-col gap-1 sm:col-span-2">
|
||||||
|
<span class="text-xs text-white/60">Anchor npub</span>
|
||||||
|
<input v-model="draft.npub" type="text" placeholder="npub1…" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-white/60">Address (host:port)</span>
|
||||||
|
<input v-model="draft.address" type="text" placeholder="192.168.1.116:8668" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-white/60">Label (optional)</span>
|
||||||
|
<input v-model="draft.label" type="text" placeholder="Home anchor" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="sm:col-span-2 min-h-[44px] glass-button rounded-lg text-sm font-medium disabled:opacity-60" :disabled="adding || !draft.npub || !draft.address">{{ adding ? 'Adding…' : 'Add anchor' }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
|
interface SeedAnchor {
|
||||||
|
npub: string
|
||||||
|
address: string
|
||||||
|
transport: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApplyResult {
|
||||||
|
npub: string
|
||||||
|
ok: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchors = ref<SeedAnchor[]>([])
|
||||||
|
const adding = ref(false)
|
||||||
|
const applying = ref(false)
|
||||||
|
const statusMessage = ref('')
|
||||||
|
const statusIsError = ref(false)
|
||||||
|
|
||||||
|
const draft = reactive<Pick<SeedAnchor, 'npub' | 'address' | 'label'>>({
|
||||||
|
npub: '',
|
||||||
|
address: '',
|
||||||
|
label: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function flash(msg: string, isError = false) {
|
||||||
|
statusMessage.value = msg
|
||||||
|
statusIsError.value = isError
|
||||||
|
setTimeout(() => { statusMessage.value = '' }, 6000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({ method: 'fips.list-seed-anchors' })
|
||||||
|
anchors.value = res.seed_anchors
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (import.meta.env.DEV) console.warn('fips.list-seed-anchors failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAnchor() {
|
||||||
|
if (!draft.npub.trim() || !draft.address.trim()) return
|
||||||
|
adding.value = true
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[]; apply: ApplyResult[] }>({
|
||||||
|
method: 'fips.add-seed-anchor',
|
||||||
|
params: {
|
||||||
|
npub: draft.npub.trim(),
|
||||||
|
address: draft.address.trim(),
|
||||||
|
transport: 'udp',
|
||||||
|
label: draft.label.trim(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
anchors.value = res.seed_anchors
|
||||||
|
draft.npub = ''
|
||||||
|
draft.address = ''
|
||||||
|
draft.label = ''
|
||||||
|
const applied = res.apply.find(r => r.ok)
|
||||||
|
flash(applied ? 'Anchor added and dialed.' : 'Anchor saved — dial failed, will retry on the next apply cycle.', !applied)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
flash(`Add failed: ${msg}`, true)
|
||||||
|
} finally {
|
||||||
|
adding.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAnchor(npub: string) {
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({
|
||||||
|
method: 'fips.remove-seed-anchor',
|
||||||
|
params: { npub },
|
||||||
|
})
|
||||||
|
anchors.value = res.seed_anchors
|
||||||
|
flash('Anchor removed.')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
flash(`Remove failed: ${msg}`, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyAll() {
|
||||||
|
applying.value = true
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ applied: number; results: ApplyResult[] }>({ method: 'fips.apply-seed-anchors' })
|
||||||
|
const ok = res.results.filter(r => r.ok).length
|
||||||
|
flash(`${ok} of ${res.applied} anchor${res.applied === 1 ? '' : 's'} dialed.`, ok === 0 && res.applied > 0)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
flash(`Apply failed: ${msg}`, true)
|
||||||
|
} finally {
|
||||||
|
applying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@ -180,6 +180,18 @@ init()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||||
|
<!-- v1.7.21-alpha -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.21-alpha</span>
|
||||||
|
<span class="text-xs text-white/40">Apr 21, 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||||
|
<p>FIPS bootstrap no longer depends on a single public anchor. You can now add your own anchors — other archipelago nodes or a VPS you control — and the node will dial every one of them to join the mesh on startup. If one anchor is down, the next one seeds the routing layer instead, so a flaky public anchor no longer strands a fresh install.</p>
|
||||||
|
<p>Anchors persist across restarts and are re-applied every five minutes, so a daemon that got temporarily isolated reconnects on its own without anyone having to SSH in. Each anchor carries an operator-editable label so you can remember which is which.</p>
|
||||||
|
<p>No behavior change if you don't configure any — the upstream daemon's own defaults keep working as before. This purely adds an operator-controlled list on top.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- v1.7.20-alpha -->
|
<!-- v1.7.20-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
@ -1,26 +1,27 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.20-alpha",
|
"version": "1.7.21-alpha",
|
||||||
"release_date": "2026-04-21",
|
"release_date": "2026-04-21",
|
||||||
"changelog": [
|
"changelog": [
|
||||||
"Fixed a critical bug where nodes on 'Check & Apply Daily' could end up offline after their nightly update. The scheduler was killing the service a moment too early, before the built-in restart handler could bring the new version back up — leaving the node dead until someone SSH'd in. The scheduler now uses the same restart path as the 'Install Update' button, so auto-applied updates come back online on their own.",
|
"FIPS bootstrap no longer depends on a single public anchor. You can add your own anchors — other archipelago nodes or a VPS you control — and the node dials every one on startup to join the mesh. If one anchor is down, the next seeds the routing layer instead, so a flaky public anchor no longer strands a fresh install.",
|
||||||
"Applies automatically — no action needed on your end beyond taking this update."
|
"Anchors persist across restarts and are re-applied every five minutes, so a daemon that got temporarily isolated reconnects on its own without anyone having to SSH in. Each anchor carries an operator-editable label.",
|
||||||
|
"No behavior change if you don't configure any — the upstream daemon's defaults keep working as before. This purely adds an operator-controlled list on top."
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"name": "archipelago",
|
"name": "archipelago",
|
||||||
"current_version": "1.7.19-alpha",
|
"current_version": "1.7.20-alpha",
|
||||||
"new_version": "1.7.20-alpha",
|
"new_version": "1.7.21-alpha",
|
||||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.20-alpha/archipelago",
|
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.21-alpha/archipelago",
|
||||||
"sha256": "bf4f8b91b021cad445a868f454707e0fa005446f755604f8c3e072bb7a059e6f",
|
"sha256": "47e5ddff3a2eeb3ff7117bfccb1799a72932e77afefb4f03b17679c21858f21c",
|
||||||
"size_bytes": 40640016
|
"size_bytes": 40799840
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "archipelago-frontend-1.7.20-alpha.tar.gz",
|
"name": "archipelago-frontend-1.7.21-alpha.tar.gz",
|
||||||
"current_version": "1.7.19-alpha",
|
"current_version": "1.7.20-alpha",
|
||||||
"new_version": "1.7.20-alpha",
|
"new_version": "1.7.21-alpha",
|
||||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.20-alpha/archipelago-frontend-1.7.20-alpha.tar.gz",
|
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.21-alpha/archipelago-frontend-1.7.21-alpha.tar.gz",
|
||||||
"sha256": "a82f187b597c51e5f3d8753529914651ab2d8e8bb3ad9c36d287b335e4d386a9",
|
"sha256": "f8fd8f7d07f99fd227c6d3f3a188154b51e20e19b0f2f303175ea46095ae30d9",
|
||||||
"size_bytes": 162082209
|
"size_bytes": 162082809
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
releases/v1.7.21-alpha/archipelago
Executable file
BIN
releases/v1.7.21-alpha/archipelago
Executable file
Binary file not shown.
BIN
releases/v1.7.21-alpha/archipelago-frontend-1.7.21-alpha.tar.gz
Normal file
BIN
releases/v1.7.21-alpha/archipelago-frontend-1.7.21-alpha.tar.gz
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user