Compare commits

...
Author SHA1 Message Date
DorianandClaude Opus 4.7 f8304aed90 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>
2026-04-21 06:21:37 -04:00
DorianandClaude Opus 4.7 40f76013dc release(v1.7.20-alpha): stop auto-apply scheduler killing the service
The 3AM auto-update path called std::process::exit(0) immediately
after apply_update returned. apply_update had already spawned a 2s-
delayed systemctl restart, but exit(0) killed the runtime before that
spawned task could run — and the unit's Restart=on-failure does not
trigger on a clean exit 0, so the service stayed dead until someone
SSH'd in and started it manually (.253 hit this today).

Scheduler now returns from the task without killing the process;
apply_update's existing restart path (same one the UI's Install
Update button uses) brings the new version up cleanly.

Also hardens the ISO CI: the AIUI inclusion step now falls back to
extracting from the newest release tarball if the runner's cached
/opt/archipelago/web-ui/aiui path is missing, so a reprovisioned
runner can't silently ship a frontend tarball without AIUI. The ISO
build step also sanity-checks the binary exists before invoking the
builder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 04:33:11 -04:00
DorianandClaude Opus 4.7 4e2c6d210b release(v1.7.19-alpha): kill stale available_update + numeric version compare
load_state now drops any stored available_update whenever the running
binary version differs from what's on disk — the old migration only
cleared it when the stale entry happened to match the new version, so
skipping releases (e.g. sideloading 1.7.16 → 1.7.18 without 1.7.17)
left a pointer to an intermediate version as the "update available",
which the UI then offered as a downgrade prompt.

check_for_updates also uses a numeric version comparator so a stale or
cached manifest with an older version can't offer itself as an
update, and 1.7.10 correctly outranks 1.7.9 past the single-digit
patch boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 04:04:20 -04:00
DorianandClaude Opus 4.7 7d8ddcccef release(v1.7.18-alpha): transitive peers default Trusted + update-flow logs
Flip transitively-discovered federation peers to Trusted instead of
Observer. Hints are already only ingested from peers we trust and only
peers we trust are re-exported via build_local_state, so the chain of
trust is already vetted end-to-end — making the user promote each
newcomer by hand was friction with no security win.

Backend:
- federation/sync.rs: merge_transitive_peers now inserts TrustLevel::Trusted
  (doc comment updated to explain the transitive-trust rationale)
- update.rs: info! log at download start (version, components, total_bytes,
  staging path), cancel (staging wiped?, marker cleared?), and apply (backup
  path) so journalctl reveals where a stuck update actually is

