diff --git a/CHANGELOG.md b/CHANGELOG.md index becb6680..6c6f0ce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,11 @@ ## v1.7.89-alpha (2026-06-12) -- Restored the established AIUI mobile layout by hiding the Archipelago close pill and removing the newly added back button on phones. -- Embedded AIUI once again receives `hideClose=true`, and its previous 16px mobile spacing allowance is restored without changing shared Mesh, dashboard, or app-session layouts. -- Validation passed with `git diff --check`, `npm run type-check`, and the focused frontend route tests. +- The AI assistant looks the way it always did again: no extra back button or close button on phones, and the desktop view fills the whole screen without a gap at the bottom. +- System updates are much more reliable: updates that previously got stuck partway or failed to install now complete cleanly, and a failed update can no longer block all future updates. +- After an update, the system now checks itself correctly on every node type, so working updates are no longer mistakenly undone. +- Generating a Bitcoin receive address works again on nodes where a network proxy previously got in the way. +- The Lightning wallet now recovers and unlocks itself properly after restarts. ## v1.7.88-alpha (2026-06-12) diff --git a/core/Cargo.lock b/core/Cargo.lock index d3aaab94..142233d4 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.88-alpha" +version = "1.7.89-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 814d130c..d731cb71 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.88-alpha" +version = "1.7.89-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/api/rpc/lnd/info.rs b/core/archipelago/src/api/rpc/lnd/info.rs index 0ed8a4ac..1aee5a2a 100644 --- a/core/archipelago/src/api/rpc/lnd/info.rs +++ b/core/archipelago/src/api/rpc/lnd/info.rs @@ -38,6 +38,7 @@ impl RpcHandler { let macaroon_hex = hex::encode(&macaroon_bytes); let client = reqwest::Client::builder() + .no_proxy() .timeout(std::time::Duration::from_secs(10)) .danger_accept_invalid_certs(true) .build() @@ -180,6 +181,7 @@ impl RpcHandler { let macaroon_hex = hex::encode(&macaroon_bytes); let client = reqwest::Client::builder() + .no_proxy() .danger_accept_invalid_certs(true) .timeout(std::time::Duration::from_secs(10)) .build() diff --git a/core/archipelago/src/api/rpc/lnd/mod.rs b/core/archipelago/src/api/rpc/lnd/mod.rs index 55b3a665..973e2d61 100644 --- a/core/archipelago/src/api/rpc/lnd/mod.rs +++ b/core/archipelago/src/api/rpc/lnd/mod.rs @@ -63,6 +63,7 @@ impl RpcHandler { let macaroon_bytes = read_lnd_admin_macaroon().await?; let macaroon_hex = hex::encode(&macaroon_bytes); let client = reqwest::Client::builder() + .no_proxy() .timeout(std::time::Duration::from_secs(15)) .danger_accept_invalid_certs(true) .build() diff --git a/core/archipelago/src/api/rpc/lnd/wallet.rs b/core/archipelago/src/api/rpc/lnd/wallet.rs index 0f0ae6fe..26ba4e6c 100644 --- a/core/archipelago/src/api/rpc/lnd/wallet.rs +++ b/core/archipelago/src/api/rpc/lnd/wallet.rs @@ -530,6 +530,7 @@ impl RpcHandler { // Call LND REST API to initialize wallet with derived entropy. // LND must be running but NOT yet initialized (no existing wallet). let client = reqwest::Client::builder() + .no_proxy() .timeout(std::time::Duration::from_secs(30)) .danger_accept_invalid_certs(true) .build() diff --git a/core/archipelago/src/container/lnd.rs b/core/archipelago/src/container/lnd.rs index 98f84cb3..8ac77ad8 100644 --- a/core/archipelago/src/container/lnd.rs +++ b/core/archipelago/src/container/lnd.rs @@ -76,7 +76,7 @@ pub async fn ensure_wallet_initialized() -> Result<()> { let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon"; let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db"; if file_exists_as_root(wallet_db).await { - if file_exists_as_root(admin_macaroon).await { + if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await { return Ok(()); } unlock_existing_wallet().await?; @@ -127,6 +127,7 @@ async fn unlock_existing_wallet() -> Result<()> { async fn unlock_existing_wallet_via_rest() -> Result<()> { let client = reqwest::Client::builder() + .no_proxy() .timeout(std::time::Duration::from_secs(20)) .danger_accept_invalid_certs(true) .build() @@ -204,6 +205,7 @@ struct InitWalletRequest { async fn init_wallet_via_rest() -> Result<()> { let client = reqwest::Client::builder() + .no_proxy() .timeout(std::time::Duration::from_secs(20)) .danger_accept_invalid_certs(true) .build() @@ -305,12 +307,12 @@ async fn decode_lnd_unlocker_response Deserialize<'de>>( anyhow::bail!("LND REST {path} returned {status}: {text}") } -#[allow(dead_code)] async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool { let Ok(macaroon) = read_file_as_root(admin_macaroon).await else { return false; }; let Ok(client) = reqwest::Client::builder() + .no_proxy() .timeout(std::time::Duration::from_secs(5)) .danger_accept_invalid_certs(true) .build() diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index cf9cc70a..db4153a6 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -3220,11 +3220,11 @@ app: - /data/.filebrowser.json volumes: - type: bind - source: /tmp/filebrowser-srv + source: /var/lib/archipelago/filebrowser-srv target: /srv options: [rw] - type: bind - source: /tmp/filebrowser-data + source: /var/lib/archipelago/filebrowser-data target: /data options: [rw] "#; @@ -3244,7 +3244,7 @@ app: secret_file: bitcoin-rpc-password volumes: - type: bind - source: /tmp/lnd + source: /var/lib/archipelago/lnd target: /root/.lnd "#; AppManifest::parse(yaml).unwrap() diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 3971a72a..bbfb3e26 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -367,12 +367,20 @@ async fn probe_frontend_once() -> Result<()> { .context("build probe client")?; // Prefer HTTPS since that's the failure mode we're catching (nginx // 500 on the PWA). HTTP usually redirects to HTTPS and would mask - // the bug. - let resp = client - .get("https://127.0.0.1/") - .send() - .await - .context("probe GET https://127.0.0.1/")?; + // the bug. BUT not every node binds 443 on loopback (.116 serves + // plain HTTP; 443 there belongs to tailscale) — on a *connect* + // error, fall back to HTTP so a healthy node isn't "verified" into + // a rollback. An HTTP error status stays fatal on whichever scheme + // answered. + let resp = match client.get("https://127.0.0.1/").send().await { + Ok(resp) => resp, + Err(e) if e.is_connect() => client + .get("http://127.0.0.1/") + .send() + .await + .context("probe GET http://127.0.0.1/ (https not bound on loopback)")?, + Err(e) => return Err(e).context("probe GET https://127.0.0.1/"), + }; let status = resp.status(); if status.is_success() || status.is_redirection() { return Ok(()); @@ -1078,6 +1086,17 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> { let current_binary = Path::new("/usr/local/bin/archipelago"); if current_binary.exists() { let backup_path = backup_dir.join("archipelago"); + // A leftover backup from an earlier rollback can be root-owned + // (rollback used to chown it in place), and fs::copy O_TRUNCs the + // existing file — EACCES as the service user, wedging every apply + // (seen on .116, v1.7.86 OTA). Unlink first; the dir is + // service-owned so unlink works even when the file isn't ours. + if backup_path.exists() { + if let Err(e) = fs::remove_file(&backup_path).await { + tracing::warn!(error = %e, "unlink of stale binary backup failed, retrying via host_sudo"); + let _ = host_sudo(&["rm", "-f", &backup_path.to_string_lossy()]).await; + } + } fs::copy(current_binary, &backup_path) .await .context("Failed to backup current binary")?; @@ -1415,21 +1434,38 @@ pub async fn rollback_update(data_dir: &Path) -> Result<()> { let backup_binary = backup_dir.join("archipelago"); if backup_binary.exists() { - // Use host_sudo + mv so we escape the archipelago service's - // ProtectSystem=strict mount namespace. A plain fs::copy or - // `sudo cp` from inside the service hits EROFS on /usr/local/bin, - // which would silently orphan the rollback — exactly the - // opposite of what auto-rollback is for. Pattern matches - // apply_update()'s binary swap above. + // Same two namespace gotchas as apply_update()'s binary swap: + // `cp` straight onto the running binary is O_TRUNC and fails + // ETXTBSY (exit 1 — exactly what broke the .116 rollback), and + // plain sudo inherits ProtectSystem=strict, so everything goes + // through host_sudo. Copy to a temp name on the same filesystem, + // fix ownership on the TEMP file (never the stored backup — an + // in-place chown is what later wedged apply_update), then mv, + // which is an atomic rename and tolerates a busy destination. let backup_str = backup_binary.to_string_lossy().to_string(); - let _ = host_sudo(&["chmod", "0755", &backup_str]).await; - let _ = host_sudo(&["chown", "root:root", &backup_str]).await; - let status = host_sudo(&["cp", &backup_str, "/usr/local/bin/archipelago"]) + let tmp = format!( + "/usr/local/bin/.archipelago.rollback.{}", + chrono::Utc::now().timestamp_millis() + ); + let copy = host_sudo(&["cp", &backup_str, &tmp]) + .await + .context("Failed to stage backup binary via host_sudo")?; + if !copy.success() { + anyhow::bail!( + "cp backup binary to {} failed (exit {:?})", + tmp, + copy.code() + ); + } + let _ = host_sudo(&["chmod", "0755", &tmp]).await; + let _ = host_sudo(&["chown", "root:root", &tmp]).await; + let status = host_sudo(&["mv", &tmp, "/usr/local/bin/archipelago"]) .await .context("Failed to restore backup binary via host_sudo")?; if !status.success() { + let _ = host_sudo(&["rm", "-f", &tmp]).await; anyhow::bail!( - "cp backup binary into /usr/local/bin failed (exit {:?})", + "mv backup binary into /usr/local/bin failed (exit {:?})", status.code() ); } diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index fcf3d074..e622ff9e 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "neode-ui", - "version": "1.7.88-alpha", + "version": "1.7.89-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "neode-ui", - "version": "1.7.88-alpha", + "version": "1.7.89-alpha", "dependencies": { "@types/dompurify": "^3.0.5", "@vue-leaflet/vue-leaflet": "^0.10.1", diff --git a/neode-ui/package.json b/neode-ui/package.json index 84310293..fbcc2233 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -1,7 +1,7 @@ { "name": "neode-ui", "private": true, - "version": "1.7.88-alpha", + "version": "1.7.89-alpha", "type": "module", "scripts": { "start": "./start-dev.sh", diff --git a/neode-ui/src/views/Dashboard.vue b/neode-ui/src/views/Dashboard.vue index d28f4e42..df9a21ab 100644 --- a/neode-ui/src/views/Dashboard.vue +++ b/neode-ui/src/views/Dashboard.vue @@ -86,8 +86,8 @@
&2; exit 2 ;; + esac + shift +done + +PASS=() FAIL=() +stage() { # stage + local name="$1"; shift + echo + echo "=== [$name] $*" + if "$@"; then + echo "=== [$name] PASS" + PASS+=("$name") + else + echo "=== [$name] FAIL (exit $?)" + FAIL+=("$name") + summary 1 + fi +} +summary() { + echo + echo "──────── release gate summary ────────" + printf 'PASS: %s\n' "${PASS[@]:-none}" + [[ ${#FAIL[@]} -gt 0 ]] && printf 'FAIL: %s\n' "${FAIL[@]}" + exit "${1:-0}" +} + +# ── Stage 1: static ────────────────────────────────────────────────── +stage "git-diff-check" git diff --check +stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check +stage "catalog-drift" python3 scripts/check-app-catalog-drift.py +if [[ $MANIFEST -eq 1 ]]; then + stage "release-manifest" scripts/check-release-manifest.sh +fi + +# ── Stage 2: frontend ──────────────────────────────────────────────── +stage "ui-type-check" bash -c 'cd neode-ui && npm run --silent type-check' +stage "ui-unit-tests" bash -c 'cd neode-ui && npx vitest run --silent 2>&1 | tail -4; exit ${PIPESTATUS[0]}' + +if [[ $WITH_BUILD -eq 1 ]]; then + # npm run build can fail silently (vue-tsc EACCES burned us before) — + # require the packaged output to actually contain the current version. + VERSION=$(grep -m1 '^version' core/archipelago/Cargo.toml | cut -d'"' -f2) + stage "ui-build" bash -c 'cd neode-ui && npm run build' + stage "ui-dist-version" bash -c "grep -rqo '${VERSION}' web/dist/neode-ui/assets/*.js" +fi + +[[ $QUICK -eq 1 ]] && summary 0 + +# ── Stage 3: backend ───────────────────────────────────────────────── +stage "cargo-check" timeout 580 cargo check --manifest-path core/Cargo.toml -p archipelago +# Focused suites for the subsystems this release train touched: +# update:: — OTA download/apply/rollback/probe (v1.7.89 hardening) +# lnd — receive address + wallet readiness (v1.7.85–.89) +# container::image_versions — image pinning / false-update detection +# scanner — RAII in-flight guard (v1.7.84) +# 1500s: the non-incremental test-profile compile alone takes ~9 min on the +# .116 ThinkPad; 580s expires mid-compile (exit 124) before a single test runs. +stage "cargo-test-weekly" timeout 1500 env CARGO_INCREMENTAL=0 \ + cargo test --manifest-path core/Cargo.toml -p archipelago -- \ + update:: lnd container::image_versions scanner + +# ── Stage 4: live node smoke ───────────────────────────────────────── +if [[ $LIVE -eq 1 ]]; then + stage "live-frontend" bash -c "curl -skf -o /dev/null '$LIVE_URL/' || curl -skf -o /dev/null '${LIVE_URL/http:/https:}/'" + stage "live-aiui" curl -sf -o /dev/null "$LIVE_URL/aiui/" + stage "live-rpc" bash -c "curl -s -X POST '$LIVE_URL/rpc/v1' -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"update.status\",\"params\":{}}' | grep -qE '\"(result|error)\"'" +fi + +summary 0