426 lines
13 KiB
Rust
426 lines
13 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";
|
||
|
|
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<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) {
|
||
|
|
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()
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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<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()
|
||
|
|
.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::<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!("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<T: for<'de> Deserialize<'de>>(
|
||
|
|
client: &reqwest::Client,
|
||
|
|
path: &str,
|
||
|
|
body: serde_json::Value,
|
||
|
|
) -> Result<UnlockerResponse<T>> {
|
||
|
|
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<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()
|
||
|
|
.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);
|
||
|
|
}
|
||
|
|
}
|