Compare commits

...

8 Commits

Author SHA1 Message Date
archipelago
640dc87a5f chore: sync core/Cargo.lock to 1.7.93-alpha (release leftover)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:21:07 -04:00
archipelago
327a4e34dd chore: release v1.7.93-alpha 2026-06-14 15:18:34 -04:00
archipelago
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
archipelago
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
archipelago
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
archipelago
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
archipelago
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
archipelago
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
12 changed files with 472 additions and 92 deletions

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.

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.92-alpha"
version = "1.7.93-alpha"
dependencies = [
"anyhow",
"archipelago-container",

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"]

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();

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).

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));
}
}

View File

@ -4,6 +4,55 @@ Last updated: 2026-06-14 (session on node .116 / archi-thinkpad)
---
# ▶ IN PROGRESS — LND wallet auto-unlock fix (2026-06-14)
## RESUME PROMPT (paste into a fresh session, on .116 / archi-thinkpad, tree at /home/archipelago/Projects/archy)
> 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)

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",

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",

View File

@ -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">

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
}
]
}

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
}
]
}