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:
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.25-alpha"
|
||||
version = "1.7.26-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user