//! lnd config bootstrap helper. use anyhow::{Context, Result}; use base64::Engine; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use tokio::fs; 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"; /// 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 { pub data_dir: PathBuf, pub conf_path: PathBuf, } impl Default for EnsurePaths { fn default() -> Self { Self { data_dir: PathBuf::from(DEFAULT_DATA_DIR), conf_path: PathBuf::from(DEFAULT_CONF_PATH), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EnsureOutcome { Written, Unchanged, } pub async fn ensure_config(paths: &EnsurePaths, rpc_pass: &str) -> Result { fs::create_dir_all(&paths.data_dir) .await .with_context(|| format!("creating {}", paths.data_dir.display()))?; if paths.conf_path.exists() { let existing = fs::read_to_string(&paths.conf_path) .await .with_context(|| format!("reading {}", paths.conf_path.display()))?; if has_required_lnd_flags(&existing, rpc_pass) { return Ok(EnsureOutcome::Unchanged); } } let conf = format!( "debuglevel=info\n\ maxpendingchannels=10\n\ alias=Archipelago Node\n\ color=#f7931a\n\ listen=0.0.0.0:9735\n\ rpclisten=0.0.0.0:10009\n\ restlisten=0.0.0.0:8080\n\ bitcoin.active=true\n\ bitcoin.mainnet=true\n\ bitcoin.node=bitcoind\n\ bitcoind.rpchost=bitcoin-knots:8332\n\ bitcoind.rpcuser=archipelago\n\ bitcoind.rpcpass={}\n\ bitcoind.rpcpolling=true\n\ bitcoind.estimatemode=ECONOMICAL\n", rpc_pass ); write_config_atomically(paths, &conf).await?; Ok(EnsureOutcome::Written) } pub async fn ensure_wallet_initialized() -> Result<()> { let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon"; let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db"; if file_exists_as_root(wallet_db).await { if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(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 ` 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; } tokio::process::Command::new("sudo") .args(["test", "-f", path]) .status() .await .map(|status| status.success()) .unwrap_or(false) } async fn read_file_as_root(path: &str) -> Result> { match fs::read(path).await { Ok(bytes) => Ok(bytes), Err(direct_err) => { let out = tokio::process::Command::new("sudo") .args(["cat", path]) .output() .await .with_context(|| format!("reading {path} via sudo"))?; if out.status.success() { Ok(out.stdout) } else { anyhow::bail!( "reading {path} failed (direct: {direct_err}; sudo: {})", String::from_utf8_lossy(&out.stderr).trim() ) } } } } /// 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 { 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 { 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 { 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 { unlock_existing_wallet_via_rest().await } async fn unlock_existing_wallet_via_rest() -> Result { let client = reqwest::Client::builder() .no_proxy() .timeout(std::time::Duration::from_secs(20)) .danger_accept_invalid_certs(true) .build() .context("building LND REST client")?; 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 { 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 { 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}") } } /// 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 { 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")?; // Only act on a wallet that is actually LOCKED. if wallet_state(&client).await.as_deref() != Some("LOCKED") { return Ok(false); } // 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)] struct GenSeedResponse { cipher_seed_mnemonic: Vec, } #[derive(Debug)] enum UnlockerResponse { Value(T), WalletAlreadyExists, } #[derive(Debug, Serialize)] struct InitWalletRequest { wallet_password: String, cipher_seed_mnemonic: Vec, } async fn init_wallet_via_rest() -> Result<()> { let client = reqwest::Client::builder() .no_proxy() .timeout(std::time::Duration::from_secs(20)) .danger_accept_invalid_certs(true) .build() .context("building LND REST client")?; let seed: GenSeedResponse = match get_lnd_unlocker_json(&client, "/v1/genseed") .await .context("generating LND wallet seed")? { UnlockerResponse::Value(seed) => seed, UnlockerResponse::WalletAlreadyExists => { unlock_existing_wallet().await?; return Ok(()); } }; if seed.cipher_seed_mnemonic.is_empty() { anyhow::bail!("LND genseed returned no seed words"); } 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, }; match post_lnd_unlocker_json::( &client, "/v1/initwallet", serde_json::to_value(req)?, ) .await .context("initializing LND wallet")? { UnlockerResponse::Value(_) => {} UnlockerResponse::WalletAlreadyExists => { unlock_existing_wallet().await?; } } Ok(()) } async fn get_lnd_unlocker_json Deserialize<'de>>( client: &reqwest::Client, path: &str, ) -> Result> { let url = format!("{LND_REST_BASE_URL}{path}"); let mut last_err = None; for _ in 0..60 { match client.get(&url).send().await { Ok(resp) => match decode_lnd_unlocker_response(resp, path).await { Ok(value) => return Ok(value), Err(e) => last_err = Some(e.to_string()), }, Err(e) => last_err = Some(e.to_string()), } tokio::time::sleep(std::time::Duration::from_secs(1)).await; } anyhow::bail!( "LND REST {path} unavailable: {}", last_err.unwrap_or_else(|| "unknown error".to_string()) ) } async fn post_lnd_unlocker_json Deserialize<'de>>( client: &reqwest::Client, path: &str, body: serde_json::Value, ) -> Result> { let url = format!("{LND_REST_BASE_URL}{path}"); let mut last_err = None; for _ in 0..60 { match client.post(&url).json(&body).send().await { Ok(resp) => match decode_lnd_unlocker_response(resp, path).await { Ok(value) => return Ok(value), Err(e) => last_err = Some(e.to_string()), }, Err(e) => last_err = Some(e.to_string()), } tokio::time::sleep(std::time::Duration::from_secs(1)).await; } anyhow::bail!( "LND REST {path} unavailable: {}", last_err.unwrap_or_else(|| "unknown error".to_string()) ) } async fn decode_lnd_unlocker_response Deserialize<'de>>( resp: reqwest::Response, path: &str, ) -> Result> { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); if status.is_success() { let value = serde_json::from_str(&text) .with_context(|| format!("parsing LND REST response from {path}"))?; return Ok(UnlockerResponse::Value(value)); } if text.contains("wallet already exists") { return Ok(UnlockerResponse::WalletAlreadyExists); } anyhow::bail!("LND REST {path} returned {status}: {text}") } async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool { let Ok(macaroon) = read_file_as_root(admin_macaroon).await else { return false; }; let Ok(client) = reqwest::Client::builder() .no_proxy() .timeout(std::time::Duration::from_secs(5)) .danger_accept_invalid_certs(true) .build() else { return false; }; client .get(format!("{LND_REST_BASE_URL}/v1/getinfo")) .header("Grpc-Metadata-macaroon", hex::encode(macaroon)) .send() .await .map(|resp| resp.status().is_success()) .unwrap_or(false) } async fn wait_for_admin_macaroon(admin_macaroon: &str) -> Result<()> { for _ in 0..60 { if file_exists_as_root(admin_macaroon).await { return Ok(()); } tokio::time::sleep(std::time::Duration::from_secs(1)).await; } anyhow::bail!("LND admin macaroon not created after wallet init") } async fn write_config_atomically(paths: &EnsurePaths, conf: &str) -> Result<()> { let tmp = paths.conf_path.with_extension("tmp"); match fs::write(&tmp, conf).await { Ok(()) => { fs::rename(&tmp, &paths.conf_path).await.with_context(|| { format!( "renaming {} -> {}", tmp.display(), paths.conf_path.display() ) })?; Ok(()) } Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { let script = format!( "set -eu\ncat > '{}' <<'LNDCONF'\n{}LNDCONF\n", shell_quote(&paths.conf_path.to_string_lossy()), conf ); let status = host_sudo(&["sh", "-lc", &script]) .await .context("writing lnd.conf via sudo")?; if !status.success() { anyhow::bail!("writing lnd.conf via sudo exited with {status}"); } Ok(()) } Err(e) => Err(e).with_context(|| format!("writing tmp {}", tmp.display())), } } fn shell_quote(s: &str) -> String { s.replace('\'', "'\\''") } fn has_required_lnd_flags(conf: &str, rpc_pass: &str) -> bool { let rpc_pass_line = format!("bitcoind.rpcpass={rpc_pass}"); [ "bitcoin.active=true", "bitcoin.mainnet=true", "bitcoin.node=bitcoind", "bitcoind.rpchost=bitcoin-knots:8332", rpc_pass_line.as_str(), ] .iter() .all(|needle| conf.lines().any(|line| line.trim() == *needle)) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn ensure_config_writes_required_bitcoin_network_flags() { let tmp = tempfile::TempDir::new().unwrap(); let paths = EnsurePaths { data_dir: tmp.path().join("lnd"), conf_path: tmp.path().join("lnd/lnd.conf"), }; let out = ensure_config(&paths, "secret").await.unwrap(); assert_eq!(out, EnsureOutcome::Written); let conf = fs::read_to_string(&paths.conf_path).await.unwrap(); assert!(conf.contains("bitcoin.active=true")); assert!(conf.contains("bitcoin.mainnet=true")); assert!(conf.contains("bitcoin.node=bitcoind")); assert!(conf.contains("bitcoind.rpchost=bitcoin-knots:8332")); assert!(conf.contains("bitcoind.rpcpass=secret")); } #[tokio::test] async fn ensure_config_repairs_rpc_password_drift() { let tmp = tempfile::TempDir::new().unwrap(); let paths = EnsurePaths { data_dir: tmp.path().join("lnd"), conf_path: tmp.path().join("lnd/lnd.conf"), }; assert_eq!( ensure_config(&paths, "first").await.unwrap(), EnsureOutcome::Written ); assert_eq!( ensure_config(&paths, "second").await.unwrap(), EnsureOutcome::Written ); let conf = fs::read_to_string(&paths.conf_path).await.unwrap(); assert!(conf.contains("bitcoind.rpcpass=second")); } #[tokio::test] async fn ensure_config_repairs_incomplete_existing_config() { let tmp = tempfile::TempDir::new().unwrap(); let paths = EnsurePaths { data_dir: tmp.path().join("lnd"), conf_path: tmp.path().join("lnd/lnd.conf"), }; fs::create_dir_all(&paths.data_dir).await.unwrap(); fs::write(&paths.conf_path, "debuglevel=info\n") .await .unwrap(); assert_eq!( ensure_config(&paths, "repaired").await.unwrap(), EnsureOutcome::Written ); let conf = fs::read_to_string(&paths.conf_path).await.unwrap(); assert!(conf.contains("bitcoin.mainnet=true")); assert!(conf.contains("bitcoind.rpcpass=repaired")); } #[test] 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)); } }