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))
|
.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::*;
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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<()> {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user