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"