feat(lnd): rotate Lightning macaroons from the dashboard, and stop stranding BTCPay
Demo images / Build & push demo images (push) Successful in 3m34s
Demo images / Build & push demo images (push) Successful in 3m34s
Rotating LND's macaroons was an SSH-only script, which in practice meant it did not happen — while a macaroon is a bearer token with no revocation and no expiry, so anything that ever read one keeps the ability to spend until they are replaced. Settings → Lightning credentials now does it behind the node password, shows a step checklist, and refuses to report success unless it has confirmed the node identity and channel census are unchanged. Three findings from performing a real rotation on a dev node, each fixed here: 1. BTCPay was left holding a dead credential, silently. Its connection string carries the macaroon INLINE (LND's datadir is owned by its container subuid, so btcpay cannot bind-mount the file), and the daemon only regenerates that secret when LND's TLS cert thumbprint changes — which macaroon rotation does not touch. Result: btcpay up, LND up, both healthy, every Lightning payment failing, nothing anywhere saying why. 2. Rewriting the secret is not enough to fix it. `secret_env_hash` makes the change visible as env drift, but the reconcile loop runs `ExistingOnly` at boot AND periodically, and there it deliberately leaves running restart-sensitive apps untouched — observed once per tick for half an hour on the dev node. So this reuses FED-07's `credential_rotated` carve-out via a new default-no-op `ContainerOrchestrator::mark_credential_rotated`, on the same reasoning: restart sensitivity protects apps that are working, and this one is working only in appearance. The shell script cannot reach an in-process flag, so it removes the container and lets desired-state recovery rebuild it. 3. LND stayed locked forever on a loaded node. The unlocker is only served after channel.db/graph.db/wallet.db open, measured at 2m38s on a box running 30 containers; the unlock helper gave up at ~60s. That is not a harmless retry — reconcile records the post-start hook as failed, restarts LND, and the slow open begins again, so the wallet never opens and every LND-dependent app stays broken. The not-ready budget is now ~10 minutes; a genuinely wrong password still exits on the first pass via `all_rejected`. Safety properties worth not regressing: - No macaroon content in any response, error, log line or the polled progress feed — digests and byte counts only. - Rotation unlocks via a new `unlock_existing_wallet_no_wipe`, so there is no code path from "rotate my credentials" to `recreate_wallet_destructively`. A wallet whose password this node lacks fails the rotation with the wallet intact. - Channels are compared as active+inactive totals, not `num_active_channels`, which legitimately dips after any restart while peers reconnect. - Backup verified by file count before anything is deleted. Verified: cargo check + fmt clean, 6 new unit tests and the 6 existing container::lnd tests pass, vue-tsc clean, and the built bundle contains the three new RPC method names (the frontend build can silently no-op). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b7e57ca9cf
commit
d15cd58d7f
@@ -1158,6 +1158,69 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
/** This node's Lightning credential state. Digests and counts only — the
|
||||
* backend never returns macaroon content, so nothing here is sensitive. */
|
||||
async lndMacaroonStatus(): Promise<LndMacaroonStatus> {
|
||||
return this.call({ method: 'lnd.macaroon-status', timeout: 30000 })
|
||||
}
|
||||
|
||||
/** Begin a macaroon rotation. Returns as soon as the job is accepted; the
|
||||
* work takes minutes (LND has to close and reopen its databases), so poll
|
||||
* `lndMacaroonRotationProgress` for the outcome. */
|
||||
async lndRotateMacaroons(password: string): Promise<{ status: string }> {
|
||||
return this.call({
|
||||
method: 'lnd.rotate-macaroons',
|
||||
params: { password },
|
||||
timeout: 30000,
|
||||
})
|
||||
}
|
||||
|
||||
async lndMacaroonRotationProgress(): Promise<LndRotationProgress> {
|
||||
return this.call({ method: 'lnd.macaroon-rotation-progress' })
|
||||
}
|
||||
}
|
||||
|
||||
export type RotationStepState = 'pending' | 'running' | 'done' | 'failed' | 'skipped'
|
||||
|
||||
export interface LndRotationStep {
|
||||
key: string
|
||||
label: string
|
||||
state: RotationStepState
|
||||
detail: string | null
|
||||
}
|
||||
|
||||
export interface LndRotationProgress {
|
||||
running: boolean
|
||||
/** null while running, then the verdict. Lets the UI tell "in progress"
|
||||
* apart from "finished and failed". */
|
||||
ok: boolean | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
error: string | null
|
||||
steps: LndRotationStep[]
|
||||
/** Holds the OLD root key, so it is still secret. The UI tells the operator
|
||||
* to delete it once every wallet app has been re-paired. */
|
||||
backup_path: string | null
|
||||
identity_pubkey: string | null
|
||||
channels_before: number | null
|
||||
channels_after: number | null
|
||||
new_admin_macaroon_sha256: string | null
|
||||
}
|
||||
|
||||
export interface LndMacaroonStatus {
|
||||
installed: boolean
|
||||
admin_macaroon_sha256: string | null
|
||||
/** When LND last minted these credentials, host local time. */
|
||||
issued_at: string | null
|
||||
identity_pubkey: string | null
|
||||
channels_open: number | null
|
||||
channels_pending: number | null
|
||||
/** Why LND could not be asked, when it could not. */
|
||||
lnd_error: string | null
|
||||
btcpay_uses_internal_lnd: boolean
|
||||
/** null when BTCPay has no internal Lightning node — an absence, not a fault. */
|
||||
btcpay_credential_current: boolean | null
|
||||
rotation: LndRotationProgress
|
||||
}
|
||||
|
||||
export const rpcClient = new RPCClient()
|
||||
|
||||
Reference in New Issue
Block a user