From 91bbe4faa1ac7ba27ae5a9129a222c281fb86cd4 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 12:44:57 -0400 Subject: [PATCH] fix: portainer pin, bitcoin conf tolerance, gate login UI, named OTA origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portainer: nodes have been running :latest — which is 2.39.1 — while the manifest pinned 2.19.4 from two years ago. The port migration recreated the container onto that old pin and Portainer refused to start: it migrates a database forward, never backward, so an existing install died with 'schema version does not align' and My Apps showed 'app is not responding' (100.82.34.38). 2.39.1 published as an immutable tag and pinned forward, so existing databases keep working and older ones migrate up. Bitcoin: complements PR #131. That removes the code which kept writing a datadir bitcoin.conf; -allowignoredconf=1 additionally makes an existing one non-fatal, so a node already carrying the file recovers on restart instead of crash-looping until something reinstalls it. App gate login: rebuilt against the dashboard's own design — rotating intro backgrounds served from the gate, the glass panel, the Archipelago mark in its gradient ring, the app's icon as a My Apps tile, and the glass button. Crucially it no longer sends X-Frame-Options: DENY, which made every gated app render as unreachable inside My Apps' embedded frame; frame-ancestors expresses 'only this node may frame me', which X-Frame-Options cannot. OTA origin: primary mirror is now source.archipelago-foundation.org over TLS instead of a bare IP on plaintext. The IP stays as an automatic fallback for nodes whose DNS or clock is broken — both break TLS, and the signature, not the transport, is what establishes trust. Co-Authored-By: Claude Fable 5 --- apps/bitcoin-core/manifest.yml | 14 +- apps/bitcoin-knots/manifest.yml | 14 +- apps/portainer/manifest.yml | 2 +- core/archipelago/src/appgate/mod.rs | 347 ++++++++++++++++++++++++---- core/archipelago/src/update.rs | 59 +++-- scripts/image-versions.sh | 8 +- 6 files changed, 376 insertions(+), 68 deletions(-) diff --git a/apps/bitcoin-core/manifest.yml b/apps/bitcoin-core/manifest.yml index 6cdd5faa..c8619f7e 100644 --- a/apps/bitcoin-core/manifest.yml +++ b/apps/bitcoin-core/manifest.yml @@ -38,6 +38,16 @@ app: RPC_CONF="/tmp/rpc.conf"; umask 077; { echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF"; + # A stray bitcoin.conf in the datadir is FATAL when -conf points + # elsewhere: bitcoind refuses to start with "contains a bitcoin.conf + # file which is ignored", and the app crash-loops (100.82.34.38, + # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the + # RPC credentials and the flags below are the authoritative config, + # so the datadir file is legacy debris; say so out loud rather than + # failing, and let bitcoind start. + if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then + echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2; + fi; RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)"; DISK_GB_VALUE="$(printenv DISK_GB || true)"; RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256"; @@ -46,9 +56,9 @@ app: RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"; fi; if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then - exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; + exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; else - exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; + exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; fi derived_env: - key: DISK_GB diff --git a/apps/bitcoin-knots/manifest.yml b/apps/bitcoin-knots/manifest.yml index 9d7967d4..cdc492c5 100644 --- a/apps/bitcoin-knots/manifest.yml +++ b/apps/bitcoin-knots/manifest.yml @@ -38,6 +38,16 @@ app: RPC_CONF="/tmp/rpc.conf"; umask 077; { echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF"; + # A stray bitcoin.conf in the datadir is FATAL when -conf points + # elsewhere: bitcoind refuses to start with "contains a bitcoin.conf + # file which is ignored", and the app crash-loops (100.82.34.38, + # 2026-08-05 — Exited(1) every few seconds). Our -conf carries the + # RPC credentials and the flags below are the authoritative config, + # so the datadir file is legacy debris; say so out loud rather than + # failing, and let bitcoind start. + if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then + echo "archipelago: ignoring legacy /home/bitcoin/.bitcoin/bitcoin.conf; RPC config comes from $RPC_CONF and the flags below" >&2; + fi; RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)"; DISK_GB_VALUE="$(printenv DISK_GB || true)"; RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256"; @@ -46,9 +56,9 @@ app: RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"; fi; if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then - exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; + exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; else - exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; + exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; fi derived_env: - key: DISK_GB diff --git a/apps/portainer/manifest.yml b/apps/portainer/manifest.yml index 70fdac89..868e114e 100644 --- a/apps/portainer/manifest.yml +++ b/apps/portainer/manifest.yml @@ -6,7 +6,7 @@ app: category: development container: - image: 146.59.87.168:3000/lfg2025/portainer:2.19.4 + image: 146.59.87.168:3000/lfg2025/portainer:2.39.1 pull_policy: if-not-present data_uid: "1000:1000" diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index dee3e7e0..9dc4090b 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -154,6 +154,11 @@ impl AppGate { action: &str, client_ip: IpAddr, ) -> Response { + // Assets are GET and pre-auth by nature: the login page cannot + // render its own background or logo without them. + if let Some(name) = action.strip_prefix("asset/") { + return self.serve_asset(name); + } if req.method() != Method::POST { return login_page(app, None, StatusCode::OK); } @@ -187,6 +192,26 @@ impl AppGate { } } + /// Static assets the login page needs, served from the gate's own origin. + /// + /// The backgrounds are ~1 MB each, so inlining them as data URIs would + /// bloat every challenge response. Serving them here keeps the page + /// byte-identical to the dashboard's login while the CSP stays tight: + /// `img-src 'self' data:` and nothing else. + fn serve_asset(&self, name: &str) -> Response { + let Some((bytes, mime)) = read_ui_asset(name) else { + return not_found(); + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime) + // Immutable art; caching it costs nothing and keeps the login + // instant on a repeat challenge. + .header(header::CACHE_CONTROL, "public, max-age=86400") + .body(Body::from(bytes)) + .expect("asset response builds") + } + async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response { let password = field(form, "password").unwrap_or_default(); @@ -428,43 +453,158 @@ fn esc(s: &str) -> String { /// none. Inlined as a data URI rather than linked: the gate is answering on /// the app's own port, so any asset URL would either hit the unauthenticated /// app behind it or a different origin the browser may not reach. +/// One stacked layer per background, each delayed so they cross-fade in turn. +fn background_layers() -> String { + let step = LOGIN_BACKGROUNDS.len() as u32 * 9 / LOGIN_BACKGROUNDS.len() as u32; + LOGIN_BACKGROUNDS + .iter() + .enumerate() + .map(|(i, name)| { + format!( + r#"
"#, + prefix = GATE_PREFIX, + delay = i as u32 * step, + ) + }) + .collect() +} + fn icon_markup(app: &GatedPort) -> String { - if let Some(path) = &app.icon { - if let Some(data_uri) = read_icon_data_uri(path) { - return format!(r#""#, esc(&data_uri)); - } - } - let letter = app - .app_name - .chars() - .next() - .map(|c| c.to_uppercase().to_string()) - .unwrap_or_else(|| "?".to_string()); - format!(r#"
{}
"#, esc(&letter)) + let inner = app + .icon + .as_deref() + .and_then(read_icon_data_uri) + // A manifest that names no icon still gets one: the dashboard already + // ships icons named after the app, so fall back to those before + // giving up. Without this EVERY gated app showed a lettermark, + // because no manifest declares metadata.icon (archi-dev-box, + // 2026-08-05). + .or_else(|| { + icon_candidates(&app.app_id) + .iter() + .find_map(|c| read_icon_data_uri(c)) + }) + .map(|data_uri| format!(r#""#, esc(&data_uri))) + .unwrap_or_else(|| { + let letter = app + .app_name + .chars() + .find(|c| c.is_alphanumeric()) + .map(|c| c.to_uppercase().to_string()) + .unwrap_or_else(|| "?".to_string()); + format!(r#"
{}
"#, esc(&letter)) + }); + format!(r#"
{inner}
"#) } /// Icons live with the web UI. Only files under the icon directory are read, /// and only known image extensions — the path comes from a manifest, which is /// signed, but treating it as untrusted costs nothing. -fn read_icon_data_uri(icon_path: &str) -> Option { - let name = std::path::Path::new(icon_path).file_name()?.to_str()?; - let mime = match name.rsplit_once('.')?.1.to_ascii_lowercase().as_str() { - "svg" => "image/svg+xml", - "png" => "image/png", - "webp" => "image/webp", - "jpg" | "jpeg" => "image/jpeg", - _ => return None, +/// Icon basenames to try for an app id, best first. +/// +/// The shipped icon set is named for the *product*, while app ids carry +/// packaging detail — `filebrowser` vs `file-browser`, `morphos-server` vs +/// `morphos` — and the per-app screens (`lnd-ui`, `bitcoin-ui`, `electrs-ui`) +/// have no icon of their own but obviously belong to the app they front. +/// Resolving those here keeps the mapping in one readable place instead of +/// adding a `metadata.icon` line to every manifest, which would have to be +/// re-signed into the catalog to take effect. +fn icon_candidates(app_id: &str) -> Vec { + let mut out = vec![app_id.to_string()]; + let alias = match app_id { + "filebrowser" => Some("file-browser"), + "home-assistant" => Some("homeassistant"), + "morphos-server" => Some("morphos"), + "barkd" => Some("bark"), + "archy-mempool-web" | "mempool-api" => Some("mempool"), + "lnd-ui" | "lightning-stack" => Some("lnd"), + "bitcoin-ui" => Some("bitcoin-core"), + "electrs-ui" => Some("electrumx"), + "fips-ui" | "aiui" | "did-wallet" => Some("archipelago-a"), + "fedimint-gateway" | "fedimint-clientd" => Some("fedimint"), + _ => None, }; + out.extend(alias.map(str::to_string)); + // `-ui` / `-server` / `-web` front an app whose icon is the bare name. + for suffix in ["-ui", "-server", "-web"] { + if let Some(base) = app_id.strip_suffix(suffix) { + out.push(base.to_string()); + } + } + out +} + +/// Backgrounds the login cycles through, matching the dashboard's own +/// `/login` art. Cross-faded by CSS alone — the CSP forbids script, and a +/// rotation that needs JavaScript would not survive it. +const LOGIN_BACKGROUNDS: [&str; 4] = [ + "bg-intro.jpg", + "bg-intro-4.webp", + "bg-intro-6.webp", + "bg-intro-3.jpg", +]; + +/// Assets the gate will serve, by exact name. An allowlist rather than a path +/// join: the name arrives in a URL, and the gate answers before any +/// authentication, so nothing here may be caller-controlled beyond this set. +fn read_ui_asset(name: &str) -> Option<(Vec, &'static str)> { + let allowed = LOGIN_BACKGROUNDS.contains(&name) || name == "logo-archipelago.svg"; + if !allowed { + return None; + } + let mime = icon_mime(name.rsplit_once('.')?.1)?; for root in [ - "/opt/archipelago/web-ui/assets/img/app-icons", - "web/dist/neode-ui/assets/img/app-icons", + "/opt/archipelago/web-ui/assets/img", + "web/dist/neode-ui/assets/img", + "neode-ui/public/assets/img", ] { - let candidate = std::path::Path::new(root).join(name); - if let Ok(bytes) = std::fs::read(&candidate) { - if bytes.len() > 512 * 1024 { - return None; + if let Ok(bytes) = std::fs::read(std::path::Path::new(root).join(name)) { + return Some((bytes, mime)); + } + } + None +} + +const ICON_ROOTS: [&str; 2] = [ + "/opt/archipelago/web-ui/assets/img/app-icons", + "web/dist/neode-ui/assets/img/app-icons", +]; + +fn icon_mime(ext: &str) -> Option<&'static str> { + match ext.to_ascii_lowercase().as_str() { + "svg" => Some("image/svg+xml"), + "png" => Some("image/png"), + "webp" => Some("image/webp"), + "jpg" | "jpeg" => Some("image/jpeg"), + _ => None, + } +} + +/// Read an app icon as a `data:` URI. +/// +/// `icon_ref` may be a filename or path with an extension (a manifest's +/// `metadata.icon`), or a bare name such as an app id — in which case the +/// known extensions are tried in turn. Only the file name is used; the +/// directories searched are fixed, so a manifest cannot point the gate at an +/// arbitrary path. +fn read_icon_data_uri(icon_ref: &str) -> Option { + let name = std::path::Path::new(icon_ref).file_name()?.to_str()?; + let candidates: Vec<(String, &str)> = match name.rsplit_once('.') { + Some((_, ext)) => vec![(name.to_string(), icon_mime(ext)?)], + None => ["svg", "png", "webp", "jpg"] + .iter() + .filter_map(|ext| Some((format!("{name}.{ext}"), icon_mime(ext)?))) + .collect(), + }; + for (file, mime) in candidates { + for root in ICON_ROOTS { + let candidate = std::path::Path::new(root).join(&file); + if let Ok(bytes) = std::fs::read(&candidate) { + if bytes.len() > 512 * 1024 { + continue; + } + return Some(format!("data:{mime};base64,{}", base64_encode(&bytes))); } - return Some(format!("data:{mime};base64,{}", base64_encode(&bytes))); } } None @@ -484,29 +624,79 @@ fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Respons {title} — {app_name} -
{body}
"#, +{backgrounds}
{body}
"#, title = esc(title), app_name = esc(&app.app_name), body = body, + backgrounds = background_layers(), + cycle = LOGIN_BACKGROUNDS.len() as u32 * 9, + hold = 100 / LOGIN_BACKGROUNDS.len() as u32, + fade = 100 / LOGIN_BACKGROUNDS.len() as u32 + 4, ); Response::builder() .status(status) @@ -514,10 +704,18 @@ button:hover {{ background:#2f6fd6; }} // The gate answers on the app's own port for an unauthenticated // caller; nothing here should be cached or framed. .header(header::CACHE_CONTROL, "no-store") - .header("X-Frame-Options", "DENY") + // NOT X-Frame-Options: DENY. My Apps opens an app in an embedded + // frame, so a blanket DENY made every gated app render as "app is + // not responding" the moment the gate challenged it (reported on + // 100.82.34.38, 2026-08-05). frame-ancestors is the modern control + // and can be precise: only pages from this same node may frame the + // login, on any port or scheme, which is exactly the dashboard. + // Anything else — another site embedding it to harvest the node + // password — is still refused. .header( "Content-Security-Policy", - "default-src 'none'; img-src data:; style-src 'unsafe-inline'; form-action 'self'", + "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \ + form-action 'self'; frame-ancestors 'self' http://*:* https://*:*", ) .body(Body::from(html)) .expect("static response builds") @@ -528,7 +726,8 @@ button:hover {{ background:#2f6fd6; }} /// password by an unexplained page. fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response { let body = format!( - r#"{icon} + r#" +{icon}

