From ce6ec13e28217809f519c60ada1b8bf102ab7d87 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 10 Jul 2026 17:31:18 +0100 Subject: [PATCH] feat(lnd): auto-generate btcpay internal-LND connection secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Materialise /var/lib/archipelago/secrets/btcpay-lnd-connection (type=lnd-rest;server=https://lnd:8080/;macaroon=;certthumbprint=) in ensure_app_secrets before btcpay-server env resolution. The macaroon travels inline as hex because LND's datadir is owned by its container subuid (100999) — bind-mounting it into btcpay's userns EACCESes. Reads the macaroon via the existing read_file_as_root sudo fallback; pins the TLS cert by DER SHA256 thumbprint and rewrites on cert rotation. No-op while LND is absent (btcpay's secret_env entry is optional), and generation errors log-and-continue so btcpay never fails to start over this. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/container/lnd.rs | 67 +++++++++++++++++++ .../src/container/prod_orchestrator.rs | 11 +++ core/archipelago/src/container/secrets.rs | 11 +++ 3 files changed, 89 insertions(+) diff --git a/core/archipelago/src/container/lnd.rs b/core/archipelago/src/container/lnd.rs index df9ef5f0..8772cdfe 100644 --- a/core/archipelago/src/container/lnd.rs +++ b/core/archipelago/src/container/lnd.rs @@ -670,6 +670,73 @@ fn has_required_lnd_flags(conf: &str, rpc_pass: &str, bitcoin_host: &str) -> boo .all(|needle| conf.lines().any(|line| line.trim() == *needle)) } +/// Secret file consumed by btcpay-server's optional `BTCPAY_BTCLIGHTNING` +/// secret_env (see apps/btcpay-server/manifest.yml). +const BTCPAY_LND_CONNECTION_SECRET: &str = "btcpay-lnd-connection"; + +/// Materialise the BTCPay→internal-LND connection-string secret. +/// +/// LND's datadir is owned by its container subuid (100999 on a stock node), +/// so btcpay cannot bind-mount the macaroon — EACCES across the userns +/// boundary. The connection string therefore carries the macaroon inline as +/// hex, delivered by reference through the podman secret store. +/// +/// No-op when LND isn't provisioned yet (missing tls.cert or macaroon) — +/// btcpay's secret_env entry is `optional`, so it simply starts without an +/// internal Lightning node and picks it up on a later reconcile tick. +/// Rewrites when the pinned cert thumbprint no longer matches (LND TLS cert +/// rotation). Macaroon rotation without cert rotation is not auto-detected +/// (reading the macaroon needs sudo; probing it every tick is not worth the +/// churn) — delete the secret file once to force regeneration. +pub async fn ensure_btcpay_lnd_connection_secret(secrets_dir: &std::path::Path) -> Result<()> { + let cert_path = format!("{DEFAULT_DATA_DIR}/tls.cert"); + let pem = match fs::read_to_string(&cert_path).await { + Ok(s) => s, + Err(_) => return Ok(()), // LND not installed/provisioned yet + }; + let thumbprint = + cert_sha256_thumbprint(&pem).context("computing LND tls.cert thumbprint")?; + + let target = secrets_dir.join(BTCPAY_LND_CONNECTION_SECRET); + // Fast path (no sudo): existing secret already pins the current cert. + if let Ok(existing) = fs::read_to_string(&target).await { + if !existing.trim().is_empty() + && existing.contains(&format!("certthumbprint={thumbprint}")) + { + return Ok(()); + } + } + + let macaroon_path = format!("{DEFAULT_DATA_DIR}/data/chain/bitcoin/mainnet/admin.macaroon"); + if !file_exists_as_root(&macaroon_path).await { + return Ok(()); // wallet not created yet; next tick retries + } + let macaroon = read_file_as_root(&macaroon_path).await?; + let value = format!( + "type=lnd-rest;server=https://lnd:8080/;macaroon={};certthumbprint={}", + hex::encode(macaroon), + thumbprint + ); + crate::container::secrets::write_secret_file(&target, &value) + .context("writing btcpay-lnd-connection secret") +} + +/// SHA256 over the DER certificate body (matches +/// `openssl x509 -fingerprint -sha256` without colons) — the format BTCPay's +/// `certthumbprint=` connection-string parameter expects. +fn cert_sha256_thumbprint(pem: &str) -> Result { + use sha2::{Digest, Sha256}; + let b64: String = pem + .lines() + .filter(|l| !l.starts_with("-----")) + .collect::>() + .join(""); + let der = base64::engine::general_purpose::STANDARD + .decode(b64.trim()) + .context("decoding tls.cert PEM body")?; + Ok(hex::encode_upper(Sha256::digest(&der))) +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index a04ff1ab..6b7328a3 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -3162,6 +3162,17 @@ impl ProdContainerOrchestrator { .await .context("ensuring bitcoin tx-relay credentials")?; } + if app_id == "btcpay-server" { + // Derived secret (LND macaroon + cert thumbprint), consumed by an + // `optional` secret_env — btcpay must still start when LND is + // absent or the derivation fails, so log-and-continue. + if let Err(e) = + crate::container::lnd::ensure_btcpay_lnd_connection_secret(&self.secrets_dir) + .await + { + tracing::warn!(error = %e, "btcpay-lnd-connection secret not generated; btcpay will run without the internal LND node"); + } + } // Other app secrets (fmcd-password, fedimint-gateway-hash, …) are now // declared as `generated_secrets` in their manifests and materialised // generically in `resolve_dynamic_env` — no per-app code here. diff --git a/core/archipelago/src/container/secrets.rs b/core/archipelago/src/container/secrets.rs index b866d954..74eb72da 100644 --- a/core/archipelago/src/container/secrets.rs +++ b/core/archipelago/src/container/secrets.rs @@ -102,6 +102,17 @@ fn random_base64(bytes: usize) -> String { base64::engine::general_purpose::STANDARD.encode(buf) } +/// Write an externally computed secret value (0600, atomic). For derived +/// secrets that aren't random generators — e.g. the btcpay internal-LND +/// connection string assembled in `container::lnd`. +pub(crate) fn write_secret_file(path: &Path, value: &str) -> Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir) + .with_context(|| format!("creating secrets dir {}", dir.display()))?; + } + write_secret(path, value) +} + /// Atomically write a `0600` secret: a temp file in the same dir (so the rename /// is atomic), fsynced, then renamed over the target. fn write_secret(path: &Path, value: &str) -> Result<()> {