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>
628 lines
21 KiB
Rust
628 lines
21 KiB
Rust
//! 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<EnsureOutcome> {
|
|
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(());
|
|
}
|
|
unlock_existing_wallet().await?;
|
|
wait_for_admin_macaroon(admin_macaroon).await?;
|
|
return Ok(());
|
|
}
|
|
|
|
init_wallet_via_rest().await?;
|
|
wait_for_admin_macaroon(admin_macaroon).await
|
|
}
|
|
|
|
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<Vec<u8>> {
|
|
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<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,
|
|
}
|
|
}
|
|
|
|
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(()),
|
|
UnlockAttempt::WrongPassword => {}
|
|
UnlockAttempt::NotReady => all_rejected = false,
|
|
}
|
|
}
|
|
if all_rejected {
|
|
anyhow::bail!(
|
|
"LND wallet unlock failed: none of the {} candidate password(s) were accepted \
|
|
— the wallet was created with a password this node does not have; \
|
|
user-assisted migration or seed-recovery is required",
|
|
candidates.len()
|
|
);
|
|
}
|
|
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}")
|
|
}
|
|
}
|
|
|
|
/// 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")?;
|
|
|
|
// 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<String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum UnlockerResponse<T> {
|
|
Value(T),
|
|
WalletAlreadyExists,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct InitWalletRequest {
|
|
wallet_password: String,
|
|
cipher_seed_mnemonic: Vec<String>,
|
|
}
|
|
|
|
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::<serde_json::Value>(
|
|
&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<T: for<'de> Deserialize<'de>>(
|
|
client: &reqwest::Client,
|
|
path: &str,
|
|
) -> Result<UnlockerResponse<T>> {
|
|
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<T: for<'de> Deserialize<'de>>(
|
|
client: &reqwest::Client,
|
|
path: &str,
|
|
body: serde_json::Value,
|
|
) -> Result<UnlockerResponse<T>> {
|
|
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<T: for<'de> Deserialize<'de>>(
|
|
resp: reqwest::Response,
|
|
path: &str,
|
|
) -> Result<UnlockerResponse<T>> {
|
|
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));
|
|
}
|
|
}
|