diff --git a/core/Cargo.lock b/core/Cargo.lock index 3949d227..0141f775 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.25-alpha" +version = "1.7.26-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index cd3464a8..7adfa962 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -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"] diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index a430ab97..1620a57b 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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, diff --git a/core/archipelago/src/api/rpc/update.rs b/core/archipelago/src/api/rpc/update.rs index 9091090c..30126ab8 100644 --- a/core/archipelago/src/api/rpc/update.rs +++ b/core/archipelago/src/api/rpc/update.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { let schedule = update::get_schedule(&self.config.data_dir).await?; diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 87cbc11f..da0b3bb6 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -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 { + 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> { + 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 = + 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 { + 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 { 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 = 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 = 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::().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::().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(); diff --git a/neode-ui/src/views/SystemUpdate.vue b/neode-ui/src/views/SystemUpdate.vue index a32cd83d..5086348e 100644 --- a/neode-ui/src/views/SystemUpdate.vue +++ b/neode-ui/src/views/SystemUpdate.vue @@ -172,6 +172,66 @@ + +
+
+

Update mirrors

+ +
+

+ 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. +

+
    +
  • +
    +
    +

    {{ m.label || `Mirror ${i + 1}` }}

    + PRIMARY +
    +

    {{ m.url }}

    +
    +
    + + +
    +
  • +
+
+ + + +
+
+

{{ t('systemUpdate.actions') }}

@@ -304,7 +364,7 @@