archipelago c49e8fcacd fix: harden OTA updates, AIUI desktop gap, LND no-proxy
- update.rs: post-OTA probe falls back to http://127.0.0.1/ on connect
  error (nginx binds :80, not :443) so good updates are no longer rolled
  back; recover stuck update_in_progress; avoid ETXTBSY on running binary
- LND: REST client bypasses proxy, GET newaddress p2wkh, wallet
  readiness/unlock after restart
- Dashboard.vue: chat route back to plain h-full (desktop bottom-gap fix)
- vite.config.ts: dev-only /aiui proxy
- tests/release/run.sh: release gate harness (static+frontend+backend)
- CHANGELOG: v1.7.89-alpha notes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:23:32 -04:00

457 lines
14 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";
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, 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()
)
}
}
}
}
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 wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
match post_lnd_unlocker_json::<serde_json::Value>(
&client,
"/v1/unlockwallet",
serde_json::json!({ "wallet_password": wallet_password }),
)
.await
.context("unlocking existing LND wallet")?
{
UnlockerResponse::Value(_) | UnlockerResponse::WalletAlreadyExists => Ok(()),
}
}
#[allow(dead_code)]
async fn unlock_existing_wallet_via_lncli() -> 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()
.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(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!("{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 wallet_password_is_valid_for_lncli() {
assert!(WALLET_PASSWORD.len() > 8);
}
}