Renew registrar leaves and honour the reload contract (#768) - #962
Conversation
Both leaves the registrar surface runs on expire, and nothing renewed them: start-time issuance mints them once and leaves a usable pair alone, so a lapse took every enrollment in the deployment down and the registrar could not re-mint itself. The daemon now owns one renewal adapter, created only where the endpoint is enabled, running on the rendered internal agent configuration's own cadence, lead time and issuance-retry budget rather than on a second policy of its own. It initializes the per-leaf state accessor from the certificates already on disk, before the endpoint serves anything, and joins with the daemon's other tasks on shutdown. Nothing live changes until everything that can fail has succeeded. The replacement is issued off to one side — the ACME path is split so it can return the chain, the candidate and a fresh key instead of publishing them — then validated, then merged into a staged CA bundle, then turned into a whole next TLS configuration. Only then are the bundle, the certificate and the key written, each behind a snapshot of its bytes, mode and ownership; a write that fails restores what it reached, and a rollback that fails says so rather than claiming the files were put back. The endpoint's active configuration is exchanged rather than its resolver alone. The old acceptor retains the old client verifier, so replacing only the presented certificate would leave a trust-anchor rotation undecided until a restart. The accept loop loads the active configuration immediately before each handshake, so the exchange costs no restart, no signal, no socket rebind and no dropped connection. The caller's half of the same contract is the per-dial load: the pair is published by two separate renames, so a reader that lands between them re-reads rather than presenting a mismatch, five reads with the 1/2/4/8 ms waits and then a typed error. Closes #768
The accessor exists to state one thing correctly: the lifetime of the certificate actually at a leaf's path, and how the last attempt on it ended. Three paths could make it say otherwise. An issuance carried its fresh private key as a bare String inside a struct that derives Debug, so the key of every certificate this crate issues would print into whatever log line, trace field or error chain first rendered one. It is wrapped now, in the newtype the internal leaf's key already used. A publication that reached the active configuration really did renew the leaf. Failing the attempt because the 0700 working directory beside the pair would not unlink would retain the old notAfter against a certificate that has just been replaced, so the cleanup is folded in rather than substituted: reported beside the success, and attached to the error a failed attempt already carries. The lifetime a success records is read off the candidate before anything is published rather than off the file it becomes. They are the same bytes, but a read-back is one more thing that can fail after the swap has already happened, and a failure there would record the leaf as failed at exactly the moment it was renewed. Rollback's owner restoration is pub(crate) with the same reasoning that keeps every other protected write root-owned: it is the one way to publish under an owner that is not root, and it exists for a snapshot putting back ids it read off the file itself. Part of #768
|
[Reviewer Round 1] Found two issues:
|
|
[Review Verdict Round 1: NOT_APPROVED] |
An enabled endpoint owes both leaves a renewal adapter and both accessor entries, and a failed attempt has no lifetime with which to create a missing entry. Logging a failed preparation and spawning the accept task anyway therefore produced exactly the state the adapter exists to prevent: an endpoint serving two certificates that nothing would renew, with no entry to report on them either. Preparation now happens beside the handler's own resolution, before anything is spawned, and a failure fails the invocation. That is the same rule the accept task's other dependencies already follow, and the material it needs is already guaranteed by start-time issuance, which runs before the daemon does. Initialization no longer tolerates a leaf it cannot observe, for the same reason. The cadence's retry budget also goes through the daemon's own selection now. The internal config's sole profile may carry a `[profiles.retry]` of its own, and copying the top-level table would have issued the registrar leaves under a different budget from the profile every other cadence value comes from. Part of #768
|
[Author Round 1] CI is running on the pushed commit. Here is the disposition of each review item. [P1] Do not serve an enabled endpoint after renewal setup fails — FixedAccepted in full; the issue's contract is explicit that an enabled endpoint has an adapter and both accessor entries, and the old code could satisfy neither while still serving.
[P2] Honour the internal profile's retry override — FixedValid: Docs
VerificationLinux container ( |
|
[Reviewer Round 2] [Reviewer Round 2: NOT_APPROVED]
Round 1’s two findings are resolved: preparation now fails before spawning the endpoint, and cadence correctly uses the internal profile’s retry override. PR linkage and test-plan hygiene look correct. |
|
[Review Verdict Round 2: NOT_APPROVED] |
The merged CA bundle at [trust] ca_bundle_path has three writers inside one daemon: each profile's publication reads it, merges its issued chain in and writes it back, the fast-poll trust apply replaces it wholesale, and the registrar surface's renewal stages a merge, snapshots it, publishes it and restores it when the publication that follows fails. Nothing serialized them. ProfileLocks cannot: its keys are profile labels, and this file has no profile. So a merge computed from bytes another writer had already replaced overwrote that writer's anchor, and one landing after the registrar transaction's snapshot was discarded by that transaction's rollback. Either way the host went on serving and validating against a bundle missing a CA it had been told to trust, with nothing recording that it went missing. Up to three profiles issue concurrently by default, so the window is not a corner case. All three writers now take one lock per bundle path across the whole read-merge-write span. The lock is a static keyed on the configured path rather than something a caller is handed: the per-profile publication is reached from the CLI as well as from the daemon loop, several frames below anything that knows a daemon exists, and threading a registry through all of them would put the lock's correctness in the hands of every future call site. It is deliberately not taken inside fs_util::write_ca_bundle, which the holding transactions call. Part of #768
|
[Author Round 2] CI is running on [P1] Serialize the shared CA-bundle transaction with other in-process writers — FixedThe finding is correct, and it is worse than the two writers it names — there are three, and one of the races predates this branch. What was wrong. The fix. A new A Deterministic interleaving test, plus three more:
Scope note: including Docs. Verification (Linux container, |
|
[Reviewer Round 3] [Reviewer Round 3: NOT_APPROVED]
Round 2’s P1 is resolved: the path-keyed bundle lock now covers registrar staging/snapshot/rollback, profile merge publication, and fast-poll trust replacement, with a deterministic rollback interleaving test. |
|
[Review Verdict Round 3: NOT_APPROVED] |
The reload contract's server half was asserted off the disk: the test opened an unserved raw socket and compared certificate files, so a publication that renewed the live material and never exchanged the active configuration would have passed it. That is the one failure the whole transaction exists to prevent. Both cases now run the production accept loop over the harness endpoint and read the answer off the wire. One holds a connection handshaken before the swap open across it, publishes a server candidate, and asserts the next handshake is presented the renewed leaf while the older connection finishes under the configuration it handshook with. The other dials with a caller under a rotated anchor, watches the live verifier refuse it, publishes the client leaf that carries that anchor, and dials again -- the same material, now admitted by the verifier the publication rebuilt. Removing the swap fails both: the first on the presented leaf, the second on an UnknownCA alert. Part of #768
|
[Author Round 3] CI is running on [P2] Exercise the reload contract through the renewal path — FixedThe finding is correct. It is replaced by two tests that run the production accept loop (
Acceptance is asserted through a round trip rather than through Negative control, measured: with DocsNone. This round is test-only; no behaviour, interface, or user-visible change, so VerificationLinux container ( |
|
[Reviewer Round 4] [Reviewer Round 4: APPROVED] Round 3’s P2 is resolved. The new tests use the production accept loop and real mTLS handshakes to prove both server-leaf replacement and client-verifier replacement after publication (tests, tests). The accept loop loads the active acceptor immediately before the handshake, and publication swaps it only after all live writes succeed (serve.rs, registrar_renewal.rs). All prior findings are resolved. I found no new issues. PR linkage and test-plan hygiene are correct. |
|
[Review Verdict Round 4: APPROVED] |
Suggested squash commitTitle Body |
Closes #768
Part of #770
Summary
Both leaves the registrar surface runs on expire, and nothing renewed them:
ensure_registrar_surface_certificatesmints them once at start and deliberately leaves a usable pair alone, so a lapse took every enrollment in the deployment down and the registrar could not re-mint itself. This adds one daemon-owned renewal adapter that keeps both valid, and delivers both halves of the reload contract. Nothing goes on the wire: no response field, serialization fixture, wire-visible renewal state or lapse classification is added.The adapter
Created only where
[registrar_endpoint] enabled = true, from the activated endpoint the daemon already holds, and armed before anything at all is spawned — beside the accept task's other start-up dependencies — so the per-leaf state accessor is initialized from the certificates already on disk before the endpoint serves anything. Its handle joins with the daemon's other tasks and it takes the same shutdown watch. With the endpoint disabled it is never built: no adapter, no state entry, no pass, no OpenBao request and no CA request.Arming it is part of starting, not something logged over a running endpoint. An enabled endpoint owes both leaves an adapter and both accessor entries, and a failed attempt has no lifetime to make a missing entry with, so a preparation that cannot be completed — a rendered internal
agent.tomlthat is missing or no longer loads, a deployment state file that cannot be resolved, a leaf certificate on disk that no longer parses — fails the invocation with a named error, exactly as an absent internal credential or an unloadable provisioning config already does. Serving two certificates nothing will renew is the outage this whole change exists to prevent.Cadence, lead time and the issuance-retry budget all come from the rendered internal agent configuration's sole profile. No second scheduler, no lead-time constant of its own, no second retry policy: eligibility is the daemon's existing predicate, split out as
daemon::should_renew_certificateso the two registrar leaves — which have no[[profiles]]entry — are judged by the same rule rather than by a copy of it, no-bundle opt-out and all. The retry budget is selected the same way, throughdaemon::select_retry_backoff: that profile may carry a[profiles.retry]of its own, and where it does it wins over the config's top-level[retry], exactly as it does for a service profile.Publication as a transaction
Nothing touches a live path until everything that can fail has already succeeded:
acme::flowis split at exactly the point publication used to begin, soissue_certificate_materialreturns the chain, the candidate certificate and a fresh key instead of writing them.issue_certificate_with_bootstrapis now that call pluspublish_issued_material, so the two paths cannot drift.ServerConfig— from the candidate server pair when that is the leaf being renewed and the live one when it is not, and from the staged bundle in either case. The incomingWebPkiClientVerifieris rebuilt from the post-merge bundle's pinned subset and from nothing else.A failed write restores every path the publication reached — bytes, mode and ownership — leaves the old configuration installed and records a failed attempt. A rollback that itself fails carries both errors and explicitly does not describe the files as restored. Candidate files and snapshots live in a
0700working directory removed on every exit.One writer at a time on the shared CA bundle
[trust] ca_bundle_pathis not the registrar's file. The per-profile publication reads it, merges its issued chain into it and writes it back — up to three profiles concurrently by default — and the fast-poll trust apply replaces it wholesale when a trust update lands. Nothing serialized any of them, andProfileLockscannot: its keys are profile labels, and this file has no profile. So a merge computed from bytes another writer had already replaced overwrote that writer's anchor, and a merge landing after the registrar transaction's snapshot was discarded by that transaction's rollback — either way the host serves and validates against a bundle missing a CA it was told to trust, with nothing recording that it went missing.All three writers now take one lock per bundle path (
ca_bundle_lock) across their whole span: the registrar's from the staged read through the last restore its rollback can perform, the profile writer's around its read-merge-write, the fast-poll apply's around its replacement. The lock is deliberately not insidefs_util::write_ca_bundle, which the holding transactions call. The lost-update window predates this branch for the two pre-existing writers, soCHANGELOG.mdrecords it under### Fixed.The server-side swap
ActivatedEndpointnow holds its acceptor and resolver together behind anRwLock<Arc<ActiveTls>>, and the accept loop loads it immediately before each handshake, holding no lock across an await. Renewal exchanges the whole configuration: the old acceptor retains the old client verifier, so replacingEndpointCertResolveralone would leave a trust-anchor rotation undecided until a restart. No restart, no signal, no socket rebind, no dropped in-flight connection.The caller-side reload
load_matching_client_materialrereads the pair on every dial and checks the key is the leaf's. A reader that lands between the writer's two renames re-reads instead of presenting a mismatch: five reads total, with 1/2/4/8 ms waits after the first four, then a typedClientMaterialError::KeyMismatch. That is a fixed local policy of the reader's and deliberately not the daemon's issuance-retry backoff. It is documented as the caller contract indocs/en/operations.mdanddocs/ko/operations.md, naming the in-repository client as the reference behaviour.Test plan
run_daemonwith a named error before anything is spawned, so the accept task is never reachedtokio::time::pause, with a driven pass reading no role_id/secret_id and using no AppRolenever_attemptedand no timestamp without issuing anything, and a leaf it cannot observe refuses the adapter instead of leaving the endpoint one entry short[profiles.retry]wins, and the rendered[retry]table is the fallbackserve::run, a publication runs throughRegistrarCertRenewal, and the next real mTLS handshake is presented the renewed server leaf and admits a caller under an anchor only the rebuilt verifier holds — with an unchanged socket inode and an in-flight connection that finishes under the configuration it handshook with. Both are read off the wire rather than off the disk: removeswap_active_tlsand each fails, one on the presented leaf and one on anUnknownCAalertcargo test --lib(1311 passed) andcargo test --bin bootroot(1295 passed), plus fmt, clippy-D warningsand rustdoc, all run on Linux in arust:1-bookwormcontainer as a non-root user, becausesrc/registrar/endpoint/*is Linux-gated and never compiles on macOSscripts/preflight/ci/check.sh: fmt, clippy, rustdoc, ruff, biome, markdownlint,mkdocs build --strict,cargo audit(only the two pre-existing allowed advisories)scripts/preflight/ci/e2e-matrix.sh— did not run on this machine. The matrix needs passwordlesssudofor itsbootroot initstep, which this host does not supply, and port 8200 is held by an unrelated local process.tests/bootroot_rotate.rslikewise fails inside the Linux container for want of adockerbinary; those tests never reach any code this branch changes. CI'sDocker E2Eandtest-corejobs gate the arm that could not run here.