Compare commits

...
Author SHA1 Message Date
DorianandClaude Opus 4.7 9868991900 release(v1.7.27-alpha): mirror transparency — served-by line + one-click test button
- New "Served by {mirror}" line on the System Update page so operators can see
  which mirror actually served the available manifest (vs. which is configured
  primary). Backend threads the served URL through UpdateState.manifest_mirror.
- New update.test-mirror RPC + per-row lightning-bolt button that pings a
  mirror and renders reachable/latency or error inline under the URL.
- UI polish on the mirrors section: Set Primary, Remove, and the new Test
  action are compact icon buttons; add-mirror form moved into a dialog.
- "What's New" block prepended for v1.7.27-alpha.

21/21 update module tests pass. vue-tsc + vite build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:05:42 -04:00
DorianandClaude Opus 4.7 0d15ca588a release(v1.7.26-alpha): mirror list + origin-relative download URLs
Adds a multi-mirror manifest fetch. `check_for_updates` walks a
configurable list (data_dir/update-mirrors.json) in priority order
and falls through to the next mirror on any HTTP / parse / timeout
failure. Two defaults bake in: Server 1 (git.tx1138.com) and Server 2
(23.182.128.160:3000).

Critical fix: after parsing a manifest, rewrite every component's
`download_url` so its origin matches the manifest URL we fetched.
Before this, the manifest hard-coded absolute URLs pointing at one
specific server — so even when a node fetched the manifest from a
faster mirror, the actual 200MB download went back to the slow
original. Now the faster mirror wins end-to-end.

New RPCs: update.list-mirrors, update.add-mirror, update.remove-mirror,
update.set-primary-mirror. New UI section on the System Update page
for operator management. 5 new unit tests for origin parsing and
manifest rewriting (21/21 green).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:09:28 -04:00
DorianandClaude Opus 4.7 1c1416cc1a release(v1.7.25-alpha): TCP transport for public FIPS mesh + modal cleanup
Re-adds the TCP transport (`0.0.0.0:8443`) to the rendered fips.yaml
alongside UDP. Upstream factory default enables both; we had
inadvertently narrowed to UDP-only when the yaml rewriter was last
touched, which left nodes unable to reach fips.v0l.io (the public
anchor only answers on TCP right now) or talk across networks that
block UDP.

Backend startup now compares the installed yaml against the current
rendered schema and restarts whichever fips unit is active when they
differ — so OTA-upgrading nodes pick up the new transport without
anyone having to click Reconnect.

Dropped the earlier plan to auto-add federated peers as seed anchors:
invites don't carry a FIPS-reachable IP:port, and once TCP reconnects
the public mesh, federated peers become npub-routable without needing
a seed entry.

Seed Anchors modal cleanup: replaced malformed header icon with a
three-arc broadcast glyph, and the close button now matches the
What's New modal (embedded in the card header, same icon + hover
style) instead of the earlier floating off-design placeholder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:25:53 -04:00
DorianandClaude Opus 4.7 1735098d81 release(v1.7.24-alpha): unbreak frontend pipeline — fresh UI for the first time since v1.7.17
The npm run build step in the release ritual had been silently failing
for roughly seven releases. vue-tsc died with EACCES on a root-owned
node_modules/.tmp, exited non-zero, and my `tail -5` of the build
output happened to only show vite's precache summary — which makes
vite look successful even when the typecheck that precedes it failed.
The resulting archipelago-frontend-*.tar.gz files were rebuilds from
whatever content happened to live in web/dist/neode-ui/ at the moment
(files left over from v1.7.9, owned root:root from an earlier sudo'd
operation, unchanged since).

Fixed by chowning both paths back to the archipelago user and
rebuilding. Every published frontend tarball from v1.7.17 through
v1.7.23 therefore shipped the same frozen UI; v1.7.24 is the first
release in that stretch whose frontend actually matches its backend.

