//! 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"; pub const 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) { 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> { 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() ) } } } } async fn unlock_existing_wallet() -> 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()); 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; } anyhow::bail!( "lncli wallet unlock failed: {}", last_err.unwrap_or_else(|| "unknown error".to_string()) ) } #[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() .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(WALLET_PASSWORD); 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!("https://127.0.0.1:8080{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!("https://127.0.0.1:8080{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() .timeout(std::time::Duration::from_secs(5)) .danger_accept_invalid_certs(true) .build() else { return false; }; client .get("https://127.0.0.1:8080/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) -> bool { [ "bitcoin.active=true", "bitcoin.mainnet=true", "bitcoin.node=bitcoind", "bitcoind.rpchost=bitcoin-knots:8332", ] .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_is_idempotent() { 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::Unchanged ); let conf = fs::read_to_string(&paths.conf_path).await.unwrap(); assert!(conf.contains("bitcoind.rpcpass=first")); } #[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 wallet_password_is_valid_for_lncli() { assert!(WALLET_PASSWORD.len() > 8); } }