Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8304aed90 | ||
|
|
40f76013dc | ||
|
|
4e2c6d210b | ||
|
|
7d8ddcccef |
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.17-alpha"
|
||||
version = "1.7.21-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod anchors;
|
||||
pub mod config;
|
||||
pub mod dial;
|
||||
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
|
||||
if config.nostr_discovery_enabled {
|
||||
let data_dir = config.data_dir.clone();
|
||||
|
||||
+134
-21
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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 & 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">
|
||||
|
||||
Reference in New Issue
Block a user