feat(lnd): auto-generate btcpay internal-LND connection secret

Materialise /var/lib/archipelago/secrets/btcpay-lnd-connection
(type=lnd-rest;server=https://lnd:8080/;macaroon=<hex>;certthumbprint=<sha256>)
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 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-10 17:31:18 +01:00
parent bb0875f7f2
commit ce6ec13e28
3 changed files with 89 additions and 0 deletions

View File

@ -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)) .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<String> {
use sha2::{Digest, Sha256};
let b64: String = pem
.lines()
.filter(|l| !l.starts_with("-----"))
.collect::<Vec<_>>()
.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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -3162,6 +3162,17 @@ impl ProdContainerOrchestrator {
.await .await
.context("ensuring bitcoin tx-relay credentials")?; .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 // Other app secrets (fmcd-password, fedimint-gateway-hash, …) are now
// declared as `generated_secrets` in their manifests and materialised // declared as `generated_secrets` in their manifests and materialised
// generically in `resolve_dynamic_env` — no per-app code here. // generically in `resolve_dynamic_env` — no per-app code here.

View File

@ -102,6 +102,17 @@ fn random_base64(bytes: usize) -> String {
base64::engine::general_purpose::STANDARD.encode(buf) 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 /// Atomically write a `0600` secret: a temp file in the same dir (so the rename
/// is atomic), fsynced, then renamed over the target. /// is atomic), fsynced, then renamed over the target.
fn write_secret(path: &Path, value: &str) -> Result<()> { fn write_secret(path: &Path, value: &str) -> Result<()> {