diff --git a/core/Cargo.lock b/core/Cargo.lock index 367f9e4d..9e0c5da4 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.36-alpha" +version = "1.7.37-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 8e318633..88dfa420 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.36-alpha" +version = "1.7.37-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/api/rpc/package/config.rs b/core/archipelago/src/api/rpc/package/config.rs index 598bdce4..e9c2781f 100644 --- a/core/archipelago/src/api/rpc/package/config.rs +++ b/core/archipelago/src/api/rpc/package/config.rs @@ -483,7 +483,30 @@ pub(super) async fn get_app_config( None, None, ), - "bitcoin" | "bitcoin-core" | "bitcoin-knots" => ( + "bitcoin-core" => ( + vec![ + "8332:8332".to_string(), + "8333:8333".to_string(), + "28332:28332".to_string(), + "28333:28333".to_string(), + ], + vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()], + vec![], + None, + // Vanilla bitcoin/bitcoin image has no entrypoint wrapper and reads + // only what's in bitcoin.conf + argv. The shared bitcoin.conf + // carries rpcauth; we inject the networking flags as CLI args so + // RPC is reachable from the bitcoin-ui companion container. + Some(vec![ + "-server=1".to_string(), + "-rpcbind=0.0.0.0".to_string(), + "-rpcallowip=0.0.0.0/0".to_string(), + "-rpcport=8332".to_string(), + "-printtoconsole=1".to_string(), + "-datadir=/home/bitcoin/.bitcoin".to_string(), + ]), + ), + "bitcoin" | "bitcoin-knots" => ( vec![ "8332:8332".to_string(), "8333:8333".to_string(), diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 1d121dfc..3b3c3d9b 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -728,8 +728,11 @@ impl RpcHandler { candidates.push((url, reg.tls_verify)); } } - // If no registries are configured, fall back to the literal URL. - if candidates.is_empty() { + // Always include the literal URL as a last-resort candidate — + // internal mirrors may not host every third-party upstream image + // (e.g. docker.io/bitcoin/bitcoin:28.4), and we don't want + // "app not mirrored" to masquerade as a generic pull failure. + if tried.insert(docker_image.to_string()) { candidates.push((docker_image.to_string(), true)); } @@ -873,6 +876,24 @@ impl RpcHandler { let bitcoin_dir = "/var/lib/archipelago/bitcoin"; let conf_path = format!("{}/bitcoin.conf", bitcoin_dir); + // Idempotent: once bitcoin-knots (or a prior install) has started, + // the data dir is chowned into the container's user namespace + // (e.g. UID 100100 on the host) with 700 perms — the archipelago + // daemon can no longer stat or write there. Treat any non-NotFound + // error on the conf as "conf already provisioned by the container + // user" and skip. Matches the lnd.conf behavior below. + match tokio::fs::metadata(&conf_path).await { + Ok(_) => { + info!("bitcoin.conf already exists, skipping write"); + return Ok(()); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => { + info!("bitcoin.conf path inaccessible (container-owned data dir), skipping write"); + return Ok(()); + } + } + use hmac::{Hmac, Mac}; use sha2::Sha256; let salt_bytes: [u8; 16] = rand::random(); @@ -883,12 +904,14 @@ impl RpcHandler { let hash_hex = hex::encode(mac.finalize().into_bytes()); let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex); + // Default to full archive — operators with 2TB+ drives shouldn't be + // silently pruned down to 550 MB. Users who want a pruned node can + // set `prune=N` in bitcoin.conf themselves after install. let bitcoin_conf = format!( "\ # rpcauth: salted hash only — no plaintext password in config or CLI\n\ {}\n\ server=1\n\ -prune=550\n\ rpcbind=0.0.0.0\n\ rpcallowip=0.0.0.0/0\n\ rpcport=8332\n\ diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index dfdbbe7c..dea50098 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -1,15 +1,18 @@ -//! Bootstrap host-side doctor artifacts on every archipelago startup. +//! Bootstrap host-side artifacts on every archipelago startup. //! //! The update pipeline swaps the archipelago binary but does not touch -//! scripts or systemd units — those are installed once by the ISO builder. -//! Without this module, changes to `container-doctor.sh` or the doctor -//! service/timer never reach boxes installed before the change. +//! scripts, systemd units, or nginx configuration — those are installed +//! once by the ISO builder. Without this module, changes to +//! `container-doctor.sh`, the doctor service/timer, or the nginx config +//! never reach boxes installed before the change. //! -//! On startup we compare three embedded files against their on-disk -//! copies and rewrite any that differ, then enable the doctor timer if -//! it isn't already. Idempotent: no-ops on boxes that match the -//! embedded version. All work is best-effort — failures are logged but -//! never abort the backend. +//! Two things are synced on startup: +//! 1. Doctor artifacts (container-doctor.sh + service + timer). +//! 2. An nginx `location /api/app-catalog` proxy block — required for +//! the App Store catalog proxy to actually reach the backend. +//! +//! Idempotent: no-ops on boxes that are already in sync. All work is +//! best-effort — failures are logged but never abort the backend. use anyhow::{Context, Result}; use std::path::Path; @@ -28,14 +31,26 @@ const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.s const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service"; const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer"; +const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago"; + +/// Inserted into every server block of the nginx config that lacks the +/// `/api/app-catalog` proxy. Kept in sync with the canonical block in +/// image-recipe/configs/nginx-archipelago.conf. +const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location /api/app-catalog {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n"; + /// Entry point called from main startup. Never returns an error to the caller — -/// failing to bootstrap the doctor must not prevent the backend from serving. +/// failing to bootstrap host artifacts must not prevent the backend from serving. pub async fn ensure_doctor_installed() { match run().await { Ok(changed) if changed => info!("Doctor artifacts synchronized with binary"), Ok(_) => debug!("Doctor artifacts already in sync"), Err(e) => warn!("Doctor bootstrap failed (non-fatal): {:#}", e), } + match run_nginx().await { + Ok(true) => info!("Patched nginx config to proxy /api/app-catalog"), + Ok(false) => debug!("Nginx already has /api/app-catalog block"), + Err(e) => warn!("Nginx bootstrap failed (non-fatal): {:#}", e), + } } async fn run() -> Result { @@ -150,3 +165,100 @@ async fn is_timer_enabled() -> bool { .map(|s| s.success()) .unwrap_or(false) } + +/// Patch the nginx site config to add a `/api/app-catalog` proxy block if +/// it's missing. The original ISO shipped individual per-endpoint `location` +/// blocks and no catch-all `/api/`, so `/api/app-catalog` silently fell +/// through to the SPA `index.html` and the frontend got HTML instead of +/// JSON. We anchor the insert to the DWN comment that already sits right +/// after the `/api/blob` block, so the new block lands in both the HTTP +/// and HTTPS server blocks. +/// +/// Validates via `nginx -t` before reloading. On failure the patch is +/// rolled back from a backup written just before the write. +async fn run_nginx() -> Result { + // Skip on dev symlinks — we don't want to touch `/etc/nginx` on laptops. + let home_archy = Path::new("/home/archipelago/archy"); + if fs::symlink_metadata(home_archy) + .await + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + return Ok(false); + } + + if !Path::new(NGINX_CONF_PATH).exists() { + debug!( + "{} missing — skipping nginx bootstrap", + NGINX_CONF_PATH + ); + return Ok(false); + } + + let content = fs::read_to_string(NGINX_CONF_PATH) + .await + .with_context(|| format!("read {}", NGINX_CONF_PATH))?; + if content.contains("location /api/app-catalog") { + return Ok(false); + } + + // The DWN comment sits at the same indent right after the `/api/blob` + // block in both server blocks — a stable anchor that existed on every + // ISO shipped to date. If it's absent (config got heavily customized), + // we bail rather than guess where to splice. + let anchor = " # DWN endpoints — peer access over Tor (no auth)"; + if !content.contains(anchor) { + warn!("nginx conf missing DWN anchor — skipping /api/app-catalog patch"); + return Ok(false); + } + + let replacement = format!("{}{}", NGINX_APP_CATALOG_BLOCK, anchor); + let patched = content.replace(anchor, &replacement); + + // Write patched config via a user-owned tmp + sudo mv, after stashing + // a backup so we can revert if `nginx -t` hates what we produced. + let pid = std::process::id(); + let tmp = format!("/tmp/archipelago-nginx-{}.conf", pid); + fs::write(&tmp, &patched) + .await + .with_context(|| format!("write {}", tmp))?; + + let backup = format!("/tmp/archipelago-nginx-backup-{}.conf", pid); + if let Err(e) = host_sudo(&["cp", NGINX_CONF_PATH, &backup]).await { + let _ = fs::remove_file(&tmp).await; + return Err(e.context("backup nginx conf")); + } + + let mv = host_sudo(&["mv", &tmp, NGINX_CONF_PATH]).await; + match mv { + Ok(s) if s.success() => {} + Ok(s) => { + let _ = fs::remove_file(&tmp).await; + anyhow::bail!("sudo mv nginx conf exited with {}", s); + } + Err(e) => { + let _ = fs::remove_file(&tmp).await; + return Err(e.context("mv tmp -> nginx conf")); + } + } + + // Validate. + let test = host_sudo(&["nginx", "-t"]).await; + let valid = matches!(&test, Ok(s) if s.success()); + if !valid { + warn!("nginx -t failed after patch — reverting"); + let _ = host_sudo(&["mv", &backup, NGINX_CONF_PATH]).await; + if let Err(e) = test { + return Err(e.context("nginx -t")); + } + anyhow::bail!("nginx config invalid after patch — reverted"); + } + + // Reload nginx so the new block takes effect immediately. Reload (not + // restart) keeps in-flight connections alive. + if let Err(e) = host_sudo(&["systemctl", "reload", "nginx"]).await { + warn!("nginx reload failed (non-fatal): {:#}", e); + } + let _ = host_sudo(&["rm", "-f", &backup]).await; + Ok(true) +} diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 6a842b4b..8a97f5eb 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -297,9 +297,16 @@ fn get_app_tier(app_id: &str) -> &'static str { fn get_app_metadata(app_id: &str) -> AppMetadata { let mut meta = match app_id { - "bitcoin" | "bitcoin-core" | "bitcoin-knots" => AppMetadata { + "bitcoin-core" => AppMetadata { + title: "Bitcoin Core".to_string(), + description: "Reference Bitcoin node implementation".to_string(), + icon: "/assets/img/app-icons/bitcoin-core.svg".to_string(), + repo: "https://github.com/bitcoin/bitcoin".to_string(), + tier: "", + }, + "bitcoin" | "bitcoin-knots" => AppMetadata { title: "Bitcoin Knots".to_string(), - description: "Full Bitcoin node implementation".to_string(), + description: "Enhanced Bitcoin node implementation".to_string(), icon: "/assets/img/app-icons/bitcoin-knots.webp".to_string(), repo: "https://github.com/bitcoinknots/bitcoin".to_string(), tier: "", diff --git a/docker/bitcoin-ui/Dockerfile b/docker/bitcoin-ui/Dockerfile index 7e1fae50..86319f4b 100644 --- a/docker/bitcoin-ui/Dockerfile +++ b/docker/bitcoin-ui/Dockerfile @@ -1,6 +1,7 @@ FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine COPY index.html /usr/share/nginx/html/ COPY 50x.html /usr/share/nginx/html/ +COPY assets/ /usr/share/nginx/html/assets/ COPY nginx.conf /etc/nginx/conf.d/default.conf # Run nginx as root to avoid chown failures in rootless Podman user namespaces RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \ diff --git a/docker/bitcoin-ui/assets/img/app-icons/bitcoin-core.svg b/docker/bitcoin-ui/assets/img/app-icons/bitcoin-core.svg new file mode 100644 index 00000000..74fb0304 --- /dev/null +++ b/docker/bitcoin-ui/assets/img/app-icons/bitcoin-core.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/docker/bitcoin-ui/assets/img/app-icons/bitcoin-knots.webp b/docker/bitcoin-ui/assets/img/app-icons/bitcoin-knots.webp new file mode 100644 index 00000000..3441e3d9 Binary files /dev/null and b/docker/bitcoin-ui/assets/img/app-icons/bitcoin-knots.webp differ diff --git a/docker/bitcoin-ui/assets/img/bg-network.jpg b/docker/bitcoin-ui/assets/img/bg-network.jpg new file mode 100644 index 00000000..f474fa1f Binary files /dev/null and b/docker/bitcoin-ui/assets/img/bg-network.jpg differ diff --git a/docker/bitcoin-ui/index.html b/docker/bitcoin-ui/index.html index fc039a0c..ac81d9e9 100644 --- a/docker/bitcoin-ui/index.html +++ b/docker/bitcoin-ui/index.html @@ -6,7 +6,7 @@ - Bitcoin Knots - Archipelago + Bitcoin Node - Archipelago