Compare commits

...
Author SHA1 Message Date
archipelago 327a4e34dd chore: release v1.7.93-alpha 2026-06-14 15:18:34 -04:00
archipelagoandClaude Opus 4.8 bf2793be7b chore: sync What's New modal for v1.7.93-alpha
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:45:56 -04:00
archipelagoandClaude Opus 4.8 1973d76427 style: rustfmt lnd migrate_locked_wallet matches! call
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:41:40 -04:00
archipelagoandClaude Opus 4.8 403fa6eff3 docs: changelog for v1.7.93-alpha (LND wallet self-heal)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:38:57 -04:00
archipelagoandClaude Opus 4.8 3214d6aff3 fix(lnd): self-heal unrecoverable locked wallet via wipe+recreate
When an existing LND wallet is locked and none of the candidate passwords
(per-node secret, legacy constant) open it, the node can never auto-unlock
unattended. unlock_existing_wallet now returns Ok(false) for "all candidates
actively rejected" (vs Err for transient "LND not ready"), and
ensure_wallet_initialized responds by recreating the wallet:

  - mark the lnd container user-stopped so the health monitor won't
    re-launch it (and re-open the wallet) mid-wipe,
  - stop lnd, delete its wallet/chain/graph state as root,
  - start lnd, wait for NON_EXISTING, re-init a fresh wallet on the
    per-node secret, then clear the user-stopped flag.

LND runs as a plain bridge-network podman container (not a Quadlet unit),
so it is restarted via `systemd-run --user --scope podman`, matching the
orchestrator/health-monitor path.

Alpha nodes hold no funds and a wallet locked with an unknown password is
already inaccessible, so the wipe loses nothing reachable. Completes the
forward fix from 91adc281 for nodes whose wallet pre-dates the per-node
secret and whose password is unrecorded (e.g. .116/.228).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:08:33 -04:00
archipelagoandClaude Opus 4.8 459046b21c docs: resume notes for LND wallet fix (in-progress, branch lnd-wallet-password-fix)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:26:10 -04:00
archipelagoandClaude Opus 4.8 91adc281ca fix(lnd): per-node wallet password + locked-wallet self-heal on login
Replaces the fleet-wide hardcoded WALLET_PASSWORD='hellohello' that left wallets
LOCKED after OTA/reboot (auto-unlock used the wrong password fleet-wide).

Forward fix (both init paths unified, validated cargo check + LND REST mechanics
on a scratch wallet):
- Per-node random 256-bit secret in secrets/lnd-wallet-password (0600), mirroring
  secrets/bitcoin-rpc-password. read_wallet_password (no-gen) vs
  ensure_wallet_password (gen at init only).
- container/lnd.rs init AND api/rpc/lnd/wallet.rs seed-derived init both use the
  per-node secret (wallet.rs keeps recoverable derived entropy; password unified).
- Unlock tries [per-node secret, legacy 'hellohello']; single-attempt primitive
  distinguishes invalid-passphrase (fail fast, try next) from not-ready (retry),
  so a wrong password no longer hangs the boot path ~60s.

Migration (candidate-unlock + rotate, best-effort at login):
- change_wallet_password (WalletUnlocker.ChangePassword) + migrate_locked_wallet:
  if LOCKED, try candidates as current pw and ChangePassword onto the per-node
  secret so future boots auto-unlock. Hooked into auth.login (non-blocking) with
  the just-verified password as the candidate.

NOT YET: seed-recovery fallback for wallets where no candidate matches (e.g.
.116/.228) — destructive, needs entropy-source/funds-safety handling; next pass.
NOT shipped: pending end-to-end validation on a real node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:19:56 -04:00
archipelagoandClaude Opus 4.8 a9c4e54023 chore: sync core/Cargo.lock to 1.7.92-alpha (release leftover)
create-release.sh bumps Cargo.toml but not the lock's archipelago version line;
the cargo build regenerates it post-commit. Same as the 1.7.91 leftover — worth
fixing create-release.sh to stage Cargo.lock, tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:42:13 -04:00
archipelagoandClaude Opus 4.8 8c8e4d7a29 test: gate that LND wallet is unlocked after restart (catches fleet-wide lock)
A wrong/locked LND wallet password leaves the wallet LOCKED after every
restart/OTA, breaking all Bitcoin-receive + Lightning ops fleet-wide — and the
harness was blind to it: live-lnd-address-type treats 'wallet locked' as PASS,
os-audit treated lnd-unreachable as WARN, and the archipelago lnd.getinfo RPC
masks a locked wallet (returns all-zero success).