Sign in to open {name}

This app is protected by your node password.

{err} @@ -636,11 +835,61 @@ mod tests { assert!(!html.contains(" bool { } } +/// Primary OTA origin. Named host over TLS rather than the bare IP it used +/// to be: the IP pinned the fleet to one machine and one plaintext port, so +/// moving or fronting the origin meant an OTA to change where OTAs come +/// from — the one update you cannot ship if the origin is unreachable. The +/// signature is what establishes trust (see `trust::anchor`), not the +/// transport, but HTTPS also stops a network observer seeing which version +/// a node runs. const DEFAULT_UPDATE_MANIFEST_URL: &str = + "https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json"; + +/// The previous IP-based origin, kept as an automatic fallback so a node +/// whose DNS or TLS is broken still updates. Dropped from the mirror list +/// once the fleet has moved. +const LEGACY_UPDATE_MANIFEST_URL: &str = "http://146.59.87.168: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"; @@ -113,10 +126,19 @@ fn mirrors_path(data_dir: &Path) -> std::path::PathBuf { } fn default_mirrors() -> Vec { - vec![UpdateMirror { - url: DEFAULT_UPDATE_MANIFEST_URL.to_string(), - label: "Server 1 (OVH)".to_string(), - }] + vec![ + UpdateMirror { + url: DEFAULT_UPDATE_MANIFEST_URL.to_string(), + label: "Archipelago Foundation".to_string(), + }, + // Fallback, tried only if the named origin fails: a node whose DNS + // or clock is wrong (both break TLS) must still be able to update + // itself, and the signature check is what makes either source safe. + UpdateMirror { + url: LEGACY_UPDATE_MANIFEST_URL.to_string(), + label: "Direct (fallback)".to_string(), + }, + ] } /// Load the operator-configured mirror list. Returns defaults if the @@ -186,15 +208,18 @@ fn force_ovh_update_primary(list: &mut Vec) { } for mirror in list.iter_mut() { if mirror.url == DEFAULT_UPDATE_MANIFEST_URL { - mirror.label = "Server 1 (OVH)".to_string(); + mirror.label = "Archipelago Foundation".to_string(); + } else if mirror.url == LEGACY_UPDATE_MANIFEST_URL { + mirror.label = "Direct (fallback)".to_string(); } } - list.sort_by_key(|m| { - if m.url == DEFAULT_UPDATE_MANIFEST_URL { - 0 - } else { - 1 - } + // Named origin first, its IP fallback second, anything the operator + // added after that. Ordering matters: the list is tried in order, so a + // stale entry sitting first costs a timeout on every check. + list.sort_by_key(|m| match m.url.as_str() { + u if u == DEFAULT_UPDATE_MANIFEST_URL => 0, + u if u == LEGACY_UPDATE_MANIFEST_URL => 1, + _ => 2, }); } @@ -2373,8 +2398,16 @@ mod tests { 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(), 1); - assert!(list[0].url.contains("146.59.87.168")); + // The named origin leads, its IP fallback follows. A node with broken + // DNS or a wrong clock (both break TLS) must still have a way to + // update; the signature is what makes either source trustworthy. + assert_eq!(list.len(), 2); + assert!( + list[0].url.starts_with("https://source.archipelago-foundation.org/"), + "the named origin must be primary, got {}", + list[0].url + ); + assert!(list[1].url.contains("146.59.87.168")); assert!( !list.iter().any(|m| m.url.contains("git.tx1138.com")), "tx1138 was retired as a release server and must not be a default mirror" diff --git a/scripts/image-versions.sh b/scripts/image-versions.sh index 6857c803..5988c471 100644 --- a/scripts/image-versions.sh +++ b/scripts/image-versions.sh @@ -45,7 +45,13 @@ SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest" CRYPTPAD_IMAGE="$ARCHY_REGISTRY/cryptpad:2024.12.0" FILEBROWSER_IMAGE="$ARCHY_REGISTRY/filebrowser:v2.27.0" NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest" -PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.19.4" +# 2.39.1 is what the fleet has actually been running via the moving :latest +# tag, and it is the version that wrote their databases. Pinning back to +# 2.19.4 (2 years older) made Portainer refuse to start the moment a +# container was recreated: "database schema version does not align with the +# server version" — it migrates a DB forward, never backward. Pinned +# forward and published as a concrete tag so this is reproducible. +PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.39.1" # Networking TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"