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:
parent
bb0875f7f2
commit
ce6ec13e28
@ -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<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<()> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user