- tests/release/run.sh: new 'live-lnd-unlocked' stage polls LND's unauth
  /v1/state and FAILs if still LOCKED after a 60s grace window.
- tests/lifecycle/os-audit.sh: probe lnd.newaddress (the real receive path,
  which surfaces LND_WALLET_LOCKED) instead of lnd.getinfo; locked = hard FAIL,
  not-installed = WARN.

Proven on .116 (genuinely locked): os-audit now reports
'[FAIL] lnd wallet unlocked (lnd.newaddress) wallet LOCKED'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:36:12 -04:00
archipelagoandClaude Opus 4.8 9d3347463a docs: record v1.7.91 + v1.7.92 published; What's New gate; .116 nginx fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:20:35 -04:00
14 changed files with 543 additions and 105 deletions
+7
View File
@@ -1,5 +1,12 @@
# 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.
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.91-alpha"
version = "1.7.92-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.92-alpha"
version = "1.7.93-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+13
View File
@@ -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();
+8 -1
View File
@@ -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).
+345 -56
View File
@@ -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));
}
}
+78 -11
View File
@@ -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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.92-alpha",
"version": "1.7.93-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.92-alpha",
"version": "1.7.93-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.92-alpha",
"version": "1.7.93-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -188,6 +188,19 @@ 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">
+16 -15
View File
@@ -1,27 +1,28 @@
{
"version": "1.7.92-alpha",
"version": "1.7.93-alpha",
"release_date": "2026-06-14",
"changelog": [
"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 \u2014 every app reachable, updates not stuck, core services answering \u2014 instead of only checking that containers came back, so update-related problems are caught before shipping.",
"Settings \u2192 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 \u2014 they're all back now, and a release check keeps it from drifting again."
"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.92-alpha",
"new_version": "1.7.92-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.92-alpha/archipelago",
"sha256": "d21bb5e1b386e97e790407b7e29dec4deec743e6103509965b9afba73363d08a",
"size_bytes": 44188280
"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.92-alpha.tar.gz",
"current_version": "1.7.92-alpha",
"new_version": "1.7.92-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.92-alpha/archipelago-frontend-1.7.92-alpha.tar.gz",
"sha256": "710f6c80ceccae140bee0c382300a87222f552d400e383920bf4d55dab9cfa30",
"size_bytes": 184070054
"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 -15
View File
@@ -1,27 +1,28 @@
{
"version": "1.7.92-alpha",
"version": "1.7.93-alpha",
"release_date": "2026-06-14",
"changelog": [
"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 \u2014 every app reachable, updates not stuck, core services answering \u2014 instead of only checking that containers came back, so update-related problems are caught before shipping.",
"Settings \u2192 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 \u2014 they're all back now, and a release check keeps it from drifting again."
"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.92-alpha",
"new_version": "1.7.92-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.92-alpha/archipelago",
"sha256": "d21bb5e1b386e97e790407b7e29dec4deec743e6103509965b9afba73363d08a",
"size_bytes": 44188280
"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.92-alpha.tar.gz",
"current_version": "1.7.92-alpha",
"new_version": "1.7.92-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.92-alpha/archipelago-frontend-1.7.92-alpha.tar.gz",
"sha256": "710f6c80ceccae140bee0c382300a87222f552d400e383920bf4d55dab9cfa30",
"size_bytes": 184070054
"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
}
]
}
+15 -2
View File
@@ -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" ""
+27
View File
@@ -128,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