Compare commits
4 Commits
v1.7.22-al
...
v1.7.26-al
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97a3803640 | ||
|
|
5c634baa6d | ||
|
|
41474047bf | ||
|
|
005bbd9a9a |
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.22-alpha"
|
||||
version = "1.7.26-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.22-alpha"
|
||||
version = "1.7.26-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -431,6 +431,19 @@ 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.apply" => self.handle_update_apply().await,
|
||||
"update.git-apply" => self.handle_update_git_apply().await,
|
||||
"update.rollback" => self.handle_update_rollback().await,
|
||||
|
||||
@@ -241,6 +241,75 @@ 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 }))
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
@@ -160,71 +271,102 @@ 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);
|
||||
} 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.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -914,6 +1056,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();
|
||||
|
||||
@@ -172,6 +172,66 @@
|
||||
</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="addingMirror = !addingMirror"
|
||||
>{{ addingMirror ? 'Cancel' : '+ 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 mb-3">
|
||||
<li v-for="(m, i) in mirrors" :key="m.url" class="p-3 bg-white/5 rounded-lg flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<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 flex-col gap-1">
|
||||
<button
|
||||
v-if="i !== 0"
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded-md text-white/70 hover:bg-white/10 hover:text-white transition-colors"
|
||||
title="Make this the primary mirror"
|
||||
@click="setPrimaryMirror(m.url)"
|
||||
>Set primary</button>
|
||||
<button
|
||||
v-if="mirrors.length > 1"
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded-md text-red-300 hover:bg-red-400/10 transition-colors"
|
||||
@click="removeMirror(m.url)"
|
||||
>Remove</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<form v-if="addingMirror" class="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-3" @submit.prevent="submitMirror">
|
||||
<input
|
||||
v-model="mirrorDraft.url"
|
||||
type="text"
|
||||
placeholder="https://host/.../manifest.json"
|
||||
class="sm:col-span-2 px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
v-model="mirrorDraft.label"
|
||||
type="text"
|
||||
placeholder="Label (optional)"
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="sm:col-span-3 min-h-[40px] glass-button rounded-lg text-sm font-medium disabled:opacity-60"
|
||||
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
|
||||
>{{ mirrorSaving ? 'Adding…' : 'Add mirror' }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Actions row -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.actions') }}</h2>
|
||||
@@ -304,7 +364,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 +406,79 @@ 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: '' })
|
||||
|
||||
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.
|
||||
@@ -685,7 +818,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
|
||||
|
||||
@@ -9,15 +9,53 @@
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 class="text-xl font-semibold text-white">FIPS Mesh</h2>
|
||||
<div class="flex items-center gap-2" :title="statusLabel">
|
||||
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
|
||||
<span class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2" :title="statusLabel">
|
||||
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
|
||||
<span class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors"
|
||||
title="Seed anchors"
|
||||
aria-label="Open FIPS seed anchors settings"
|
||||
@click="showAnchorsModal = true"
|
||||
>
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm mb-4">Fast Nostr-keyed mesh routing</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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. 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="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"
|
||||
@click.stop
|
||||
>
|
||||
<FipsSeedAnchorsCard closable @close="showAnchorsModal = false" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3 shrink-0">
|
||||
<div class="p-3 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Daemon version</p>
|
||||
@@ -84,6 +122,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { safeClipboardWrite } from '@/views/web5/utils'
|
||||
import FipsSeedAnchorsCard from './FipsSeedAnchorsCard.vue'
|
||||
|
||||
interface FipsStatus {
|
||||
installed: boolean
|
||||
@@ -113,6 +152,7 @@ const reconnecting = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const statusIsError = ref(false)
|
||||
const copied = ref(false)
|
||||
const showAnchorsModal = ref(false)
|
||||
|
||||
async function copyNpub() {
|
||||
if (!status.value.npub) return
|
||||
|
||||
@@ -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,53 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- 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">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.23-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 Seed Anchors are now one click away. A small gear icon sits next to the status pill on the FIPS Mesh card — click it to open a modal where you can add, remove, and re-apply anchors. No more needing to go digging for the card or editing JSON by hand.</p>
|
||||
<p>The modal lists each anchor with its label, truncated npub, address, and transport, plus an Apply button to force-redial the full list and a Remove button per entry. The add form right below validates that the address is host:port and the npub is bech32 before saving.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.22-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
{
|
||||
"version": "1.7.22-alpha",
|
||||
"version": "1.7.26-alpha",
|
||||
"release_date": "2026-04-21",
|
||||
"changelog": [
|
||||
"FIPS Reconnect and Restart buttons now work on every node, regardless of which systemd unit is actually supervising the daemon. Previously they targeted only the archipelago-managed unit — nodes on the upstream unit saw the buttons silently do nothing. Both paths auto-detect which unit is up and act on that one.",
|
||||
"FIPS anchor status no longer shows red just because a specific public anchor is unreachable. It now lights green whenever any authenticated peer is a recognised anchor — public or one you added under Seed Anchors. Federated clusters that route through their own seed anchors finally report the truth.",
|
||||
"Reconnect also re-pushes your seed anchors after the restart, so you don't have to wait five minutes for the background apply loop to re-dial them."
|
||||
"Update downloads now fall through a mirror list. If the primary update server is slow or unreachable, your node automatically tries the next mirror and fetches files from there — no more stalling on a single wedged server.",
|
||||
"New 'Update mirrors' section on the System Update page: see the list, add your own mirror, reorder which one is tried first (Set primary), or remove one. The primary is tagged with a green PRIMARY pill.",
|
||||
"Downloads automatically follow the mirror that served the manifest. Before this, every mirror handed out the same manifest with download URLs hardcoded to one specific server, so even picking a faster mirror couldn't speed up the binary + frontend fetch. Now the backend rewrites download URLs to match whichever mirror actually responded.",
|
||||
"Ships with two defaults: Server 1 (tx1138) and Server 2 (VPS). Custom mirrors take the format https://host/.../releases/manifest.json."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.21-alpha",
|
||||
"new_version": "1.7.22-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.22-alpha/archipelago",
|
||||
"sha256": "43d632c7de75b5619b20dcb59ca5e561708fe7f8d0138b3bda7accb109a61ef6",
|
||||
"size_bytes": 40818136
|
||||
"current_version": "1.7.25-alpha",
|
||||
"new_version": "1.7.26-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago",
|
||||
"sha256": "9b9be929e668525e05550bf07930018846115a8249463a9100b2acc777b5268a",
|
||||
"size_bytes": 40700088
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.22-alpha.tar.gz",
|
||||
"current_version": "1.7.21-alpha",
|
||||
"new_version": "1.7.22-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.22-alpha/archipelago-frontend-1.7.22-alpha.tar.gz",
|
||||
"sha256": "402ba9af8d6bbbfc649a1cf62c4df4f44d9e48c78136dae7c29721d816e478e9",
|
||||
"size_bytes": 162082789
|
||||
"name": "archipelago-frontend-1.7.26-alpha.tar.gz",
|
||||
"current_version": "1.7.25-alpha",
|
||||
"new_version": "1.7.26-alpha",
|
||||
"download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago-frontend-1.7.26-alpha.tar.gz",
|
||||
"sha256": "b5981aba616bd15aa768c610cf3e44972d519c2a8204d4401da10bf2e4bd5886",
|
||||
"size_bytes": 162084251
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
BIN
releases/v1.7.23-alpha/archipelago
Executable file
BIN
releases/v1.7.23-alpha/archipelago
Executable file
Binary file not shown.
BIN
releases/v1.7.23-alpha/archipelago-frontend-1.7.23-alpha.tar.gz
Normal file
BIN
releases/v1.7.23-alpha/archipelago-frontend-1.7.23-alpha.tar.gz
Normal file
Binary file not shown.
BIN
releases/v1.7.24-alpha/archipelago
Executable file
BIN
releases/v1.7.24-alpha/archipelago
Executable file
Binary file not shown.
BIN
releases/v1.7.24-alpha/archipelago-frontend-1.7.24-alpha.tar.gz
Normal file
BIN
releases/v1.7.24-alpha/archipelago-frontend-1.7.24-alpha.tar.gz
Normal file
Binary file not shown.
BIN
releases/v1.7.25-alpha/archipelago
Executable file
BIN
releases/v1.7.25-alpha/archipelago
Executable file
Binary file not shown.
BIN
releases/v1.7.25-alpha/archipelago-frontend-1.7.25-alpha.tar.gz
Normal file
BIN
releases/v1.7.25-alpha/archipelago-frontend-1.7.25-alpha.tar.gz
Normal file
Binary file not shown.
BIN
releases/v1.7.26-alpha/archipelago
Executable file
BIN
releases/v1.7.26-alpha/archipelago
Executable file
Binary file not shown.
BIN
releases/v1.7.26-alpha/archipelago-frontend-1.7.26-alpha.tar.gz
Normal file
BIN
releases/v1.7.26-alpha/archipelago-frontend-1.7.26-alpha.tar.gz
Normal file
Binary file not shown.
Reference in New Issue
Block a user