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>
This commit is contained in:
Dorian 2026-04-21 10:09:28 -04:00
parent 1c1416cc1a
commit 0d15ca588a
7 changed files with 482 additions and 33 deletions

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.25-alpha"
version = "1.7.26-alpha"
dependencies = [
"anyhow",
"archipelago-container",

View File

@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.25-alpha"
version = "1.7.26-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]

View File

@ -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,

View File

@ -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?;

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())
@ -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();

View File

@ -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

View File

@ -180,6 +180,19 @@ 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">