acme.rs leaks challenge records on most failure paths, which permanently blocks reissuance #30
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found while porting this module into
cert-keeper, where both bugs bit in a live deployment. Not fixing here — logging it for whoever owns this. Verified against04c0cef.Relevant if ACME stays in EdgeRouter. Increment 7 of the central-cert-issuance design retires it, so this may be moot — but it is live on kuruk and roku today.
1. Challenge records leak on most failure paths (the serious one)
cleanup_txt_recordsis called in exactly two places: the order-not-ready branch (acme.rs:169) and success (:193). Every other exit fromacquire_certificatereturns with records already created and never removed::135— propagation failure,?onwait_for_txt_propagation. The most likely failure of the lot, and see §2 for why it fires.:106— unexpected authorization status:111,:125— challenge lookup / record creation, part-way through the create loop:143,:149,:153— pass-2 notification:167—poll_ready:183,:189—finalize/poll_certificateA leak is not merely untidy. Let's Encrypt reuses the challenge value of a pending authorization, so the next attempt tries to create a record identical to the orphan, Cloudflare rejects it with
81058 An identical record already exists, and the order fails at creation — before the cleanup that would have removed the orphan. Self-perpetuating, and it survives a restart because the state is at Cloudflare, not in the process.Observed exactly this in
cert-keeperon 2026-08-28: every subsequent renewal failed at creation until the record was cleared by hand.Two fixes, and the second matters more:
81057/81058as success by adopting the existing record (matched on exact content, so it cannot claim someone else's)._acme-challenge.<domain>before ordering rather than relying on cleanup afterwards. Cleanup-on-exit cannot be relied on at all — the process can be killed between create and delete, anddocker stopgives SIGKILL after 10s against a 2-minute propagation wait.cert-keeperfb4f60dhas both if it's useful as a reference.2. The DoH propagation check can poison itself for 30 minutes
wait_for_txt_propagation(:216) asks Cloudflare's public DoH resolver.oates.wshas an SOA minimum of 1800 seconds, so a query made before the record is live caches the negative answer for half an hour — far longer than the 120-second window.EdgeRouter is partly protected: it sleeps 5 seconds before its first query (
:218), wherecert-keeperqueried immediately and failed 100% of the time. But 5 seconds is a race, not a guarantee. When it loses, the failure is total rather than partial, becauseclientis built once outside the loop (:216) andreqwestkeeps the connection alive — so all 24 attempts return to the same anycast node, the one the first query poisoned. A freshcurlfrom the same host will see the record while EdgeRouter never does, which makes this extremely confusing to diagnose from outside.Worth knowing: kuruk's EdgeRouter was serving its holding certificate on 2026-08-26 while its renewal was in flight. That is consistent with this, though not proof of it.
The robust fix is to query the zone's authoritative nameservers instead of a caching resolver — they hold the zone rather than a cache of it, cannot serve a stale negative, and are what Let's Encrypt itself asks.
cert-keeper9b182a9does this withhickory-resolver. Cheaper mitigations (wait longer first, disable connection reuse) reduce the odds without removing the failure mode.Testing note
cert-keeperhad tests asserting that a matching value succeeds and a non-matching one fails, and both passed throughout. Neither could distinguish "the resolver returned no records" from "the resolver returned a different value" — which is precisely the distinction that identified the bug. Worth having whichever way this is fixed.Fixed by the per-name ACME rewrite in v2.0.0.
acme.rsnow records every published record and cleans up on every exit path, not just success:finish_orderexists precisely so the result can be held while cleanup runs before the?, andcleanup_with_retryretries once rather than giving up on a transient failure. Propagation failure, validation failure, finalize failure and an unparseable certificate all now retire the record.One residual, noted rather than left implicit: a failure inside the publish loop returns via
?without cleaning records pushed by earlier iterations. That is unreachable today because an order carries exactly one identifier, so at most one record exists and the failing one was never published. It becomes reachable if orders ever batch several names — worth a glance if #50's multi-directory work changes the order shape.Closing on the behaviour that was actually reported.