Frontend:
- SystemUpdate What's New block gets a v1.7.18-alpha entry

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:20:36 -04:00
14 changed files with 744 additions and 34 deletions
+40 -6
View File
@@ -74,12 +74,38 @@ jobs:
- name: Include AIUI if available
run: |
if [ -d "/opt/archipelago/web-ui/aiui" ] && [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then
mkdir -p web/dist/neode-ui/aiui
cp -r /opt/archipelago/web-ui/aiui/* web/dist/neode-ui/aiui/
echo "AIUI included from /opt/archipelago/web-ui/aiui/"
# AIUI (the Claude chat sidebar) lives outside the Vue build
# and must be copied into the frontend dist BEFORE packaging,
# otherwise OTA-tarball upgrades silently strip it from nodes
# in the field. Try in order: cached on runner, then the
# newest release tarball in this repo's releases/ dir as a
# fallback so a freshly-provisioned runner still gets AIUI.
AIUI_SRC=""
if [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then
AIUI_SRC="/opt/archipelago/web-ui/aiui"
elif [ -f "$HOME/archy/web/dist/neode-ui/aiui/index.html" ]; then
AIUI_SRC="$HOME/archy/web/dist/neode-ui/aiui"
else
echo "WARNING: AIUI not found on build server — ISO will not include AIUI"
LATEST_FRONTEND=$(ls -t releases/v*/archipelago-frontend-*.tar.gz 2>/dev/null | head -1)
if [ -n "$LATEST_FRONTEND" ]; then
echo "Extracting AIUI from $LATEST_FRONTEND (runner cache miss)"
TMP=$(mktemp -d)
tar xzf "$LATEST_FRONTEND" -C "$TMP" ./aiui 2>/dev/null || true
if [ -f "$TMP/aiui/index.html" ]; then
AIUI_SRC="$TMP/aiui"
fi
fi
fi
if [ -n "$AIUI_SRC" ]; then
mkdir -p web/dist/neode-ui/aiui
cp -r "$AIUI_SRC/"* web/dist/neode-ui/aiui/
echo "AIUI included from $AIUI_SRC ($(du -sh web/dist/neode-ui/aiui | cut -f1))"
else
echo "FAIL: AIUI not found anywhere (runner cache + release tarballs)"
echo " checked: /opt/archipelago/web-ui/aiui"
echo " \$HOME/archy/web/dist/neode-ui/aiui"
echo " releases/v*/archipelago-frontend-*.tar.gz"
exit 1
fi
- name: Configure root podman for insecure registry
@@ -93,7 +119,15 @@ jobs:
run: |
cd image-recipe
export ARCHIPELAGO_BIN="$(pwd)/../core/target/release/archipelago"
ls -la "$ARCHIPELAGO_BIN" || echo "WARNING: binary not found"
if [ ! -x "$ARCHIPELAGO_BIN" ]; then
echo "FAIL: backend binary missing or not executable at $ARCHIPELAGO_BIN"
exit 1
fi
BIN_VERSION=$(strings "$ARCHIPELAGO_BIN" | grep -oE 'archipelago [0-9]+\.[0-9]+\.[0-9]+(-[a-z]+)?' | head -1 || true)
EXPECTED=$(grep '^version' ../core/archipelago/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
echo "Binary: $ARCHIPELAGO_BIN ($(du -h "$ARCHIPELAGO_BIN" | cut -f1))"
echo "Embedded version string: ${BIN_VERSION:-unknown}"
echo "Expected version (Cargo.toml): $EXPECTED"
sudo -E UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 \
ARCHIPELAGO_BIN="$ARCHIPELAGO_BIN" \
./build-auto-installer-iso.sh
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.17-alpha"
version = "1.7.21-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.17-alpha"
version = "1.7.21-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -414,6 +414,16 @@ impl RpcHandler {
"fips.install" => self.handle_fips_install().await,
"fips.restart" => self.handle_fips_restart().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
"update.check" => self.handle_update_check().await,
+62
View File
@@ -129,4 +129,66 @@ impl RpcHandler {
"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<_>>(),
}))
}
}
@@ -342,6 +342,7 @@ mod tests {
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
@@ -376,6 +377,7 @@ mod tests {
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
@@ -409,6 +411,7 @@ mod tests {
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
@@ -421,6 +424,7 @@ mod tests {
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
+5 -3
View File
@@ -107,8 +107,10 @@ pub async fn sync_with_peer_by_did(
}
/// Merge peers advertised by a Trusted federated node into our own
/// federation list. New peers are added at `Observer` trust (not
/// Trusted — that requires a direct invite). Existing peers get their
/// federation list. New peers are added at `Trusted` — hints only
/// arrive from peers we already trust, and `build_local_state` only
/// re-exports our Trusted list, so transitive membership carries the
/// same trust the direct-invite path gives. Existing peers get their
/// `fips_npub` refreshed if we hadn't learned it yet.
///
/// Peers we are (us) or that we already track by DID are skipped.
@@ -142,7 +144,7 @@ async fn merge_transitive_peers(
pubkey: hint.pubkey.clone(),
onion: hint.onion.clone(),
name: hint.name.clone(),
trust_level: TrustLevel::Observer,
trust_level: TrustLevel::Trusted,
added_at: chrono::Utc::now().to_rfc3339(),
last_seen: None,
last_state: None,
+241
View 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, "");
}
}
+1
View File
@@ -25,6 +25,7 @@
// the module is deliberately API-ready ahead of those call-sites.
#![allow(dead_code)]
pub mod anchors;
pub mod config;
pub mod dial;
pub mod iface;
+28
View File
@@ -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
if config.nostr_discovery_enabled {
let data_dir = config.data_dir.clone();
+134 -21
View File
@@ -36,6 +36,31 @@ fn is_canceled() -> bool {
DOWNLOAD_CANCEL.load(Ordering::Relaxed)
}
/// Parse "MAJOR.MINOR.PATCH[-suffix]" into a tuple; suffix is ignored.
/// Returns None if the numeric portion can't be parsed — callers should
/// fall back to string comparison in that case so we don't silently
/// mis-rank versions we don't understand.
fn parse_version_triple(v: &str) -> Option<(u32, u32, u32)> {
let core = v.split('-').next().unwrap_or(v);
let mut parts = core.split('.');
let major: u32 = parts.next()?.parse().ok()?;
let minor: u32 = parts.next()?.parse().ok()?;
let patch: u32 = parts.next()?.parse().ok()?;
Some((major, minor, patch))
}
/// Is `candidate` strictly newer than `current`? Used to guard against
/// the manifest offering a version we've already passed (e.g. a stale
/// cached manifest or a node that sideloaded past the manifest's
/// latest). Falls back to string inequality if either version doesn't
/// parse, preserving the old behaviour for unusual version strings.
fn is_newer(candidate: &str, current: &str) -> bool {
match (parse_version_triple(candidate), parse_version_triple(current)) {
(Some(a), Some(b)) => a > b,
_ => candidate != current,
}
}
const DEFAULT_UPDATE_MANIFEST_URL: &str =
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json";
const UPDATE_STATE_FILE: &str = "update_state.json";
@@ -117,13 +142,13 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
let running = env!("CARGO_PKG_VERSION");
if state.current_version != running {
state.current_version = running.to_string();
// Clear any stale "available_update" that matched the old
// current_version — the new binary will re-check on its own.
if let Some(ref avail) = state.available_update {
if avail.version == running {
state.available_update = None;
}
}
// Binary version changed (sideload or apply). Any stored
// `available_update` is either redundant (points at the running
// version) or stale (points at a version we've already passed —
// which would surface as a "downgrade" offer in the UI). Clear
// it unconditionally; the next check_for_updates will repopulate
// if there's genuinely something newer.
state.available_update = None;
save_state(data_dir, &state).await?;
}
Ok(state)
@@ -161,7 +186,7 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
Ok(resp) if resp.status().is_success() => {
match resp.json::<UpdateManifest>().await {
Ok(manifest) => {
if manifest.version != state.current_version {
if is_newer(&manifest.version, &state.current_version) {
info!(
current = %state.current_version,
available = %manifest.version,
@@ -169,7 +194,16 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
);
state.available_update = Some(manifest);
} else {
debug!("Already on latest version: {}", state.current_version);
// Manifest version matches us or is behind
// us — either we're current, or the remote
// manifest is stale. Either way don't offer
// it as an "update" (that would be a
// downgrade prompt).
debug!(
current = %state.current_version,
manifest = %manifest.version,
"No newer version in manifest"
);
state.available_update = None;
}
handled = true;
@@ -244,6 +278,14 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
let mut downloaded = 0u64;
let total_bytes: u64 = manifest.components.iter().map(|c| c.size_bytes).sum();
info!(
version = %manifest.version,
components = manifest.components.len(),
total_bytes,
staging = %staging_dir.display(),
"Starting update download"
);
// Clear any stale cancel flag from a prior aborted run, then seed
// the live counters so polls during the handshake show the right
// denominator immediately instead of 0/0 → NaN%.
@@ -477,17 +519,27 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
let staging = data_dir.join("update-staging");
if staging.exists() {
let _ = tokio::fs::remove_dir_all(&staging).await;
}
let wiped = if staging.exists() {
tokio::fs::remove_dir_all(&staging).await.is_ok()
} else {
false
};
// Clear the "downloaded, ready to apply" marker too — a canceled
// download is not a staged update.
let mut cleared_marker = false;
if let Ok(mut state) = load_state(data_dir).await {
if state.update_in_progress {
state.update_in_progress = false;
let _ = save_state(data_dir, &state).await;
cleared_marker = true;
}
}
info!(
staging = %staging.display(),
wiped,
cleared_marker,
"Update download canceled"
);
Ok(())
}
@@ -529,6 +581,12 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
.await
.context("Failed to create backup dir")?;
info!(
staging = %staging_dir.display(),
backup = %backup_dir.display(),
"Applying staged update"
);
// Back up current backend binary
let current_binary = Path::new("/usr/local/bin/archipelago");
if current_binary.exists() {
@@ -824,9 +882,15 @@ pub async fn run_update_scheduler(data_dir: std::path::PathBuf) {
debug!("Update scheduler: apply failed: {}", e);
continue;
}
info!("Update scheduler: update applied, restart needed");
// Signal for service restart (systemd will handle via exit code)
std::process::exit(0);
info!("Update scheduler: update applied, restart scheduled by apply_update");
// apply_update has already spawned a 2s-delayed
// `systemctl restart archipelago`. Don't call
// std::process::exit here — that kills the runtime
// before the spawned restart task runs, and since
// the unit is Restart=on-failure a clean exit(0)
// leaves the service dead. Fall through; the
// scheduled restart will bring us back cleanly.
return;
}
Ok(_) => {
debug!("Update scheduler: no update available");
@@ -902,6 +966,52 @@ mod tests {
assert_eq!(state.schedule, UpdateSchedule::DailyCheck);
}
#[test]
fn test_parse_version_triple() {
assert_eq!(parse_version_triple("1.7.18"), Some((1, 7, 18)));
assert_eq!(parse_version_triple("1.7.18-alpha"), Some((1, 7, 18)));
assert_eq!(parse_version_triple("0.0.1"), Some((0, 0, 1)));
assert_eq!(parse_version_triple("garbage"), None);
assert_eq!(parse_version_triple("1.2"), None);
}
#[test]
fn test_is_newer() {
assert!(is_newer("1.7.19-alpha", "1.7.18-alpha"));
assert!(is_newer("1.8.0-alpha", "1.7.99-alpha"));
assert!(is_newer("1.7.10-alpha", "1.7.9-alpha")); // numeric, not lexical
assert!(!is_newer("1.7.18-alpha", "1.7.18-alpha"));
assert!(!is_newer("1.7.17-alpha", "1.7.18-alpha")); // would-be downgrade
assert!(!is_newer("1.7.9-alpha", "1.7.10-alpha"));
}
#[tokio::test]
async fn test_load_state_clears_stale_available_on_version_bump() {
// Simulates a sideload: state file on disk says we're on
// 1.7.16-alpha with 1.7.17-alpha staged as the pending update,
// but the running binary is 1.7.18-alpha (skipped a version).
// load_state must drop the stale available_update so the UI
// doesn't offer a downgrade.
let dir = tempfile::tempdir().unwrap();
let stale = UpdateState {
current_version: "1.7.16-alpha".to_string(),
available_update: Some(UpdateManifest {
version: "1.7.17-alpha".to_string(),
release_date: "2026-04-20".to_string(),
changelog: vec![],
components: vec![],
}),
..UpdateState::default()
};
save_state(dir.path(), &stale).await.unwrap();
let loaded = load_state(dir.path()).await.unwrap();
assert_eq!(loaded.current_version, env!("CARGO_PKG_VERSION"));
assert!(
loaded.available_update.is_none(),
"stale available_update must be cleared after version bump"
);
}
#[tokio::test]
async fn test_load_state_creates_default_when_missing() {
let dir = tempfile::tempdir().unwrap();
@@ -937,13 +1047,14 @@ mod tests {
};
save_state(dir.path(), &state).await.unwrap();
let loaded = load_state(dir.path()).await.unwrap();
assert_eq!(loaded.current_version, "1.0.0");
// load_state rewrites current_version to match the running
// binary (sideload self-heal), so don't assert on the saved
// value. The migration also clears available_update when the
// version changes — check the other fields survived.
assert_eq!(loaded.current_version, env!("CARGO_PKG_VERSION"));
assert!(loaded.update_in_progress);
assert_eq!(loaded.schedule, UpdateSchedule::Manual);
let manifest = loaded.available_update.unwrap();
assert_eq!(manifest.version, "1.1.0");
assert_eq!(manifest.components.len(), 1);
assert_eq!(manifest.components[0].size_bytes, 5000);
assert!(loaded.available_update.is_none());
}
#[tokio::test]
@@ -993,7 +1104,9 @@ mod tests {
};
save_state(dir.path(), &state).await.unwrap();
let status = get_status(dir.path()).await.unwrap();
assert_eq!(status.current_version, "3.0.0");
// get_status → load_state, which rewrites current_version to
// match the running binary (see the sideload-self-heal path).
assert_eq!(status.current_version, env!("CARGO_PKG_VERSION"));
assert!(status.rollback_available);
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.3.5",
"version": "1.6.0-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.3.5",
"version": "1.6.0-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
@@ -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,52 @@ init()
</button>
</div>
<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 -->
<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.20-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>Fixed a critical bug where nodes on the automatic daily-update schedule could end up offline after their nightly update. The scheduler was killing the service a moment too early, before the built-in restart handler had a chance to bring the new version back up leaving the node dead until someone SSH'd in and started it manually. The scheduler now hands off cleanly to the same restart path the 'Install Update' button uses, so auto-applied updates come back online on their own.</p>
<p>Applies to any node configured for 'Check &amp; Apply Daily' — no change required on your end, the fix ships with this update.</p>
</div>
</div>
<!-- v1.7.19-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.19-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>Your node no longer offers a version you've already passed as an "available update". If you sideload or skip a release, any stored pointer to an earlier version is dropped on next restart, and the System Update page offers only the genuinely newer release no more seeing an older version listed as something to install.</p>
<p>Version comparison is now numeric, not alphabetic. 1.7.10 correctly outranks 1.7.9 (earlier naive string-order would have got this backwards once the patch number hits double digits), so update prompts and "up to date" checks stay accurate past the nines.</p>
<p>A stale manifest from a slow cache or proxy can no longer downgrade your node. If the manifest reports a version equal to or behind what's running, your node treats that as "up to date" rather than offering the older version as an update.</p>
</div>
</div>
<!-- v1.7.18-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.18-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Nodes discovered through a trusted peer now land as Trusted instead of Observer. When your federated peer shares its own peer list with you, those nodes get the same trust level as a direct invite — the link they came through is already one you vetted, so you no longer need to promote them by hand before they can be used normally.</p>
<p>The update flow now writes clearer logs at every step. Start of download, cancel, and apply each emit a one-line entry to the system journal with the staging path and the affected files, so if a download misbehaves on your node it's easy to see exactly where it got to.</p>
</div>
</div>
<!-- v1.7.17-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">