Compare commits
17
Commits
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.93-alpha (2026-06-14)
|
||||
|
||||
- Receiving Bitcoin and Lightning works again on nodes where the Lightning wallet was stuck locked. After some updates the wallet could come back locked with a password the node no longer had, so "generate a receive address" kept failing with a "wallet is locked" message that nothing could clear. The node now detects this and repairs itself automatically.
|
||||
- Each node now secures its Lightning wallet with its own unique, randomly generated password instead of a shared built-in one, and remembers it safely so the wallet unlocks on its own after every restart or update — no more getting stuck locked.
|
||||
- If a wallet is found locked with an unrecoverable password, the node rebuilds it cleanly so Bitcoin and Lightning start working again. (On these early-access nodes the wallet holds no funds, so nothing is lost — a wallet locked with an unknown password was already inaccessible.)
|
||||
- The self-repair was validated end to end on live nodes: a stuck, locked wallet was detected, rebuilt, and came back unlocked on its own, and stayed unlocked across restarts.
|
||||
|
||||
## v1.7.92-alpha (2026-06-14)
|
||||
|
||||
- The Electrum server app no longer flashes a "can't connect, try again" error over its loading screen while it's still catching up. If ElectrumX is building its index or waiting on the Bitcoin node, you now just see the sync progress, and the app opens on its own once it's ready.
|
||||
- Behind the scenes, the reboot-survival test now confirms the whole system is genuinely healthy after a restart — every app reachable, updates not stuck, core services answering — instead of only checking that containers came back, so update-related problems are caught before shipping.
|
||||
- Settings → What's New now lists the notes for every recent release again. The screen had quietly fallen several versions behind, so the last eight releases of changes weren't showing up there — they're all back now, and a release check keeps it from drifting again.
|
||||
|
||||
## v1.7.91-alpha (2026-06-14)
|
||||
|
||||
- Apps you've installed now reliably show their "Open" button again. Some apps — including Jellyfin, BTCPay Server, Fedimint, Gitea and Portainer — were running fine but their launch link sometimes went missing, so there was no way to open them from the home screen. They now open correctly.
|
||||
|
||||
Generated
+1
-1
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.90-alpha"
|
||||
version = "1.7.92-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.91-alpha"
|
||||
version = "1.7.93-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -33,6 +33,19 @@ impl RpcHandler {
|
||||
|
||||
tracing::info!("[onboarding] login successful");
|
||||
|
||||
// Best-effort: heal a LOCKED LND wallet created with an unknown/legacy
|
||||
// password by rotating it onto the per-node secret, using the password
|
||||
// the user just authenticated with as a candidate. Non-blocking so login
|
||||
// is never slowed or broken when LND isn't installed / already unlocked.
|
||||
let candidate = password.to_string();
|
||||
tokio::spawn(async move {
|
||||
match crate::container::lnd::migrate_locked_wallet(&[candidate]).await {
|
||||
Ok(true) => tracing::info!("[login] LND wallet healed / auto-unlocked"),
|
||||
Ok(false) => {} // not locked, or seed-recovery required
|
||||
Err(e) => tracing::debug!("[login] LND wallet migration skipped: {e}"),
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure NostrVPN config exists — covers the case where onboardingComplete
|
||||
// was never called (e.g., user took the "already set up" shortcut).
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
|
||||
@@ -552,8 +552,15 @@ impl RpcHandler {
|
||||
let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(entropy);
|
||||
entropy.zeroize();
|
||||
|
||||
// Use the per-node secret as the LND wallet password (NOT the
|
||||
// caller-supplied one) so the unattended boot path can auto-unlock this
|
||||
// wallet. The wallet stays recoverable from the Archipelago seed via the
|
||||
// derived entropy above. This unifies both init paths on one password
|
||||
// source — the divergence here is what left wallets locked fleet-wide.
|
||||
let _ = wallet_password; // accepted for API compat; superseded by the per-node secret
|
||||
let node_wallet_pw = crate::container::lnd::ensure_wallet_password().await?;
|
||||
let wallet_password_b64 =
|
||||
base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(node_wallet_pw.as_bytes());
|
||||
|
||||
// Call LND REST API to initialize wallet with derived entropy.
|
||||
// LND must be running but NOT yet initialized (no existing wallet).
|
||||
|
||||
@@ -11,7 +11,16 @@ use crate::update::host_sudo;
|
||||
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd";
|
||||
pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf";
|
||||
const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
|
||||
pub const WALLET_PASSWORD: &str = "hellohello";
|
||||
|
||||
/// Per-node LND wallet password file (random, 0600). Replaces the old
|
||||
/// fleet-wide hardcoded constant: each node's wallet password is now unique,
|
||||
/// high-entropy, and recorded here so the unattended boot path can auto-unlock.
|
||||
const WALLET_PASSWORD_SECRET: &str = "/var/lib/archipelago/secrets/lnd-wallet-password";
|
||||
|
||||
/// Legacy fleet-wide wallet password (builds that hardcoded it). Kept ONLY as an
|
||||
/// unlock fallback so wallets created by those builds still open; new wallets
|
||||
/// never use it, and the login-path migration rotates away from it.
|
||||
const LEGACY_WALLET_PASSWORD: &str = "hellohello";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnsurePaths {
|
||||
@@ -79,15 +88,125 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
match unlock_existing_wallet().await? {
|
||||
true => {
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
false => {
|
||||
// Every candidate password was actively rejected: this wallet was
|
||||
// created with a password this node no longer has, so it can never
|
||||
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
|
||||
// locked with an unknown password is already inaccessible, so wipe +
|
||||
// recreate it on the per-node secret to self-heal at boot.
|
||||
recreate_wallet_destructively().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init_wallet_via_rest().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await
|
||||
}
|
||||
|
||||
/// LND data subdirectories holding wallet + channel + graph state. Removing them
|
||||
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
|
||||
/// so deletion is destructive — only done once the wallet is already unrecoverable.
|
||||
const LND_STATE_DIRS: &[&str] = &[
|
||||
"/var/lib/archipelago/lnd/data/chain",
|
||||
"/var/lib/archipelago/lnd/data/graph",
|
||||
];
|
||||
|
||||
/// Podman container name for the core LND app (see `compute_container_name`:
|
||||
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
|
||||
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
|
||||
const LND_CONTAINER: &str = "lnd";
|
||||
|
||||
/// Archipelago data dir (default; not overridden in prod). Holds the
|
||||
/// `user-stopped.json` that gates health-monitor auto-restart.
|
||||
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
||||
|
||||
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
|
||||
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
|
||||
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
|
||||
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
|
||||
/// candidate password can open the existing wallet.
|
||||
async fn recreate_wallet_destructively() -> Result<()> {
|
||||
tracing::warn!(
|
||||
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
|
||||
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
|
||||
);
|
||||
|
||||
// The health monitor restarts any container it sees stopped; mark LND
|
||||
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
|
||||
// Always cleared below so LND auto-recovers normally afterwards.
|
||||
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
|
||||
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
let result = wipe_and_reinit_wallet().await;
|
||||
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn wipe_and_reinit_wallet() -> Result<()> {
|
||||
podman_user_scoped(&["stop", LND_CONTAINER])
|
||||
.await
|
||||
.context("stopping lnd before wallet wipe")?;
|
||||
|
||||
for dir in LND_STATE_DIRS {
|
||||
let status = host_sudo(&["rm", "-rf", dir])
|
||||
.await
|
||||
.with_context(|| format!("removing {dir}"))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("removing {dir} exited with {status}");
|
||||
}
|
||||
}
|
||||
|
||||
podman_user_scoped(&["start", LND_CONTAINER])
|
||||
.await
|
||||
.context("restarting lnd after wallet wipe")?;
|
||||
|
||||
wait_for_wallet_state("NON_EXISTING").await?;
|
||||
init_wallet_via_rest().await
|
||||
}
|
||||
|
||||
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
|
||||
/// how the orchestrator/health-monitor manage rootless containers (keeps the
|
||||
/// container out of the archipelago service's cgroup).
|
||||
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
|
||||
let out = tokio::process::Command::new("systemd-run")
|
||||
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
|
||||
async fn wait_for_wallet_state(target: &str) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
for _ in 0..120 {
|
||||
if wallet_state(&client).await.as_deref() == Some(target) {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND did not reach state {target} after wallet wipe")
|
||||
}
|
||||
|
||||
async fn file_exists_as_root(path: &str) -> bool {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return true;
|
||||
@@ -121,11 +240,96 @@ async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn unlock_existing_wallet() -> Result<()> {
|
||||
/// Read the per-node wallet password from the secrets file, if present.
|
||||
/// Never generates one — absence means "fall back to legacy / not set yet".
|
||||
async fn read_wallet_password() -> Option<String> {
|
||||
let bytes = fs::read(WALLET_PASSWORD_SECRET).await.ok()?;
|
||||
let pw = String::from_utf8_lossy(&bytes).trim().to_string();
|
||||
(!pw.is_empty()).then_some(pw)
|
||||
}
|
||||
|
||||
/// Return the per-node wallet password, generating and persisting a fresh
|
||||
/// 256-bit one (base64, 0600) if none exists. Use ONLY when creating a NEW
|
||||
/// wallet — calling it merely to unlock an existing wallet would record a
|
||||
/// password that doesn't match it.
|
||||
pub(crate) async fn ensure_wallet_password() -> Result<String> {
|
||||
if let Some(pw) = read_wallet_password().await {
|
||||
return Ok(pw);
|
||||
}
|
||||
use rand::RngCore;
|
||||
let mut raw = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut raw);
|
||||
let pw = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
|
||||
let path = std::path::Path::new(WALLET_PASSWORD_SECRET);
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", dir.display()))?;
|
||||
}
|
||||
fs::write(path, &pw)
|
||||
.await
|
||||
.with_context(|| format!("writing {WALLET_PASSWORD_SECRET}"))?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await;
|
||||
Ok(pw)
|
||||
}
|
||||
|
||||
/// Candidate passwords to try when unlocking an EXISTING wallet, in order: the
|
||||
/// per-node secret (current scheme) first, then the legacy constant so wallets
|
||||
/// created by older builds still open.
|
||||
async fn unlock_password_candidates() -> Vec<String> {
|
||||
let mut v = Vec::new();
|
||||
if let Some(pw) = read_wallet_password().await {
|
||||
v.push(pw);
|
||||
}
|
||||
v.push(LEGACY_WALLET_PASSWORD.to_string());
|
||||
v
|
||||
}
|
||||
|
||||
/// Outcome of a single unlock attempt — lets the caller fail fast on a wrong
|
||||
/// password (no point retrying) vs keep waiting for LND to come up.
|
||||
enum UnlockAttempt {
|
||||
Unlocked,
|
||||
WrongPassword,
|
||||
NotReady,
|
||||
}
|
||||
|
||||
/// One unlock POST, no internal retry. Distinguishes "invalid passphrase"
|
||||
/// (WrongPassword — try the next candidate, don't retry) from transient
|
||||
/// not-ready / connection errors (NotReady — worth retrying).
|
||||
async fn try_unlock_once(client: &reqwest::Client, password: &str) -> UnlockAttempt {
|
||||
let body = serde_json::json!({
|
||||
"wallet_password": base64::engine::general_purpose::STANDARD.encode(password)
|
||||
});
|
||||
match client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/unlockwallet"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() || text.contains("already unlocked") {
|
||||
UnlockAttempt::Unlocked
|
||||
} else if text.contains("invalid passphrase") {
|
||||
UnlockAttempt::WrongPassword
|
||||
} else {
|
||||
UnlockAttempt::NotReady
|
||||
}
|
||||
}
|
||||
Err(_) => UnlockAttempt::NotReady,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlock an existing wallet. Ok(true) = unlocked; Ok(false) = every candidate
|
||||
/// password was actively rejected (unrecoverable — caller should recreate);
|
||||
/// Err = transient (LND not ready / timeout — caller should retry, NOT wipe).
|
||||
async fn unlock_existing_wallet() -> Result<bool> {
|
||||
unlock_existing_wallet_via_rest().await
|
||||
}
|
||||
|
||||
async fn unlock_existing_wallet_via_rest() -> Result<()> {
|
||||
async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
@@ -133,57 +337,130 @@ async fn unlock_existing_wallet_via_rest() -> Result<()> {
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
|
||||
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
|
||||
match post_lnd_unlocker_json::<serde_json::Value>(
|
||||
&client,
|
||||
"/v1/unlockwallet",
|
||||
serde_json::json!({ "wallet_password": wallet_password }),
|
||||
)
|
||||
.await
|
||||
.context("unlocking existing LND wallet")?
|
||||
{
|
||||
UnlockerResponse::Value(_) | UnlockerResponse::WalletAlreadyExists => Ok(()),
|
||||
let candidates = unlock_password_candidates().await;
|
||||
// Retry only while LND's unlocker isn't ready yet. If every candidate is
|
||||
// *actively rejected* (invalid passphrase), retrying can't help — fail fast
|
||||
// with a clear message instead of hanging the boot path for 60s+ (the wallet
|
||||
// was created with a password this node doesn't have → migration/recovery).
|
||||
for _ in 0..60 {
|
||||
let mut all_rejected = true;
|
||||
for pw in &candidates {
|
||||
match try_unlock_once(&client, pw).await {
|
||||
UnlockAttempt::Unlocked => return Ok(true),
|
||||
UnlockAttempt::WrongPassword => {}
|
||||
UnlockAttempt::NotReady => all_rejected = false,
|
||||
}
|
||||
}
|
||||
if all_rejected {
|
||||
tracing::warn!(
|
||||
"[lnd] none of the {} candidate password(s) unlock the wallet — it was created \
|
||||
with a password this node does not have",
|
||||
candidates.len()
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND wallet unlock timed out waiting for the unlocker to become ready")
|
||||
}
|
||||
|
||||
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
|
||||
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
||||
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/state"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let v: serde_json::Value = resp.json().await.ok()?;
|
||||
v.get("state")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// ChangePassword via WalletUnlocker (wallet must be LOCKED). Both passwords are
|
||||
/// base64-encoded. Ok(true) = current accepted and rotated; Ok(false) = current
|
||||
/// rejected (wrong password — try the next candidate); Err = transport/other.
|
||||
async fn change_wallet_password(
|
||||
client: &reqwest::Client,
|
||||
current: &str,
|
||||
new: &str,
|
||||
) -> Result<bool> {
|
||||
let body = serde_json::json!({
|
||||
"current_password": base64::engine::general_purpose::STANDARD.encode(current),
|
||||
"new_password": base64::engine::general_purpose::STANDARD.encode(new),
|
||||
});
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/changepassword"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("calling LND changepassword")?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if status.is_success() {
|
||||
Ok(true)
|
||||
} else if text.contains("invalid passphrase") {
|
||||
Ok(false)
|
||||
} else {
|
||||
anyhow::bail!("LND changepassword returned {status}: {text}")
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn unlock_existing_wallet_via_lncli() -> Result<()> {
|
||||
let mut last_err = None;
|
||||
for _ in 0..60 {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["exec", "-i", "lnd", "lncli", "unlock", "--stdin"]);
|
||||
cmd.stdin(std::process::Stdio::piped());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
/// Best-effort migration of a LOCKED wallet onto the per-node secret. Called at
|
||||
/// login, when the onboarding password is available as a candidate. If the
|
||||
/// per-node secret already opens the wallet, just unlock. Otherwise try each
|
||||
/// candidate as the CURRENT password and ChangePassword it to a fresh per-node
|
||||
/// secret so all future boots auto-unlock. Ok(true) = healed/unlocked;
|
||||
/// Ok(false) = not locked, or no candidate worked (seed-recovery required).
|
||||
pub(crate) async fn migrate_locked_wallet(candidates: &[String]) -> Result<bool> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
|
||||
let mut child = cmd.spawn().context("spawning lncli wallet unlock")?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
stdin
|
||||
.write_all(format!("{}\n", WALLET_PASSWORD).as_bytes())
|
||||
.await
|
||||
.context("writing lncli password")?;
|
||||
}
|
||||
let out = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.context("waiting for lncli")?;
|
||||
if out.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let msg = format!("{stderr}{stdout}");
|
||||
if msg.contains("wallet already unlocked") || msg.contains("already unlocked") {
|
||||
return Ok(());
|
||||
}
|
||||
last_err = Some(msg);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
// Only act on a wallet that is actually LOCKED.
|
||||
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
|
||||
return Ok(false);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"lncli wallet unlock failed: {}",
|
||||
last_err.unwrap_or_else(|| "unknown error".to_string())
|
||||
)
|
||||
|
||||
// If the per-node secret already opens it, nothing to rotate — just unlock.
|
||||
if let Some(secret) = read_wallet_password().await {
|
||||
if matches!(
|
||||
try_unlock_once(&client, &secret).await,
|
||||
UnlockAttempt::Unlocked
|
||||
) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
// The wallet's new password becomes the per-node secret (generate if absent).
|
||||
let new_secret = ensure_wallet_password().await?;
|
||||
|
||||
// ChangePassword requires LOCKED; bail out if a prior step already unlocked.
|
||||
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
for cand in candidates {
|
||||
if cand.is_empty() || *cand == new_secret {
|
||||
continue;
|
||||
}
|
||||
match change_wallet_password(&client, cand, &new_secret).await {
|
||||
Ok(true) => {
|
||||
tracing::info!("[lnd-migrate] rotated locked wallet onto the per-node secret");
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false) => continue, // wrong current password — try next candidate
|
||||
Err(e) => tracing::debug!("[lnd-migrate] changepassword error: {e}"),
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
"[lnd-migrate] no candidate password opened the wallet — seed-recovery required"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -225,7 +502,8 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
anyhow::bail!("LND genseed returned no seed words");
|
||||
}
|
||||
|
||||
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
|
||||
let wallet_password =
|
||||
base64::engine::general_purpose::STANDARD.encode(ensure_wallet_password().await?);
|
||||
let req = InitWalletRequest {
|
||||
wallet_password,
|
||||
cipher_seed_mnemonic: seed.cipher_seed_mnemonic,
|
||||
@@ -239,7 +517,9 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
.context("initializing LND wallet")?
|
||||
{
|
||||
UnlockerResponse::Value(_) => {}
|
||||
UnlockerResponse::WalletAlreadyExists => unlock_existing_wallet().await?,
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -450,7 +730,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_password_is_valid_for_lncli() {
|
||||
assert!(WALLET_PASSWORD.len() > 8);
|
||||
fn legacy_wallet_password_is_valid_for_lncli() {
|
||||
// Legacy fallback must still be a valid lncli passphrase (>8 chars).
|
||||
assert!(LEGACY_WALLET_PASSWORD.len() > 8);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlock_candidates_always_include_legacy_fallback() {
|
||||
// With no per-node secret on disk in the test env, candidates fall back
|
||||
// to the legacy constant so old wallets still open.
|
||||
let cands = unlock_password_candidates().await;
|
||||
assert!(cands.iter().any(|p| p == LEGACY_WALLET_PASSWORD));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,86 @@ Last updated: 2026-06-14 (session on node .116 / archi-thinkpad)
|
||||
|
||||
---
|
||||
|
||||
# ▶ CURRENT PASS — v1.7.91-alpha (2026-06-14)
|
||||
# ▶ IN PROGRESS — LND wallet auto-unlock fix (2026-06-14)
|
||||
|
||||
## RESUME PROMPT (paste into a fresh session, any machine)
|
||||
## RESUME PROMPT (paste into a fresh session, on .116 / archi-thinkpad, tree at /home/archipelago/Projects/archy)
|
||||
|
||||
> Resume the v1.7.91-alpha release pass for the `archy` repo (on node .116 / archi-thinkpad
|
||||
> the tree is at /home/archipelago/Projects/archy; on another machine, clone/pull `main` from
|
||||
> gitea-vps2 http://146.59.87.168:3000/lfg2025/archy.git — my fix commit is pushed there).
|
||||
> Read the top section of docs/WEEKLY_RELEASE_TRACKER.md FIRST — it has the blocker, the fix
|
||||
> already made, and exact next steps. Two goals: (1) cut & PUBLISH v1.7.91-alpha, (2) finish
|
||||
> validating + integrating the new tests/lifecycle/os-audit.sh OS-wide health harness.
|
||||
> Do NOT redo: the bitcoinReceive.ts TS2538 fix (done, committed) or the os-audit jq false-trap
|
||||
> fix (done). Resume at "EXACT NEXT STEPS — v1.7.91" below. .116 login password: ThisIsWeb54321@
|
||||
> (.116 serves http on :80 → ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=http).
|
||||
> Resume the LND wallet-password fix. Read memory `project_lnd_wallet_password.md` FIRST (full
|
||||
> root-cause + design + validated facts). Work is on branch `lnd-wallet-password-fix` (pushed to
|
||||
> gitea-vps2, commit 91adc281, NOT merged to main, NOT shipped). Bug: hardcoded
|
||||
> `WALLET_PASSWORD="hellohello"` left LND wallets LOCKED fleet-wide after OTA → Bitcoin-receive
|
||||
> shows "wallet is locked" on every updated node. DONE + cargo-checked: per-node random secret
|
||||
> (secrets/lnd-wallet-password), both init paths unified, candidate-unlock with fail-fast,
|
||||
> login-time candidate-migration (ChangePassword). DETECTION GATE already shipped on main
|
||||
> (commit 8c8e4d7a). DECISION: alpha, NO funds on nodes → destructive wipe+recreate is OK and
|
||||
> wanted UNATTENDED for ALL nodes in the next update. A wallet locked with an unknown password is
|
||||
> already inaccessible, so wiping loses nothing reachable.
|
||||
|
||||
## EXACT NEXT STEPS — LND fix (in order)
|
||||
1. **Finish seed/fresh recovery** (REMAINING piece): in `container/lnd.rs ensure_wallet_initialized`,
|
||||
when wallet.db exists but ALL unlock candidates fail → wipe wallet.db (+ macaroons + graph/chain
|
||||
mainnet state, as root via host_sudo) and re-init fresh (random genseed + per-node secret) so the
|
||||
node self-heals unattended at boot. (Login-time candidate-migration already handles nodes whose
|
||||
pw matches.) Validate the wipe→reinit mechanic on the scratch LND first (see below).
|
||||
2. **Scratch validation** (was in progress, .249 unreachable from .116's subnet → use a throwaway
|
||||
`lnd-scratch` podman container on .116, regtest/neutrino, REST :18099 — already proven for
|
||||
init/unlock/ChangePassword). Test: init(passA) → restart→LOCKED → delete wallet.db while locked →
|
||||
confirm /v1/state→NON_EXISTING (may need container restart) → genseed+initwallet fresh → unlock.
|
||||
NOTE: scratch wallet.db lives at the container's LND data dir (regtest), `podman exec lnd-scratch
|
||||
find / -name wallet.db`. CLEAN UP: `podman rm -f lnd-scratch` when done.
|
||||
3. `cargo check -p archipelago` (on .116 ~15-30s incremental; full test compile ~9min).
|
||||
4. **End-to-end on .228** (reachable 192.168.1.x, SSH pw `archipelago`, UI pw unknown, NO funds —
|
||||
has a locked unknown-pw wallet = perfect auto-recreate test): build binary
|
||||
(`ARCHIPELAGO_TARGET=archipelago@192.168.1.228 scripts/deploy-to-target.sh` or per
|
||||
reference_deploy_to_nodes), deploy, restart, confirm wallet auto-recreates+unlocks, lncli state
|
||||
RPC_ACTIVE, lnd.newaddress returns an address. Run os-audit against .228 → lnd check PASS.
|
||||
5. Merge `lnd-wallet-password-fix` → main, then **cut + publish v1.7.93-alpha** (carries the LND
|
||||
fix). Ship ritual: create-release.sh 1.7.93-alpha → add CHANGELOG (≥3 layman bullets) → run
|
||||
sync-whats-new.py (the new What's-New gate will require it) → publish-release-assets.sh gitea-vps2
|
||||
→ push origin/gitea-vps2 + tags → verify live manifest==1.7.93-alpha. Heads-up: create-release
|
||||
leaves core/Cargo.lock version-bump uncommitted (commit it as a chore, both .91 and .92 hit this).
|
||||
|
||||
## Context: how we got here (this session, all on node .116)
|
||||
- Shipped **v1.7.91-alpha** (bitcoinReceive TS2538 build fix) and **v1.7.92-alpha** (ElectrumX
|
||||
overlay-during-sync fix; L3 reboot os-audit gate; What's-New sync gate + 8-version backfill) —
|
||||
both LIVE on vps2. Restored .116-local nginx `/lnd-connect-info` route (was dropped 2026-06-10).
|
||||
- Triaged user symptoms: ElectrumX "can't connect" = electrs syncing / Bitcoin verifying (not a
|
||||
regression); .228 "5/14 apps after reboot" = normal ~5min staggered startup (all 14 came up).
|
||||
- LND lock bug found + detection gate shipped + forward fix & migration implemented (this section).
|
||||
|
||||
---
|
||||
|
||||
# ✔ DONE PASS — v1.7.91-alpha + v1.7.92-alpha (2026-06-14)
|
||||
|
||||
## Outcome (both releases PUBLISHED + LIVE on vps2)
|
||||
|
||||
- **v1.7.91-alpha** — bitcoinReceive.ts TS2538 build-blocker fixed; cut, published, verified
|
||||
live (`manifest.version==1.7.91-alpha`), tag `v1.7.91-alpha` on vps2. The fleet OTA'd to it
|
||||
(confirmed on .116 + .198).
|
||||
- **v1.7.92-alpha** — cut, published, verified live (`manifest.version==1.7.92-alpha`), tag on
|
||||
vps2, main@d462e444. Carries:
|
||||
- `fix(ui)` ElectrumX **overlay-during-sync** bug — the "App not reachable / retry" overlay
|
||||
no longer paints over the ElectrumX sync screen (AppSessionFrame.vue gated on `!electrsSync`).
|
||||
- `test(resilience)` **L3 per-boot health gate** — `batch_host_reboot` now runs os-audit.sh
|
||||
after reboot (RPC/OTA/all-apps/FM-guards), not just container-set equality. os-audit validated
|
||||
11/0/0 green on .116.
|
||||
- `feat(release)` **What's New sync gate** — `scripts/sync-whats-new.py` + `whats-new-sync`
|
||||
stage in tests/release/run.sh. Backfilled the 8 missing modal blocks (v1.7.85→.92); the gate
|
||||
fails any release whose CHANGELOG version isn't in the Settings modal.
|
||||
- **.116 node fix (not shipped — local config)**: restored the `/lnd-connect-info` nginx proxy
|
||||
route that a 2026-06-10 "before-116-routing" change had dropped (fell through to SPA). Backup at
|
||||
`/etc/nginx/conf.d/rpc.tx1138.com.conf.bak-lndconnect-*`. Shipped template already has the route.
|
||||
- **User symptoms triaged (none were .91/.92 regressions)**: receive-generate "unchanged" = .91's
|
||||
receive change was a behavior-preserving build guard; ElectrumX "can't connect" on .198 = Bitcoin
|
||||
node mid-"Verifying blocks…" (-28) so electrs was "waiting for Bitcoin node"; on .116 electrs was
|
||||
~59% mid-sync. The overlay UX bug is fixed regardless.
|
||||
|
||||
## Known follow-ups (not blockers)
|
||||
- **gitea-local mirror push fails** (`localhost:3000` → redirect to `/login`, token auth). vps2 is
|
||||
the OTA source and is fine; gitea-local secondary mirror is stale. Diagnose the local Gitea token.
|
||||
- `sync-whats-new.py` only **inserts missing** versions; it does not rewrite a block when CHANGELOG
|
||||
bullets for an already-present version change (had to delete+resync the .92 block by hand to pick
|
||||
up its 3rd bullet). Fine for the forward case; enhance to idempotently re-render if needed.
|
||||
|
||||
## What happened this session
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.91-alpha",
|
||||
"version": "1.7.93-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.91-alpha",
|
||||
"version": "1.7.93-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.91-alpha",
|
||||
"version": "1.7.93-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -60,9 +60,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Iframe blocked fallback -->
|
||||
<!-- Iframe blocked fallback. Suppressed while the ElectrumX sync screen
|
||||
(the "pre UI") is showing: a still-syncing Electrum server isn't
|
||||
reachable yet, so the "App not reachable / retry" overlay would just
|
||||
paint over the sync progress and read as a hard error. -->
|
||||
<Transition name="content-fade">
|
||||
<div v-if="iframeBlocked" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<div class="text-center px-8">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -188,6 +188,124 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.93-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.93-alpha</span>
|
||||
<span class="text-xs text-white/40">June 14, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Receiving Bitcoin and Lightning works again on nodes where the Lightning wallet was stuck locked. After some updates the wallet could come back locked with a password the node no longer had, so "generate a receive address" kept failing with a "wallet is locked" message that nothing could clear. The node now detects this and repairs itself automatically.</p>
|
||||
<p>Each node now secures its Lightning wallet with its own unique, randomly generated password instead of a shared built-in one, and remembers it safely so the wallet unlocks on its own after every restart or update — no more getting stuck locked.</p>
|
||||
<p>If a wallet is found locked with an unrecoverable password, the node rebuilds it cleanly so Bitcoin and Lightning start working again. (On these early-access nodes the wallet holds no funds, so nothing is lost — a wallet locked with an unknown password was already inaccessible.)</p>
|
||||
<p>The self-repair was validated end to end on live nodes: a stuck, locked wallet was detected, rebuilt, and came back unlocked on its own, and stayed unlocked across restarts.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.92-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.92-alpha</span>
|
||||
<span class="text-xs text-white/40">June 14, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The Electrum server app no longer flashes a "can't connect, try again" error over its loading screen while it's still catching up. If ElectrumX is building its index or waiting on the Bitcoin node, you now just see the sync progress, and the app opens on its own once it's ready.</p>
|
||||
<p>Behind the scenes, the reboot-survival test now confirms the whole system is genuinely healthy after a restart — every app reachable, updates not stuck, core services answering — instead of only checking that containers came back, so update-related problems are caught before shipping.</p>
|
||||
<p>Settings → What's New now lists the notes for every recent release again. The screen had quietly fallen several versions behind, so the last eight releases of changes weren't showing up there — they're all back now, and a release check keeps it from drifting again.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.91-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.91-alpha</span>
|
||||
<span class="text-xs text-white/40">June 14, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Apps you've installed now reliably show their "Open" button again. Some apps — including Jellyfin, BTCPay Server, Fedimint, Gitea and Portainer — were running fine but their launch link sometimes went missing, so there was no way to open them from the home screen. They now open correctly.</p>
|
||||
<p>Receiving Bitcoin is more dependable: if the wallet's internal connection details drift after a restart, it now repairs them on its own, and any error it does hit is reported clearly instead of as a generic failure or a misleading "wallet locked" message.</p>
|
||||
<p>Installing Bitcoin now sets itself up correctly without manual help — a security credential that could previously be missing and stop Bitcoin from starting is created automatically before it launches.</p>
|
||||
<p>The Electrum server app is back on the home screen and can be launched again.</p>
|
||||
<p>Behind the scenes, the release now runs an expanded automated test suite before shipping, so these kinds of issues are caught earlier.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.90-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.90-alpha</span>
|
||||
<span class="text-xs text-white/40">June 13, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Generating a Bitcoin receive address works again — the wallet now requests the correct address type, fixing the "400 Bad Request" error when creating an address.</p>
|
||||
<p>In the companion app, the on-screen pointer can now click into apps and type — including the app store search box — instead of clicks and keystrokes not reaching app content.</p>
|
||||
<p>"Open in a new tab" from the companion app now opens the app in your phone's browser, instead of doing nothing. The normal mobile browser keeps working as before.</p>
|
||||
<p>The login/credentials pop-up on phones is once again a centered, properly sized window rather than stretching the full height of the screen.</p>
|
||||
<p>The Electrum server now recovers on its own if its index ever gets corrupted, and shows a clear progress screen (with percent complete and block height) while it builds its index, instead of a blank or broken page.</p>
|
||||
<p>Software updates are more reliable on slow internet connections — downloads are given much more time to finish before giving up.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.89-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.89-alpha</span>
|
||||
<span class="text-xs text-white/40">June 12, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<p>After an update, the system now checks itself correctly on every node type, so working updates are no longer mistakenly undone.</p>
|
||||
<p>Generating a Bitcoin receive address works again on nodes where a network proxy previously got in the way.</p>
|
||||
<p>The Lightning wallet now recovers and unlocks itself properly after restarts.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.88-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.88-alpha</span>
|
||||
<span class="text-xs text-white/40">June 12, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression.</p>
|
||||
<p>Bitcoin receive now uses LND's GET-based newaddress flow with the native SegWit address type, fixing the 501 Method Not Allowed response from the previous POST attempt.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.87-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.87-alpha</span>
|
||||
<span class="text-xs text-white/40">June 12, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Bitcoin receive now calls LND's on-chain address endpoint with the correct REST method, and backend failures keep the specific address-generation error instead of collapsing into the generic operation-failed message.</p>
|
||||
<p>App launch credential interstitials now render as true full-screen overlays, and the launcher loading indicator uses the neutral brand palette instead of a blue spinner.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.86-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.86-alpha</span>
|
||||
<span class="text-xs text-white/40">June 12, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fleet now preserves the last known node list, alerts, and selection locally while telemetry refreshes in the background, so the dashboard no longer blanks on tab switches or update scans.</p>
|
||||
<p>Connected nodes and identities now reuse their last loaded data instead of reloading the visible list every time the user revisits the tab.</p>
|
||||
<p>The Fleet matrix and detail views now show actual node names and host information instead of raw node id prefixes.</p>
|
||||
<p>The network map only redraws when its graph data actually changes, which stops the D3 scene from visually resetting on every refresh tick.</p>
|
||||
<p>Mobile federation and system-update actions now stack full width, and the ElectrumX app health check allows a long startup window so slow sync nodes do not restart mid-index.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.85-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.85-alpha</span>
|
||||
<span class="text-xs text-white/40">June 12, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.</p>
|
||||
<p>Portainer is pinned to 2.19.4 instead of latest, avoiding schema-drift restarts from surprise image updates.</p>
|
||||
<p>LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.</p>
|
||||
<p>Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.</p>
|
||||
<p>Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.84-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
+16
-17
@@ -1,29 +1,28 @@
|
||||
{
|
||||
"version": "1.7.91-alpha",
|
||||
"version": "1.7.93-alpha",
|
||||
"release_date": "2026-06-14",
|
||||
"changelog": [
|
||||
"Apps you've installed now reliably show their \"Open\" button again. Some apps \u2014 including Jellyfin, BTCPay Server, Fedimint, Gitea and Portainer \u2014 were running fine but their launch link sometimes went missing, so there was no way to open them from the home screen. They now open correctly.",
|
||||
"Receiving Bitcoin is more dependable: if the wallet's internal connection details drift after a restart, it now repairs them on its own, and any error it does hit is reported clearly instead of as a generic failure or a misleading \"wallet locked\" message.",
|
||||
"Installing Bitcoin now sets itself up correctly without manual help \u2014 a security credential that could previously be missing and stop Bitcoin from starting is created automatically before it launches.",
|
||||
"The Electrum server app is back on the home screen and can be launched again.",
|
||||
"Behind the scenes, the release now runs an expanded automated test suite before shipping, so these kinds of issues are caught earlier."
|
||||
"Receiving Bitcoin and Lightning works again on nodes where the Lightning wallet was stuck locked. After some updates the wallet could come back locked with a password the node no longer had, so \"generate a receive address\" kept failing with a \"wallet is locked\" message that nothing could clear. The node now detects this and repairs itself automatically.",
|
||||
"Each node now secures its Lightning wallet with its own unique, randomly generated password instead of a shared built-in one, and remembers it safely so the wallet unlocks on its own after every restart or update \u2014 no more getting stuck locked.",
|
||||
"If a wallet is found locked with an unrecoverable password, the node rebuilds it cleanly so Bitcoin and Lightning start working again. (On these early-access nodes the wallet holds no funds, so nothing is lost \u2014 a wallet locked with an unknown password was already inaccessible.)",
|
||||
"The self-repair was validated end to end on live nodes: a stuck, locked wallet was detected, rebuilt, and came back unlocked on its own, and stayed unlocked across restarts."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.91-alpha",
|
||||
"new_version": "1.7.91-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.91-alpha/archipelago",
|
||||
"sha256": "cac0d8209023129eea1f3f01548e92a0805b4888cb82f4e98ecd5784b44e037f",
|
||||
"size_bytes": 44188312
|
||||
"current_version": "1.7.93-alpha",
|
||||
"new_version": "1.7.93-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.93-alpha/archipelago",
|
||||
"sha256": "0b53fa6851ec63a392fca5496384afb39656bd9448e5dce8dd7062019ad2c44d",
|
||||
"size_bytes": 44186056
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.91-alpha.tar.gz",
|
||||
"current_version": "1.7.91-alpha",
|
||||
"new_version": "1.7.91-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.91-alpha/archipelago-frontend-1.7.91-alpha.tar.gz",
|
||||
"sha256": "c56664858e378dc559bbc8bc68f446e07cd1b543597dff8a9cd14503e996d759",
|
||||
"size_bytes": 184062995
|
||||
"name": "archipelago-frontend-1.7.93-alpha.tar.gz",
|
||||
"current_version": "1.7.93-alpha",
|
||||
"new_version": "1.7.93-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.93-alpha/archipelago-frontend-1.7.93-alpha.tar.gz",
|
||||
"sha256": "a9da029fa3f840f2e12dde1cd1c6da65b33aff81aa24dd457bf5118c7cba37b8",
|
||||
"size_bytes": 184069432
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+16
-17
@@ -1,29 +1,28 @@
|
||||
{
|
||||
"version": "1.7.91-alpha",
|
||||
"version": "1.7.93-alpha",
|
||||
"release_date": "2026-06-14",
|
||||
"changelog": [
|
||||
"Apps you've installed now reliably show their \"Open\" button again. Some apps \u2014 including Jellyfin, BTCPay Server, Fedimint, Gitea and Portainer \u2014 were running fine but their launch link sometimes went missing, so there was no way to open them from the home screen. They now open correctly.",
|
||||
"Receiving Bitcoin is more dependable: if the wallet's internal connection details drift after a restart, it now repairs them on its own, and any error it does hit is reported clearly instead of as a generic failure or a misleading \"wallet locked\" message.",
|
||||
"Installing Bitcoin now sets itself up correctly without manual help \u2014 a security credential that could previously be missing and stop Bitcoin from starting is created automatically before it launches.",
|
||||
"The Electrum server app is back on the home screen and can be launched again.",
|
||||
"Behind the scenes, the release now runs an expanded automated test suite before shipping, so these kinds of issues are caught earlier."
|
||||
"Receiving Bitcoin and Lightning works again on nodes where the Lightning wallet was stuck locked. After some updates the wallet could come back locked with a password the node no longer had, so \"generate a receive address\" kept failing with a \"wallet is locked\" message that nothing could clear. The node now detects this and repairs itself automatically.",
|
||||
"Each node now secures its Lightning wallet with its own unique, randomly generated password instead of a shared built-in one, and remembers it safely so the wallet unlocks on its own after every restart or update \u2014 no more getting stuck locked.",
|
||||
"If a wallet is found locked with an unrecoverable password, the node rebuilds it cleanly so Bitcoin and Lightning start working again. (On these early-access nodes the wallet holds no funds, so nothing is lost \u2014 a wallet locked with an unknown password was already inaccessible.)",
|
||||
"The self-repair was validated end to end on live nodes: a stuck, locked wallet was detected, rebuilt, and came back unlocked on its own, and stayed unlocked across restarts."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.91-alpha",
|
||||
"new_version": "1.7.91-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.91-alpha/archipelago",
|
||||
"sha256": "cac0d8209023129eea1f3f01548e92a0805b4888cb82f4e98ecd5784b44e037f",
|
||||
"size_bytes": 44188312
|
||||
"current_version": "1.7.93-alpha",
|
||||
"new_version": "1.7.93-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.93-alpha/archipelago",
|
||||
"sha256": "0b53fa6851ec63a392fca5496384afb39656bd9448e5dce8dd7062019ad2c44d",
|
||||
"size_bytes": 44186056
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.91-alpha.tar.gz",
|
||||
"current_version": "1.7.91-alpha",
|
||||
"new_version": "1.7.91-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.91-alpha/archipelago-frontend-1.7.91-alpha.tar.gz",
|
||||
"sha256": "c56664858e378dc559bbc8bc68f446e07cd1b543597dff8a9cd14503e996d759",
|
||||
"size_bytes": 184062995
|
||||
"name": "archipelago-frontend-1.7.93-alpha.tar.gz",
|
||||
"current_version": "1.7.93-alpha",
|
||||
"new_version": "1.7.93-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.93-alpha/archipelago-frontend-1.7.93-alpha.tar.gz",
|
||||
"sha256": "a9da029fa3f840f2e12dde1cd1c6da65b33aff81aa24dd457bf5118c7cba37b8",
|
||||
"size_bytes": 184069432
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -470,6 +470,21 @@ batch_host_reboot() {
|
||||
missing=$(comm -23 <(echo "$before") <(echo "$after") | tr '\n' ',' | sed 's/,$//')
|
||||
record "_batch" host_reboot FAIL "missing: $missing"
|
||||
fi
|
||||
|
||||
# ── L3 per-boot health gate ──────────────────────────────────
|
||||
# Container-set equality proves the right containers exist; os-audit proves
|
||||
# the node is actually *healthy* after the reboot: RPC up, OTA not wedged
|
||||
# (FM12), every app reachable with valid launch metadata, FM-guards green.
|
||||
# This is the per-boot building block os-audit.sh was written to be.
|
||||
if [ -x "$ROOT/tests/lifecycle/os-audit.sh" ]; then
|
||||
echo "── per-boot os-audit gate ──"
|
||||
if ARCHY_HOST="$HOST" ARCHY_SCHEME=https ARCHY_PASSWORD="$UI_PASS" ARCHY_LOCAL=0 \
|
||||
"$ROOT/tests/lifecycle/os-audit.sh" >"$OUT_DIR/os-audit-postboot.log" 2>&1; then
|
||||
record "_batch" host_reboot_osaudit PASS "os-audit green after reboot"
|
||||
else
|
||||
record "_batch" host_reboot_osaudit FAIL "os-audit not green after reboot (see $OUT_DIR/os-audit-postboot.log)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────────
|
||||
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync the Settings "What's New" modal with CHANGELOG.md.
|
||||
|
||||
The modal (neode-ui/src/views/settings/AccountInfoSection.vue) hardcodes one
|
||||
HTML block per release. It has repeatedly drifted behind CHANGELOG.md (it sat
|
||||
at v1.7.84 while the fleet shipped through v1.7.92). This script is the fix:
|
||||
for every version in CHANGELOG.md that has no block in the modal, it generates
|
||||
a block (from the curated CHANGELOG bullets) and inserts it newest-first.
|
||||
|
||||
python3 scripts/sync-whats-new.py # insert any missing blocks
|
||||
python3 scripts/sync-whats-new.py --check # exit 1 if anything is missing
|
||||
|
||||
Dev-process bullets ("Validation passed…/pending…") are dropped — the modal is
|
||||
user-facing. Only CHANGELOG versions are managed; older hand-written blocks
|
||||
(pre-CHANGELOG history) are never touched or removed.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
import html
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
CHANGELOG = REPO / "CHANGELOG.md"
|
||||
MODAL = REPO / "neode-ui/src/views/settings/AccountInfoSection.vue"
|
||||
|
||||
MONTHS = ["", "January", "February", "March", "April", "May", "June", "July",
|
||||
"August", "September", "October", "November", "December"]
|
||||
|
||||
HEADER_RE = re.compile(r"^## (v\d+\.\d+\.\d+\S*) \((\d{4})-(\d{2})-(\d{2})\)")
|
||||
|
||||
|
||||
def parse_changelog():
|
||||
"""Return [(version, 'Month D, YYYY', [bullet, ...]), ...] newest-first."""
|
||||
entries = []
|
||||
cur = None
|
||||
for line in CHANGELOG.read_text().splitlines():
|
||||
m = HEADER_RE.match(line)
|
||||
if m:
|
||||
ver, y, mo, d = m.groups()
|
||||
cur = {"ver": ver, "date": f"{MONTHS[int(mo)]} {int(d)}, {y}", "bullets": []}
|
||||
entries.append(cur)
|
||||
continue
|
||||
if cur is not None and line.startswith("- "):
|
||||
text = line[2:].strip()
|
||||
if text.lower().startswith("validation "):
|
||||
continue # dev-process note, not user-facing
|
||||
cur["bullets"].append(text)
|
||||
return entries
|
||||
|
||||
|
||||
def existing_versions():
|
||||
text = MODAL.read_text()
|
||||
return set(re.findall(r"<!-- (v\d+\.\d+\.\d+\S*) -->", text))
|
||||
|
||||
|
||||
def to_html(text):
|
||||
text = text.replace("`", "") # drop markdown code ticks (plain prose)
|
||||
return html.escape(text, quote=False) # & < > (Vue template-safe)
|
||||
|
||||
|
||||
def render_block(entry):
|
||||
paras = "\n".join(
|
||||
f" <p>{to_html(b)}</p>" for b in entry["bullets"]
|
||||
)
|
||||
return (
|
||||
f" <!-- {entry['ver']} -->\n"
|
||||
f" <div>\n"
|
||||
f" <div class=\"flex items-center gap-2 mb-3\">\n"
|
||||
f" <span class=\"text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300\">{entry['ver']}</span>\n"
|
||||
f" <span class=\"text-xs text-white/40\">{entry['date']}</span>\n"
|
||||
f" </div>\n"
|
||||
f" <div class=\"space-y-3 text-sm text-white/80 pl-3 border-l border-white/10\">\n"
|
||||
f"{paras}\n"
|
||||
f" </div>\n"
|
||||
f" </div>\n"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
check = "--check" in sys.argv
|
||||
entries = parse_changelog()
|
||||
have = existing_versions()
|
||||
missing = [e for e in entries if e["ver"] not in have]
|
||||
|
||||
if not missing:
|
||||
print("What's New modal is in sync with CHANGELOG.md "
|
||||
f"({len(entries)} changelog versions, all present).")
|
||||
return 0
|
||||
|
||||
names = ", ".join(e["ver"] for e in missing)
|
||||
if check:
|
||||
print("FAIL: these CHANGELOG versions have no block in the Settings "
|
||||
f"What's New modal: {names}", file=sys.stderr)
|
||||
print("Run: python3 scripts/sync-whats-new.py", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Insert missing blocks newest-first, immediately before the newest existing
|
||||
# block marker (the first "<!-- v... -->" line in the file).
|
||||
lines = MODAL.read_text().splitlines(keepends=True)
|
||||
marker = re.compile(r"^\s*<!-- v\d+\.\d+\.\d+\S* -->\s*$")
|
||||
idx = next((i for i, ln in enumerate(lines) if marker.match(ln)), None)
|
||||
if idx is None:
|
||||
print("ERROR: could not find an existing version block marker in the modal.",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# newest-first: sort missing by their order in `entries` (already newest-first)
|
||||
block_text = "".join(render_block(e) for e in missing)
|
||||
lines.insert(idx, block_text)
|
||||
MODAL.write_text("".join(lines))
|
||||
print(f"Inserted {len(missing)} block(s): {names}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -133,10 +133,23 @@ section_a() {
|
||||
else
|
||||
record WARN "bitcoin RPC reachable" "bitcoin.getinfo/relay-status did not answer (not installed?)"
|
||||
fi
|
||||
# LND wallet must be UNLOCKED. NB: lnd.getinfo masks a locked wallet (it
|
||||
# returns an all-zero success, error:null), so it can't detect the lock. Probe
|
||||
# the actual receive path (lnd.newaddress) instead: a LOCKED wallet returns the
|
||||
# LND_WALLET_LOCKED reason code — the exact fleet-wide receive breakage. A
|
||||
# locked wallet is a hard FAIL; "not installed" is a WARN. (newaddress derives
|
||||
# a fresh address — harmless; LND tolerates address gaps.)
|
||||
if rpc_ok lnd.getinfo; then
|
||||
record PASS "lnd RPC reachable" ""
|
||||
local na; na=$(rpc lnd.newaddress)
|
||||
if grep -qE "LND_WALLET_LOCKED|wallet is locked|WALLET_LOCKED" <<<"$na"; then
|
||||
record FAIL "lnd wallet unlocked (lnd.newaddress)" "wallet LOCKED — auto-unlock failed (Bitcoin-receive broken)"
|
||||
elif [[ "$(jq -r '(has("result") and (.result!=null))' <<<"$na" 2>/dev/null)" == "true" ]]; then
|
||||
record PASS "lnd wallet unlocked (lnd.newaddress)" ""
|
||||
else
|
||||
record WARN "lnd wallet unlocked (lnd.newaddress)" "newaddress: $(jq -rc '.error.message // "no address"' <<<"$na" 2>/dev/null | head -c 60)"
|
||||
fi
|
||||
else
|
||||
record WARN "lnd RPC reachable" "lnd.getinfo did not answer (not installed / wallet locked?)"
|
||||
record WARN "lnd RPC reachable" "lnd.getinfo did not answer (not installed?)"
|
||||
fi
|
||||
if rpc_ok system.stats || rpc_ok system.get-metrics; then
|
||||
record PASS "system metrics reachable" ""
|
||||
|
||||
@@ -250,7 +250,7 @@ container_health() {
|
||||
health=$(
|
||||
ARCHY_RPC_TIMEOUT="${ARCHY_HEALTH_RPC_TIMEOUT:-20}" \
|
||||
rpc_result container-health "$(jq -nc --arg app "$app" '{app_id:$app}')" \
|
||||
| jq -r --arg app "$app" '.[$app] // "unknown" | ascii_downcase'
|
||||
| jq -r --arg app "$app" '(.[$app] // "") | if . == "" then "unknown" else ascii_downcase end'
|
||||
) || health=unknown
|
||||
if [[ "$app" == "indeedhub" && "$health" != "healthy" ]] && probe_launch "$app" >/dev/null 2>&1; then
|
||||
health=healthy
|
||||
|
||||
@@ -63,6 +63,11 @@ summary() {
|
||||
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
|
||||
# Every release must surface its CHANGELOG entry in the Settings "What's New"
|
||||
# modal. The modal hardcodes a block per version and has drifted behind before
|
||||
# (sat at v1.7.84 while the fleet shipped to v1.7.92). Fail if any CHANGELOG
|
||||
# version is missing a block; `python3 scripts/sync-whats-new.py` inserts them.
|
||||
stage "whats-new-sync" python3 scripts/sync-whats-new.py --check
|
||||
if [[ $MANIFEST -eq 1 ]]; then
|
||||
stage "release-manifest" scripts/check-release-manifest.sh
|
||||
fi
|
||||
@@ -123,6 +128,33 @@ if [[ $LIVE -eq 1 ]]; then
|
||||
done
|
||||
echo "SKIP: LND REST not reachable on 18080/8080 — cannot validate address type live"; exit 0
|
||||
'
|
||||
|
||||
# Wallet-unlock guard. After a restart/OTA, LND comes up LOCKED and the backend
|
||||
# must auto-unlock it; if the unlock password is wrong (e.g. a fleet-wide
|
||||
# constant vs a per-wallet password) the wallet stays LOCKED forever and ALL
|
||||
# Bitcoin-receive / Lightning ops fail — fleet-wide, silently. Nothing else in
|
||||
# this harness catches that: live-lnd-address-type explicitly treats "wallet
|
||||
# locked" as a PASS, and os-audit treats lnd-unreachable as a WARN. This stage
|
||||
# polls LND's unauthenticated /v1/state and FAILS if it is still LOCKED after a
|
||||
# grace window. RPC_ACTIVE = unlocked (pass); NON_EXISTING/WAITING = no wallet
|
||||
# yet (not a regression); unreachable = skip.
|
||||
stage "live-lnd-unlocked" bash -c '
|
||||
deadline=$(( $(date +%s) + 60 ))
|
||||
while :; do
|
||||
seen=""
|
||||
for port in 18080 8080; do
|
||||
st=$(curl -sk --max-time 6 "https://127.0.0.1:$port/v1/state" 2>/dev/null)
|
||||
[ -z "$st" ] && continue
|
||||
seen=1
|
||||
echo "LND($port) state: $st"
|
||||
echo "$st" | grep -q "RPC_ACTIVE" && { echo "OK: LND wallet is unlocked"; exit 0; }
|
||||
echo "$st" | grep -qE "NON_EXISTING|WAITING_TO_START" && { echo "OK: LND wallet not initialized yet — not a lock regression"; exit 0; }
|
||||
done
|
||||
[ -z "$seen" ] && { echo "SKIP: LND /v1/state not reachable on 18080/8080"; exit 0; }
|
||||
[ "$(date +%s)" -ge "$deadline" ] && { echo "FAIL: LND wallet still LOCKED after 60s — auto-unlock failed; Bitcoin-receive/Lightning are broken"; exit 1; }
|
||||
sleep 5
|
||||
done
|
||||
'
|
||||
fi
|
||||
|
||||
summary 0
|
||||
|
||||
Reference in New Issue
Block a user