Recorded the build-verification rule as a persistent feedback memory
(feedback_frontend_build_verify.md) — future ships must grep the
packaged tarball for the new version string before push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 08:53:00 -04:00
12 changed files with 824 additions and 58 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.23-alpha"
version = "1.7.27-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.23-alpha"
version = "1.7.27-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -431,6 +431,23 @@ impl RpcHandler {
"update.dismiss" => self.handle_update_dismiss().await,
"update.download" => self.handle_update_download().await,
"update.cancel-download" => self.handle_update_cancel_download().await,
"update.list-mirrors" => self.handle_update_list_mirrors().await,
"update.add-mirror" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_add_mirror(&p).await
}
"update.remove-mirror" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_remove_mirror(&p).await
}
"update.set-primary-mirror" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_set_primary_mirror(&p).await
}
"update.test-mirror" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_test_mirror(&p).await
}
"update.apply" => self.handle_update_apply().await,
"update.git-apply" => self.handle_update_git_apply().await,
"update.rollback" => self.handle_update_rollback().await,
+85
View File
@@ -40,6 +40,7 @@ impl RpcHandler {
"last_check": state.last_check,
"update_available": update_info.is_some(),
"update": update_info,
"manifest_mirror": state.manifest_mirror,
}))
}
@@ -195,6 +196,7 @@ impl RpcHandler {
"update_available": state.available_update.is_some(),
"update_in_progress": state.update_in_progress,
"rollback_available": state.rollback_available,
"manifest_mirror": state.manifest_mirror,
"download_progress": if active || completed {
Some(serde_json::json!({
"bytes_downloaded": downloaded,
@@ -241,6 +243,89 @@ impl RpcHandler {
Ok(serde_json::json!({ "rolled_back": true, "restart_required": true }))
}
/// List configured update mirrors in priority order.
pub(super) async fn handle_update_list_mirrors(&self) -> Result<serde_json::Value> {
let list = update::load_mirrors(&self.config.data_dir).await?;
Ok(serde_json::json!({ "mirrors": list }))
}
/// Add a mirror to the end of the list. Params: `{ url, label? }`.
/// Duplicates (same URL) are replaced rather than added twice.
pub(super) async fn handle_update_add_mirror(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let url = params
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing url"))?
.trim()
.to_string();
if !url.starts_with("http://") && !url.starts_with("https://") {
anyhow::bail!("url must start with http:// or https://");
}
let label = params
.get("label")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let mut list = update::load_mirrors(&self.config.data_dir).await?;
list.retain(|m| m.url != url);
list.push(update::UpdateMirror { url, label });
update::save_mirrors(&self.config.data_dir, &list).await?;
Ok(serde_json::json!({ "mirrors": list }))
}
/// Remove a mirror by URL. Params: `{ url }`.
pub(super) async fn handle_update_remove_mirror(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let url = params
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
let mut list = update::load_mirrors(&self.config.data_dir).await?;
list.retain(|m| m.url != url);
update::save_mirrors(&self.config.data_dir, &list).await?;
Ok(serde_json::json!({ "mirrors": list }))
}
/// Ping a mirror's manifest URL. Returns reachability, wall-clock
/// latency, and HTTP status. Params: `{ url }`.
pub(super) async fn handle_update_test_mirror(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let url = params
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
let result = update::test_mirror(url).await;
Ok(serde_json::to_value(result)?)
}
/// Move a mirror to the top of the list so it's tried first.
/// Params: `{ url }`.
pub(super) async fn handle_update_set_primary_mirror(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let url = params
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing url"))?;
let mut list = update::load_mirrors(&self.config.data_dir).await?;
let Some(idx) = list.iter().position(|m| m.url == url) else {
anyhow::bail!("mirror not in list");
};
let entry = list.remove(idx);
list.insert(0, entry);
update::save_mirrors(&self.config.data_dir, &list).await?;
Ok(serde_json::json!({ "mirrors": list }))
}
/// Get the current update schedule.
pub(super) async fn handle_update_get_schedule(&self) -> Result<serde_json::Value> {
let schedule = update::get_schedule(&self.config.data_dir).await?;
+18 -8
View File
@@ -13,22 +13,27 @@ use anyhow::{Context, Result};
use std::path::Path;
use tokio::process::Command;
use super::{DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_UDP_PORT};
use super::{DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT};
/// Write the FIPS daemon config based on the local npub and default
/// transports. Overwrites any existing file — callers are expected to
/// re-run this whenever the key or daemon version changes.
///
/// Schema is intentionally minimal: node identity comes from the key
/// file on disk (the daemon handles it), transports enable UDP + Tor,
/// IPv6 TUN + DNS on defaults. Static peer list is empty — archipelago
/// feeds peers dynamically via federation updates.
/// file on disk (the daemon handles it), transports enable UDP + TCP
/// (matching upstream factory default), IPv6 TUN + DNS on defaults.
/// Static peer list is empty — archipelago feeds peers dynamically via
/// the seed-anchors apply loop and federation-invite hooks.
pub fn render_config_yaml() -> String {
// Schema matches upstream jmcorgan/fips as of 2026-04. With
// `node.identity.persistent: true` the daemon reuses the key file at
// config-dir/fips.key (= DAEMON_KEY_PATH). Transports take `bind_addr`
// rather than `enabled: true / port: N`, and the upstream no longer
// has a `tor:` transport — archipelago's own Tor fallback handles that.
// rather than `enabled: true / port: N`. Both UDP and TCP are
// enabled by default because the public anchor (fips.v0l.io)
// currently answers on TCP/8443 only, and networks that block UDP
// outbound can still bootstrap via TCP. Upstream fips no longer
// has a `tor:` transport variant — archipelago's own Tor fallback
// handles that layer.
format!(
"# Generated by archipelago — do not edit by hand.\n\
# Regenerated on every key change and daemon upgrade.\n\
@@ -44,9 +49,12 @@ pub fn render_config_yaml() -> String {
bind_addr: \"127.0.0.1\"\n\
transports:\n \
udp:\n \
bind_addr: \"0.0.0.0:{port}\"\n\
bind_addr: \"0.0.0.0:{udp}\"\n \
tcp:\n \
bind_addr: \"0.0.0.0:{tcp}\"\n\
peers: []\n",
port = DEFAULT_UDP_PORT,
udp = DEFAULT_UDP_PORT,
tcp = DEFAULT_TCP_PORT,
)
}
@@ -185,7 +193,9 @@ mod tests {
let yaml = render_config_yaml();
assert!(yaml.contains("persistent: true"));
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_UDP_PORT)));
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_TCP_PORT)));
assert!(yaml.contains("udp:"));
assert!(yaml.contains("tcp:"));
assert!(yaml.contains("tun:"));
assert!(yaml.contains("name: fips0"));
// Upstream fips dropped the `tor:` transport variant; archipelago
+8
View File
@@ -53,6 +53,14 @@ pub const UPSTREAM_REPO: &str = "jmcorgan/fips";
/// Default UDP port the daemon listens on.
pub const DEFAULT_UDP_PORT: u16 = 8668;
/// Default TCP port the daemon listens on. Used as a fallback when a
/// peer can't be reached over UDP — common on networks that block UDP
/// (corporate/guest wifi) and the path the public fips.v0l.io anchor
/// currently accepts. Upstream factory default enables both transports
/// and archipelago intentionally matches that baseline so fresh nodes
/// can reach the broader FIPS mesh without operator config.
pub const DEFAULT_TCP_PORT: u16 = 8443;
/// Upstream systemd unit shipped by the `fips` debian package. Archipelago
/// prefers its own supervision (`archipelago-fips.service`) but respects an
/// already-running upstream unit so legacy/dev nodes — where no seed-derived
+27
View File
@@ -510,10 +510,37 @@ impl Server {
tracing::warn!("FIPS key load/migrate failed: {}", e);
return;
}
// Check if the installed fips.yaml matches what we'd
// render now. If not, we need to restart the daemon after
// reinstalling so it picks up schema changes (e.g. the
// v1.7.25 re-addition of the TCP transport). Without this,
// OTA'd nodes would be stuck on the old UDP-only config
// until someone manually clicked Reconnect.
let expected = crate::fips::config::render_config_yaml();
let installed = tokio::fs::read_to_string("/etc/fips/fips.yaml")
.await
.ok();
let config_changed = installed.as_deref() != Some(expected.as_str());
if let Err(e) = crate::fips::config::install(&identity_dir).await {
tracing::warn!("FIPS config install failed on startup: {}", e);
return;
}
if config_changed {
tracing::info!(
"FIPS config schema changed on disk — restarting daemon to pick up new transports"
);
// Restart whichever unit is actually supervising
// the daemon (archipelago-fips vs upstream fips).
let unit = crate::fips::service::active_unit().await;
if let Err(e) = crate::fips::service::restart(unit).await {
tracing::warn!(
"FIPS restart after config migration failed on {}: {} — user can retry via fips.reconnect",
unit,
e
);
}
}
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
tracing::warn!(
"archipelago-fips activate failed on startup: {} — user can retry via fips.install RPC",
+324 -29
View File
@@ -63,8 +63,119 @@ fn is_newer(candidate: &str, current: &str) -> bool {
const DEFAULT_UPDATE_MANIFEST_URL: &str =
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json";
/// Secondary mirror: same manifest, served from the VPS. Added as a
/// default mirror so nodes automatically fall through when the primary
/// is slow or unreachable.
const DEFAULT_SECONDARY_MIRROR_URL: &str =
"http://23.182.128.160:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
const UPDATE_STATE_FILE: &str = "update_state.json";
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UpdateMirror {
/// Full URL to `manifest.json`. Download URLs in the fetched
/// manifest are origin-rewritten to match this URL's scheme+host+
/// port, so hitting a mirror pulls its components from the same
/// mirror rather than whatever absolute host the publisher baked in.
pub url: String,
/// Human-readable label for the UI ("Server 1", "Home VPS", …).
#[serde(default)]
pub label: String,
}
fn mirrors_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(UPDATE_MIRRORS_FILE)
}
fn default_mirrors() -> Vec<UpdateMirror> {
vec![
UpdateMirror {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Server 1 (tx1138)".to_string(),
},
UpdateMirror {
url: DEFAULT_SECONDARY_MIRROR_URL.to_string(),
label: "Server 2 (VPS)".to_string(),
},
]
}
/// Load the operator-configured mirror list. Returns defaults if the
/// file doesn't exist yet, so a node OTA'd from a pre-mirrors release
/// starts with both Server 1 and Server 2 available without any manual
/// config.
pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
let path = mirrors_path(data_dir);
if !path.exists() {
return Ok(default_mirrors());
}
let bytes = fs::read(&path)
.await
.with_context(|| format!("read {}", path.display()))?;
let list: Vec<UpdateMirror> =
serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
if list.is_empty() {
Ok(default_mirrors())
} else {
Ok(list)
}
}
pub async fn save_mirrors(data_dir: &Path, mirrors: &[UpdateMirror]) -> Result<()> {
fs::create_dir_all(data_dir)
.await
.with_context(|| format!("mkdir {}", data_dir.display()))?;
let path = mirrors_path(data_dir);
let tmp = path.with_extension("json.tmp");
let json = serde_json::to_vec_pretty(mirrors).context("serialize mirrors")?;
fs::write(&tmp, json)
.await
.with_context(|| format!("write {}", tmp.display()))?;
fs::rename(&tmp, &path)
.await
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
/// Parse a manifest URL and return its `scheme://host[:port]` prefix.
/// Used by `rewrite_manifest_origins` so a manifest fetched from a
/// mirror points component downloads back at the same mirror rather
/// than whatever absolute URL the publisher baked in.
fn manifest_origin(manifest_url: &str) -> Option<String> {
let rest = manifest_url.strip_prefix("https://")
.map(|r| ("https", r))
.or_else(|| manifest_url.strip_prefix("http://").map(|r| ("http", r)))?;
let (scheme, after_scheme) = rest;
let host_and_port = after_scheme.split('/').next()?;
if host_and_port.is_empty() {
return None;
}
Some(format!("{}://{}", scheme, host_and_port))
}
/// Rewrite every component `download_url` so its origin matches the
/// manifest URL we just fetched. Preserves the path portion (which is
/// consistent across mirrors — every gitea serves `/lfg2025/archy/raw/…`).
/// Leaves URLs with a different path shape untouched (some operator
/// might mirror with a custom layout; in that case we don't guess).
fn rewrite_manifest_origins(manifest: &mut UpdateManifest, manifest_url: &str) {
let Some(new_origin) = manifest_origin(manifest_url) else {
return;
};
for c in manifest.components.iter_mut() {
if let Some(orig_origin) = manifest_origin(&c.download_url) {
if orig_origin != new_origin {
let path = c.download_url.trim_start_matches(&orig_origin).to_string();
c.download_url = format!("{}{}", new_origin, path);
}
}
}
}
/// Which manifest URL to try FIRST — operator override via env wins,
/// otherwise the first entry in the mirrors list, otherwise the hard
/// default. Callers that need the full mirror walk should use
/// `load_mirrors` directly.
fn update_manifest_url() -> String {
std::env::var("ARCHIPELAGO_UPDATE_URL")
.unwrap_or_else(|_| DEFAULT_UPDATE_MANIFEST_URL.to_string())
@@ -107,6 +218,11 @@ pub struct UpdateState {
pub rollback_available: bool,
#[serde(default)]
pub schedule: UpdateSchedule,
/// URL of the mirror whose manifest populated `available_update`.
/// Surfaces in the UI so operators can tell at a glance which mirror
/// their node actually hit (vs. just which is configured primary).
#[serde(default)]
pub manifest_mirror: Option<String>,
}
impl Default for UpdateState {
@@ -118,6 +234,7 @@ impl Default for UpdateState {
update_in_progress: false,
rollback_available: false,
schedule: UpdateSchedule::DailyCheck,
manifest_mirror: None,
}
}
}
@@ -149,6 +266,7 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
// it unconditionally; the next check_for_updates will repopulate
// if there's genuinely something newer.
state.available_update = None;
state.manifest_mirror = None;
save_state(data_dir, &state).await?;
}
Ok(state)
@@ -160,71 +278,104 @@ pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> {
fs::write(&path, data).await.context("Writing update state")
}
/// Check for available updates by fetching the release manifest.
/// Check for available updates by walking the mirror list. The first
/// mirror that returns a parseable manifest with a strictly-newer
/// version wins; if no mirror offers a newer version, the node is
/// reported as up-to-date. Per-mirror we retry up to 3 times on
/// transient failures.
///
/// Manifest `download_url`s are origin-rewritten to match the mirror
/// we fetched from, so switching mirrors in the UI also switches where
/// component downloads come from — even if the publisher baked an
/// absolute URL pointing at a different server into the manifest.
pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
let mut state = load_state(data_dir).await?;
info!("Checking for updates...");
// 45s total budget, and we retry up to 3 times so a momentary
// gitea hiccup doesn't make the node report "up to date" when an
// update actually exists. Short per-attempt timeout keeps the RPC
// responsive in the common case.
let client = reqwest::Client::builder()
// Short per-attempt HTTP timeout so a wedged mirror doesn't
// delay the whole check — we'd rather move on to the next
// mirror quickly than sit waiting on a slow one. 15s covers
// slow but alive mirrors.
.timeout(std::time::Duration::from_secs(15))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.context("Failed to create HTTP client")?;
let manifest_url = update_manifest_url();
// Env override (ARCHIPELAGO_UPDATE_URL) short-circuits the mirror
// list — used on dev boxes that point at a local gitea. Otherwise
// walk the operator-configured list and fall through on failure.
let mirrors: Vec<String> = if std::env::var("ARCHIPELAGO_UPDATE_URL").is_ok() {
vec![update_manifest_url()]
} else {
load_mirrors(data_dir)
.await
.unwrap_or_else(|_| default_mirrors())
.into_iter()
.map(|m| m.url)
.collect()
};
let mut last_err: Option<String> = None;
let mut handled = false;
for attempt in 1..=3u8 {
if attempt > 1 {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
match client.get(&manifest_url).send().await {
Ok(resp) if resp.status().is_success() => {
match resp.json::<UpdateManifest>().await {
Ok(manifest) => {
'mirrors: for manifest_url in mirrors.iter() {
for attempt in 1..=3u8 {
if attempt > 1 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
match client.get(manifest_url).send().await {
Ok(resp) if resp.status().is_success() => match resp.json::<UpdateManifest>().await {
Ok(mut manifest) => {
rewrite_manifest_origins(&mut manifest, manifest_url);
if is_newer(&manifest.version, &state.current_version) {
info!(
current = %state.current_version,
available = %manifest.version,
mirror = %manifest_url,
"Update available"
);
state.available_update = Some(manifest);
state.manifest_mirror = Some(manifest_url.clone());
} else {
// 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).
// us — either we're current, or this mirror
// is stale. Try the next mirror; if all are
// stale or at our version we'll fall through
// to "up to date".
debug!(
current = %state.current_version,
manifest = %manifest.version,
mirror = %manifest_url,
"No newer version in manifest"
);
if state.available_update.is_some() {
// A later mirror might still have a
// newer version — don't clobber what an
// earlier mirror told us. But also don't
// break: another mirror could be ahead.
continue 'mirrors;
}
state.manifest_mirror = None;
state.available_update = None;
}
handled = true;
break;
}
Err(e) => {
last_err = Some(format!("parse: {}", e));
break 'mirrors;
}
Err(e) => last_err = Some(format!("{}: parse: {}", manifest_url, e)),
},
Ok(resp) => {
last_err = Some(format!("{}: HTTP {}", manifest_url, resp.status()));
}
Err(e) => {
last_err = Some(format!("{}: {}", manifest_url, e));
}
}
Ok(resp) => {
last_err = Some(format!("HTTP {}", resp.status()));
}
Err(e) => {
last_err = Some(e.to_string());
}
}
tracing::debug!(mirror = %manifest_url, "Mirror exhausted, trying next");
}
if !handled {
if let Some(e) = last_err {
debug!("Update check failed after retries: {}", e);
debug!("Update check failed across all mirrors: {}", e);
}
}
@@ -233,6 +384,66 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
Ok(state)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MirrorTestResult {
pub reachable: bool,
pub latency_ms: u64,
pub http_status: Option<u16>,
pub error: Option<String>,
}
/// Ping a mirror's manifest URL and return reachability + wall-clock
/// latency. Used by the "Test mirror" button so operators can sanity-
/// check a newly added mirror without running a full update check.
pub async fn test_mirror(url: &str) -> MirrorTestResult {
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
{
Ok(c) => c,
Err(e) => {
return MirrorTestResult {
reachable: false,
latency_ms: 0,
http_status: None,
error: Some(format!("client build failed: {}", e)),
}
}
};
let start = std::time::Instant::now();
match client.get(url).send().await {
Ok(resp) => {
let latency_ms = start.elapsed().as_millis() as u64;
let status = resp.status();
if status.is_success() {
MirrorTestResult {
reachable: true,
latency_ms,
http_status: Some(status.as_u16()),
error: None,
}
} else {
MirrorTestResult {
reachable: false,
latency_ms,
http_status: Some(status.as_u16()),
error: Some(format!("HTTP {}", status.as_u16())),
}
}
}
Err(e) => {
let latency_ms = start.elapsed().as_millis() as u64;
MirrorTestResult {
reachable: false,
latency_ms,
http_status: None,
error: Some(e.to_string()),
}
}
}
}
/// Get current update status without checking remote.
pub async fn get_status(data_dir: &Path) -> Result<UpdateState> {
load_state(data_dir).await
@@ -914,6 +1125,85 @@ mod tests {
assert_eq!(schedule, UpdateSchedule::DailyCheck);
}
#[test]
fn test_manifest_origin_parses_https() {
assert_eq!(
manifest_origin("https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"),
Some("https://git.tx1138.com".to_string())
);
}
#[test]
fn test_manifest_origin_parses_http_with_port() {
assert_eq!(
manifest_origin("http://23.182.128.160:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"),
Some("http://23.182.128.160:3000".to_string())
);
}
#[test]
fn test_manifest_origin_rejects_garbage() {
assert_eq!(manifest_origin("not a url"), None);
assert_eq!(manifest_origin("ftp://git.tx1138.com/x"), None);
}
#[test]
fn test_rewrite_manifest_origins_swaps_all_components() {
let mut manifest = UpdateManifest {
version: "1.7.26-alpha".into(),
release_date: "2026-04-21".into(),
changelog: vec![],
components: vec![
ComponentUpdate {
name: "archipelago".into(),
current_version: "1.7.25-alpha".into(),
new_version: "1.7.26-alpha".into(),
download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago".into(),
sha256: "x".into(),
size_bytes: 1,
},
ComponentUpdate {
name: "frontend".into(),
current_version: "1.7.25-alpha".into(),
new_version: "1.7.26-alpha".into(),
download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/frontend.tar.gz".into(),
sha256: "y".into(),
size_bytes: 2,
},
],
};
rewrite_manifest_origins(&mut manifest, "http://23.182.128.160:3000/lfg2025/archy/raw/branch/main/releases/manifest.json");
assert_eq!(
manifest.components[0].download_url,
"http://23.182.128.160:3000/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago"
);
assert_eq!(
manifest.components[1].download_url,
"http://23.182.128.160:3000/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/frontend.tar.gz"
);
}
#[tokio::test]
async fn test_load_mirrors_returns_defaults_when_absent() {
let dir = tempfile::tempdir().unwrap();
let list = load_mirrors(dir.path()).await.unwrap();
assert_eq!(list.len(), 2);
assert!(list[0].url.contains("git.tx1138.com"));
assert!(list[1].url.contains("23.182.128.160"));
}
#[tokio::test]
async fn test_save_and_load_mirrors_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let list = vec![UpdateMirror {
url: "https://example.com/m.json".into(),
label: "Example".into(),
}];
save_mirrors(dir.path(), &list).await.unwrap();
let back = load_mirrors(dir.path()).await.unwrap();
assert_eq!(back, list);
}
#[test]
fn test_update_state_default_values() {
let state = UpdateState::default();
@@ -934,6 +1224,7 @@ mod tests {
update_in_progress: false,
rollback_available: true,
schedule: UpdateSchedule::AutoApply,
manifest_mirror: None,
};
let json = serde_json::to_string(&state).unwrap();
let deserialized: UpdateState = serde_json::from_str(&json).unwrap();
@@ -1044,6 +1335,10 @@ mod tests {
update_in_progress: true,
rollback_available: false,
schedule: UpdateSchedule::Manual,
manifest_mirror: Some(
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
.to_string(),
),
};
save_state(dir.path(), &state).await.unwrap();
let loaded = load_state(dir.path()).await.unwrap();
+265 -2
View File
@@ -42,6 +42,12 @@
<div>
<h2 class="text-lg font-semibold text-white">{{ t('systemUpdate.updateAvailable') }}</h2>
<p class="text-sm text-white/60">Version {{ updateInfo.version }} &mdash; {{ updateInfo.release_date }}</p>
<p v-if="manifestMirrorLabel" class="text-xs text-white/40 mt-1 flex items-center gap-1.5">
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7" />
</svg>
<span>Served by <span class="text-white/70">{{ manifestMirrorLabel }}</span></span>
</p>
</div>
<span class="px-3 py-1 bg-orange-500/20 text-orange-400 text-xs font-medium rounded-full">{{ t('systemUpdate.new') }}</span>
</div>
@@ -172,6 +178,87 @@
</div>
</div>
<!-- Mirrors -->
<div class="glass-card p-6 mb-6">
<div class="flex items-start justify-between gap-4 mb-2">
<h2 class="text-lg font-semibold text-white">Update mirrors</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"
@click="openAddMirror"
>+ Add mirror</button>
</div>
<p class="text-sm text-white/60 mb-4">
Servers this node checks for updates. The primary is tried first; if it's slow or unreachable, the next one in the list is tried automatically. Downloads always come from the mirror that served the manifest — switching primary switches where files come from.
</p>
<ul v-if="mirrors.length" class="space-y-2">
<li v-for="(m, i) in mirrors" :key="m.url" class="p-3 bg-white/5 rounded-lg">
<div class="flex items-start gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5 flex-wrap">
<p class="text-sm font-medium text-white truncate">{{ m.label || `Mirror ${i + 1}` }}</p>
<span v-if="i === 0" class="text-[10px] font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-300">PRIMARY</span>
</div>
<p class="text-xs text-white/50 font-mono break-all">{{ m.url }}</p>
</div>
<div class="shrink-0 flex items-center gap-1">
<button
type="button"
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="mirrorTests[m.url]?.testing"
title="Test reachability"
@click="testMirror(m.url)"
>
<svg v-if="mirrorTests[m.url]?.testing" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25"></circle>
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</button>
<button
v-if="i !== 0"
type="button"
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-yellow-300 hover:bg-white/10 transition-colors"
title="Make primary"
@click="setPrimaryMirror(m.url)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button
v-if="mirrors.length > 1"
type="button"
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-red-300 hover:bg-red-400/10 transition-colors"
title="Remove mirror"
@click="removeMirror(m.url)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<div v-if="mirrorTests[m.url] && !mirrorTests[m.url]?.testing" class="mt-2 pt-2 border-t border-white/5 text-xs">
<span v-if="mirrorTests[m.url]?.reachable" class="inline-flex items-center gap-1.5 text-green-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
Reachable &middot; {{ mirrorTests[m.url]?.latency_ms }}ms
</span>
<span v-else class="inline-flex items-center gap-1.5 text-red-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="truncate">{{ mirrorTests[m.url]?.error || 'Unreachable' }}</span>
</span>
</div>
</li>
</ul>
</div>
<!-- Actions row -->
<div class="glass-card p-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.actions') }}</h2>
@@ -257,6 +344,51 @@
</Transition>
</Teleport>
<!-- Add-mirror modal -->
<Transition name="fade">
<div v-if="addingMirror" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelAddMirror">
<div class="glass-card p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-1">Add update mirror</h3>
<p class="text-sm text-white/60 mb-5">
The URL should point directly at a <span class="font-mono text-white/80">manifest.json</span> served by a Gitea mirror or equivalent. It's added to the end of the list; use "Make primary" to change order.
</p>
<form class="space-y-3" @submit.prevent="submitMirror">
<div>
<label class="block text-xs text-white/60 mb-1">Manifest URL</label>
<input
v-model="mirrorDraft.url"
type="text"
autofocus
placeholder="https://host/.../manifest.json"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
/>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Label (optional)</label>
<input
v-model="mirrorDraft.label"
type="text"
placeholder="Home VPS"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
/>
</div>
<div class="flex gap-3 justify-end pt-2">
<button
type="button"
@click="cancelAddMirror"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
>{{ t('common.cancel') }}</button>
<button
type="submit"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
>{{ mirrorSaving ? 'Adding…' : 'Add mirror' }}</button>
</div>
</form>
</div>
</div>
</Transition>
<!-- Confirmation modal -->
<Transition name="fade">
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelConfirm">
@@ -304,7 +436,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
@@ -346,6 +478,133 @@ const statusIsError = ref(false)
const downloadPercent = ref(0)
const downloadPercentFormatted = computed(() => downloadPercent.value.toFixed(2))
// Mirrors — servers this node tries for the manifest, in priority
// order. First entry is the primary. Add/remove/set-primary are wired
// to update.*-mirror RPCs; downloads automatically go to the mirror
// that served the manifest.
interface UpdateMirror { url: string; label: string }
const mirrors = ref<UpdateMirror[]>([])
const addingMirror = ref(false)
const mirrorSaving = ref(false)
const mirrorDraft = reactive({ url: '', label: '' })
// URL of the mirror that served the currently-available-update manifest.
// Backend reports it in update.status and update.check responses; the UI
// resolves it to a friendly label by matching against the mirrors list.
const manifestMirror = ref<string | null>(null)
const manifestMirrorLabel = computed(() => {
const url = manifestMirror.value
if (!url) return null
const match = mirrors.value.find(m => m.url === url)
if (match && match.label) return match.label
try {
const u = new URL(url)
return u.host
} catch {
return url
}
})
// Per-mirror test state. Populated by testMirror(); each entry is either
// { testing: true } while in flight or the backend response shape on
// completion. Rendered inline under each mirror row.
interface MirrorTestState {
testing?: boolean
reachable?: boolean
latency_ms?: number
http_status?: number | null
error?: string | null
}
const mirrorTests = ref<Record<string, MirrorTestState>>({})
async function testMirror(url: string) {
mirrorTests.value = { ...mirrorTests.value, [url]: { testing: true } }
try {
const res = await rpcClient.call<{
reachable: boolean
latency_ms: number
http_status: number | null
error: string | null
}>({ method: 'update.test-mirror', params: { url } })
mirrorTests.value = { ...mirrorTests.value, [url]: { ...res, testing: false } }
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
mirrorTests.value = { ...mirrorTests.value, [url]: { testing: false, reachable: false, error: msg } }
}
}
function openAddMirror() {
mirrorDraft.url = ''
mirrorDraft.label = ''
addingMirror.value = true
}
function cancelAddMirror() {
addingMirror.value = false
}
async function loadMirrors() {
try {
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({ method: 'update.list-mirrors' })
mirrors.value = res.mirrors
} catch (e) {
if (import.meta.env.DEV) console.warn('update.list-mirrors failed', e)
}
}
async function submitMirror() {
const url = mirrorDraft.url.trim()
if (!url) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
showStatus('Mirror URL must start with http:// or https://', true)
return
}
mirrorSaving.value = true
try {
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({
method: 'update.add-mirror',
params: { url, label: mirrorDraft.label.trim() },
})
mirrors.value = res.mirrors
mirrorDraft.url = ''
mirrorDraft.label = ''
addingMirror.value = false
showStatus('Mirror added.')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showStatus(`Add mirror failed: ${msg}`, true)
} finally {
mirrorSaving.value = false
}
}
async function removeMirror(url: string) {
try {
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({
method: 'update.remove-mirror',
params: { url },
})
mirrors.value = res.mirrors
showStatus('Mirror removed.')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showStatus(`Remove failed: ${msg}`, true)
}
}
async function setPrimaryMirror(url: string) {
try {
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({
method: 'update.set-primary-mirror',
params: { url },
})
mirrors.value = res.mirrors
showStatus('Primary mirror updated. Next update check will try it first.')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showStatus(`Set primary failed: ${msg}`, true)
}
}
// Poll the backend for the real bytes_downloaded / total_bytes so the
// progress bar tracks actual download state (and survives route
// changes). Returns true if a download is currently in progress.
@@ -488,11 +747,13 @@ async function loadStatus() {
update_available: boolean
update_in_progress: boolean
rollback_available: boolean
manifest_mirror: string | null
}>({ method: 'update.status' })
currentVersion.value = res.current_version
lastCheck.value = res.last_check
updateInProgress.value = res.update_in_progress
rollbackAvailable.value = res.rollback_available
manifestMirror.value = res.manifest_mirror ?? null
if (res.update_in_progress) {
downloaded.value = true
@@ -512,11 +773,13 @@ async function checkForUpdates() {
update_available: boolean
update: UpdateDetail | null
update_method?: string
manifest_mirror?: string | null
}>({ method: 'update.check' })
currentVersion.value = res.current_version
lastCheck.value = res.last_check
updateInfo.value = res.update
updateMethod.value = res.update_method === 'git' ? 'git' : 'manifest'
manifestMirror.value = res.manifest_mirror ?? null
if (!res.update_available) {
showStatus(t('systemUpdate.upToDateMessage'))
}
@@ -685,7 +948,7 @@ async function setSchedule(value: ScheduleValue) {
}
onMounted(async () => {
await Promise.all([loadStatus(), loadSchedule(), checkForUpdates()])
await Promise.all([loadStatus(), loadSchedule(), loadMirrors(), checkForUpdates()])
// If a download was already running when the user navigated here
// (or refreshed), pick up the progress bar where it is and keep
// polling until the backend reports done. No RPC call to start the
+10 -14
View File
@@ -34,27 +34,23 @@
<!-- Seed anchors modal operator-editable list of peers this node
dials to bootstrap the mesh. Tucked behind the gear so it
doesn't crowd the card but is still one click away. -->
doesn't crowd the card but is still one click away. Close
button and layout mirror the What's New modal (and the rest
of the app) so it feels like a first-class modal. -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="showAnchorsModal"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click.self="showAnchorsModal = false"
@click="showAnchorsModal = false"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div class="relative z-10 max-w-xl w-full" style="max-height: 90vh; overflow-y: auto">
<div class="flex justify-end mb-2">
<button
type="button"
class="p-2 rounded-md bg-white/10 hover:bg-white/20 text-white/70 hover:text-white transition-colors"
aria-label="Close"
@click="showAnchorsModal = false"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<FipsSeedAnchorsCard />
<div
class="relative z-10 max-w-xl w-full"
style="max-height: 90vh; overflow-y: auto"
@click.stop
>
<FipsSeedAnchorsCard closable @close="showAnchorsModal = false" />
</div>
</div>
</Transition>
@@ -1,9 +1,23 @@
<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 data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1 relative">
<button
v-if="closable"
type="button"
class="absolute top-4 right-4 p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors z-10"
aria-label="Close"
@click="$emit('close')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div class="flex items-start gap-4 mb-4 shrink-0" :class="{ 'pr-10': closable }">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<!-- Radio/broadcast icon three concentric arcs radiating from a
dot. Reads as mesh, signal, anchor-reaching-peers. -->
<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" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0" />
<circle cx="12" cy="18" r="1.25" fill="currentColor" stroke="none" />
</svg>
</div>
<div class="flex-1">
@@ -70,6 +84,9 @@
import { onMounted, reactive, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
defineProps<{ closable?: boolean }>()
defineEmits<{ (e: 'close'): void }>()
interface SeedAnchor {
npub: string
address: string
@@ -180,6 +180,54 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.27-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.27-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>The Update page now shows which mirror delivered your update a small "Served by" line under the new version tells you whether Server 1, Server 2, or a custom mirror was the one your node actually reached. Great for spot-checking that mirror fallback is doing its job.</p>
<p>Every mirror row has a new lightning-bolt button that pings the mirror and shows whether it's reachable, plus the round-trip latency in milliseconds. No more guessing if a mirror you just added is responding.</p>
<p>The Update mirrors section got a visual refresh: Set Primary, Remove, and the new Test action are compact icon buttons instead of crowded text, and adding a mirror now happens in a dedicated dialog that matches the rest of the UI.</p>
</div>
</div>
<!-- v1.7.26-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.26-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>Update downloads now have a mirror list. If the primary update server is slow or unreachable, your node automatically tries the next mirror and downloads the files from there — no more waiting on a stalled server with no recourse.</p>
<p>A new 'Update mirrors' section on the System Update page lets you see the list, add your own mirror URL, reorder which is tried first (Set primary), or remove one. The primary is tagged with a green PRIMARY pill.</p>
<p>Downloads automatically follow the mirror that served the manifest. Previously every mirror served the same manifest, and the manifest's download URLs were hardcoded to a single server so even picking a faster mirror couldn't speed up the actual download. Now the backend rewrites download URLs to match whichever mirror succeeded.</p>
<p>Ships with two defaults: Server 1 (tx1138) and Server 2 (VPS). Add the URL format <code>https://host/.../releases/manifest.json</code> for custom mirrors.</p>
</div>
</div>
<!-- v1.7.25-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.25-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 can now reach the broader FIPS public mesh, not just your own federated cluster. The FIPS daemon now binds both UDP (fast mesh forwarding) and TCP (NAT-friendly bootstrap) transports — matching the upstream factory default. The public anchor currently answers on TCP, so UDP-only nodes couldn't reach it; this fixes that without any action needed on your end.</p>
<p>Upgrading the config happens automatically. On next startup, if the installed FIPS yaml doesn't match the new two-transport schema, the node reinstalls and restarts the daemon so the TCP transport comes online. No manual Reconnect required.</p>
<p>Side benefit: TCP also helps on networks that block outbound UDP (corporate, some guest wifi) — your node falls back to TCP/8443 automatically and still joins the mesh.</p>
</div>
</div>
<!-- v1.7.24-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.24-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>Frontend updates now actually ship. Since roughly v1.7.17 the release pipeline had been rebuilding the backend every version but silently skipping the frontend bundle — a permissions issue on the build server meant vue-tsc failed before vite ever ran, and nobody noticed because the published tarballs still extracted cleanly. The result was the backend moving forward while the UI stayed frozen at its v1.7.9-era state, which is why the FIPS gear icon and the What's New entries for every release since then had been missing on your node.</p>
<p>Once this update applies, your node gets the real v1.7.24 frontend: the FIPS Seed Anchors modal (gear icon on the FIPS Mesh card), the current What's New history, the cancel-download button, and every other UI touch from the releases in between.</p>
</div>
</div>
<!-- v1.7.23-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">