746 lines
26 KiB
Rust
746 lines
26 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(());
|
|
}
|
|
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;
|
|
}
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// 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<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 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}")
|
|
}
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
}
|