Repository navigation
cherry-pick: Publish TLS state transactionally so a failed rebuild never downgrades below last-good (#2384 → v5.2) - #2982
Conversation
…s below last-good (#2384) * Publish TLS state transactionally so a failed rebuild never downgrades serving A cert renewal whose hdb_certificate write misses the 1500ms rebuild debounce made updateTLS pair the old table cert with the new on-disk key. The pass had already cleared the shared SNI map, and a per-record build failure was log-and-skip, so the record's hostnames fell through to the self-signed default — production nodes served the wrong certificate for days, silently, with nothing retrying (harper#2382). updateTLS now builds the whole replacement state into pass-local candidates and reconciles the live maps only after the pass completes. A record that is still in the table but fails to build keeps every live entry it owns and its default candidacy, so a transient mismatch can never publish a state worse than the one being served; deleting the record remains the way to drop its contexts. A failed pass arms a self-retry on the shared debounce with per-signature backoff (1.5s doubling to 5min) and signature-throttled logging, and a pass-level throw after .ready has settled leaves live state untouched instead of rejecting into the void. loadAndWatch's mtime latch now means "last successfully applied": it rolls back on a synchronous throw or a rejected callback promise (equality-guarded so a stale rejection cannot unlatch a newer reload), and the cert watcher returns its certificateTable.put so a lost write is retried by the periodic poll instead of being deduplicated forever. The cert-key-reload integration test previously waited for a handshake failure or fallback cert to establish its ordering — the exact behavior this change removes — and would now skip silently. It confirms the renewed cert's arrival through the system table instead, asserts every handshake keeps serving the last-good pair while the matching key is absent, then delivers the key and requires convergence on every worker. Closes #2382 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address cross-model review: fail closed on CA-set change, report all pass failures Review findings on the transactional-publication commit (codex + harper-domain adjudication): - A retained context froze its ca: trust list and appended chain at its own build time, so retention across a CA-set change would keep honoring trust the operator just revoked. Retention now fails closed for a record whose built-in CA material no longer matches the candidate CA set; the retry pursues a fresh build instead. - An unparseable authority row bypassed the failure path entirely: it re-logged on every pass outside the signature throttle, never armed the backoff, and let clearFailureState claim a clean recovery. It now reports through failedThisPass like every other record failure. - The zero-cert early return swallowed per-record errors and retried on a flat debounce; it now reports the failure set and uses the backoff when failures drove the empty pass. - The repeat-failure summary stays at error level so a stuck rotation keeps an alertable signal while the retained cert ages toward expiry, and a signature change or recovery now clears an armed retry timer instead of letting it run on a stale delay. - The end-to-end test replaces its fixed settle sleep with a deadline-retried handshake hammer and gains HARPER_TEST_REQUIRE_FILE_WATCHERS to turn the inotify-limitation skip into a failure in pipelines that own this regression. - integrationTests/**/node_modules/ is now ignored one level deeper, dropping a test-run-generated fixture dependency tree (with machine-local symlinks) that the previous commit swept in. Refs #2382 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Rebuild retained TLS contexts against the current CA set instead of dropping them The prior fail-closed rule (drop retention when the CA set changed) went inert in exactly the case it existed for: with every record failing, the empty pass took the zero-cert early return and the whole old state — revoked trust included — kept serving. It also punished CA additions with a needless drop. A retained context's cert and key are still a consistent pair; only its frozen ca: trust list can be stale. Retention now rebuilds the context from its own options against the current CA material (memoized per pass), so revoked client-CA trust is never carried forward, additions flow through, and the zero-cert return can no longer bypass the rule — a successful rebuild keeps the candidate set non-empty. A rebuild failure drops the record's entries as before. Also wires HARPER_TEST_REQUIRE_FILE_WATCHERS into the Linux integration job so the cert-key-reload skip cannot silently retire the #2382 end-to-end assertion in the pipeline that owns it, and corrects DESIGN.md's retention contract. Refs #2382 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Scope retention rebuilds to mTLS listeners and route their failures through the throttle Round-3 review deltas: the CA-aware rebuild only matters when the context carries a ca: trust list, so non-mTLS listeners retain their existing object untouched instead of paying a cold-path rebuild whose only failure mode was converting a safe retention into a drop. A rebuild failure now reports through the same failure-signature throttle as record failures instead of logging raw on every pass, and the retention comment plus DESIGN.md state the real contract for the empty-pass corner: when nothing else is servable, the zero-certificate guard retains the old state — availability outranks the drop — while the failure keeps retrying. New tests pin the security property itself: an mTLS selector whose CA row is deleted while its record is failing must publish a rebuilt context whose options.ca no longer contains the removed PEM, and a non-mTLS record must keep its identical context across a CA-set change. Refs #2382 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Refresh retained contexts' CA bookkeeping and de-flake the throttle assertion The non-mTLS retention short-circuit kept the retained object's certificateAuthorities frozen; that array is mirrored into socket metadata read by fronting proxies, so refresh it on retention. The corrupt-row throttle test absorbs straggler first-logs over a longer settle window and then requires a tight bound, so the assertion is sensitive to re-logging without being racy against late selectors. Refs #2382 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Take fresh ticket keys on retention rebuilds; compress over-narrated comments A rebuilt retained context now calls getTicketKeys() like every fresh build instead of inheriting the previous build's session-ticket keys (gemini review finding on #2384). Comment pass per review nits: production comments compress to the invariant they protect, with the full retention contract living in DESIGN.md. Refs #2382 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Compress the inherited zero-cert comments, scope trust claims, anchor the throttle test The two comment blocks flagged across three review rounds (both inherited from main) compress to their invariants: the zero-cert guard from 24 lines to 8, the defaultContextSetThisPass note from 6 to 3. The trust-aware-retention wording in keys.ts and DESIGN.md is scoped to what the code guarantees — NEW handshakes never see revoked trust; established sessions and outstanding tickets are unaffected, exactly as on a fresh build, since ticket keys are process-wide and never rotate on trust changes (session-invalidation-scope is recorded as an open decision on #2384). The corrupt-row throttle test drops its tuned sleeps for a publish-anchored assertion: by this selector's second completed pass every other selector's single first-log is counted, and across two further completed passes any per-record log growth fails — deterministic, and sensitive to exactly the re-logging regression it guards. Refs #2382 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drive both rebuild loops from one snapshot; ties go to the incumbent; publish the wildcard flag Review findings from kriszyp on #2384: - The CA map and the serving contexts came from two separate search() calls, each owning its own read snapshot, so a write committing between them could publish a renewed leaf against the previous CA set. Both loops now iterate one materialized records array. - Strict > comparisons let an equal-quality sibling take a failed record's hostname or the default on a tie. Retention now reclaims a hostname the retained context already owned on >=, and the live default wins the default tie only when it IS the retained record — a context retained merely for a hostname still needs strict > to become the default. An equal-quality regression test pins both rules. - hasWildcards was only ever set true; with transactional publication the replacement flag is published directly, and retention marks it when it carries a wildcard-keyed entry forward (their keys start with '.'). Refs #2382 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Attribute TLS failure logs to their listener so the throttle is testable The per-record failure line now names the listener type — operationally useful on multi-listener nodes, and it makes the throttle contract attributable: the corrupt-row test drives a selector with a unique listener type and asserts exactly one log for a stable signature, immune to the suite's accumulated selectors logging the same record under their own types (the publish-anchored form assumed foreign first-logs land before this selector's second pass, which CI runner load disproved). Refs #2382 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 1e4c163) Dispatch-Task: harper-2384-backport-v52 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSCvLgqVLi4PMaUr6ZgeP4
There was a problem hiding this comment.
Code Review
This pull request introduces transactional publication for TLS configuration updates to resolve issue #2382. By building the replacement state off-to-side and only updating the live maps upon successful completion, the system ensures that transient configuration mismatches or build failures do not downgrade the active serving state below the last-good configuration. Additionally, it implements self-retry with backoff, throttled logging for failed passes, and mtime latch rollback on failed file applies. The feedback suggests minor improvements to the new unit tests, specifically guarding the afterEach cleanup hooks to prevent secondary TypeErrors if the beforeEach hooks fail before initializing key variables.
Cherry-pick of #2384 onto
v5.2: a TLS rebuild pass never publishes a state worse than the one being served.updateTLSbuilds the whole replacement state into pass-local candidates and reconciles the live maps only after the pass completes; a record that is still in the table but fails to build keeps every live entry it owns and its default candidacy, so a cert renewal whosehdb_certificatewrite reaches a worker before the matching on-disk key no longer drops that worker to the self-signed default until the 5-minute re-read heals it. A failed pass arms a self-retry with per-signature backoff (1.5 s doubling to 5 min), andloadAndWatch's mtime latch now means "last successfully applied": it rolls back on a synchronous throw or a rejected callback promise, so the periodic poll can heal a lost table write instead of deduplicating it forever.Why now: on 5.2 today, a renewal whose key lands on a worker before the matching cert makes that worker serve the self-signed certificate until the next 5-minute re-read (#1394); production nodes served the wrong certificate for days with nothing retrying (#2382). The fails-on-base run below reproduces exactly that on unmodified
v5.2.For the human reviewer
security/keys.ts, no extra main commit ported. Five main-sidekeys.tscommits sit between the v5.2 branch point and Publish TLS state transactionally so a failed rebuild never downgrades below last-good #2384; only two of them reach the squash commit's parent (Canonicalize watch paths so a Windows 8.3 short path cannot abort the process #2309, watch-path canonicalization with polling fallback; and the IPv6 node-identity change togetHost). Neither is a dependency of Publish TLS state transactionally so a failed rebuild never downgrades below last-good #2384's hunks, and neither is pulled in:loadAndWatch: main spells the reload closure(path, stats?)because Canonicalize watch paths so a Windows 8.3 short path cannot abort the process #2309 calls it without stats from its canonicalized watcher. v5.2 still registersloadFiledirectly on the chokidar watcher, so the closure keeps v5.2's(path, stats)signature and takes Publish TLS state transactionally so a failed rebuild never downgrades below last-good #2384's latch-rollback body unchanged. Thestats ?? statSync(path)fallback was already on v5.2.publishTrustedAuthorities(caCerts.values())right after the zero-certificate retry guard, where main at that time had nothing. Publish TLS state transactionally so a failed rebuild never downgrades below last-good #2384 replaces that spot with the candidate-map swap, so the publish line now sits aftercaCertsis rebuilt fromcandidateCAs, identical to where main'skeys.tsholds it today. Look hardest here: publishing before the swap would hand the revocation checker the previous pass's CA set.keys.tsis byte-identical to the squash commit's hunks; the remaining diff against main's currentkeys.tsis exactly the two unrelated commits above plus later main work.integration-tests.ymlhas the same job shape as main, soHARPER_TEST_REQUIRE_FILE_WATCHERS: '1'is added to the integration job env, makingcert-key-reloadfail instead of skip when a file change never reacheshdb_certificateon Linux runners. Pushing a workflow file needs a token withworkflowscope, which is whycherry-pick-patch.ymlcould not open this PR itself.cherry-pick-patch.ymlrebuildscherry-pick/<rel>/pr-<N>branches from the release branch on PR pushes and at merge, without rerere; expect thekeys.tsresolution to need re-applying if that workflow touches this branch. Test this branch by dispatchingintegration-tests.yml/unit-test.ymlwith--ref cherry-pick/v5.2/pr-2384, not through the cherry-pick workflow.liveReload=false,getReplicationCert) now arm the self-rearming failure-retry timer too, leaving an orphan rebuild chain per boot/restart while a record keeps failing (log noise and periodic scans, no serving impact); (b) deleting the last leaf record trips the zero-certificate guard, so its hostnames and default stay served whilescheduleRebuildloops, which contradicts the new DESIGN.md sentence (the default half of that was already true before Publish TLS state transactionally so a failed rebuild never downgrades below last-good #2384); (c) a leaf record with an unparseable PEM is pushed tofailedThisPassby both loops; (d) the tie regression test'swaitForpredicate is already true at baseline; (e) newsinon/rewireuses inkeys.test.jsagainst AGENTS.md. None of these is changed on this branch.Changes
security/keys.ts: pass-local candidate maps and default; per-record build failure retains the record's live entries and default candidacy; retained mTLS contexts are rebuilt against the current CA set and dropped if that rebuild fails (unless nothing else is servable); failure-retry backoff and signature-throttled logging; a pass-level throw after.readysettled leaves live state untouched;loadAndWatchlatch rollback; the cert watcher returns itscertificateTable.putso a rejected write unlatches and is retried by the poll.DESIGN.md: the transactional-publication note, verbatim from main..github/workflows/integration-tests.ymland.gitignore(ignorenode_modulesunder anyintegrationTests/fixture, which the new test's fixture dir creates on a dev-mode boot): applied cleanly.Verification
Node v26.2.0 locally,
npm ciagainst v5.2's lockfile,dist/rebuilt. All runs under a privateHOME(v5.2'smocha.inithas no per-PID system-database isolation).origin/v5.2(78e528445) built in a side tree with the newcert-key-reload.test.tscopied in,HARPER_TEST_REQUIRE_FILE_WATCHERS=1 npm run test:integration -- integrationTests/security/cert-key-reload.test.ts: 0 pass / 1 fail,a worker stopped serving the last-good cert while the renewed cert had no matching key(served serial8353068887037099000, the self-signed default, instead of3001), withError applying TLS ... key values mismatchlogged by both HTTP workers. That is the A cert renewal whose table write misses the rebuild window downgrades TLS to the self-signed cert and never retries #2382 fallback the task describes.npx mocha unitTests/security/keys.test.js: 70 passing, including the new retain-last-good, whole-pass failure and latch rollback suites.npm run test:unit:main,npm run test:unit:resources: 4579 passing / 2 failing and 1817 passing / 0 failing. The twotest:unit:mainfailures are this box, not the diff:gitCredentialssees the dispatch daemon's ownGIT_CONFIG_GLOBAL/GIT_EDITORin the spawn env, andtokenAuthentication rsa_keyspasses when re-run alone (load flake). Neither file importssecurity/keys.ts.npm run build,npm run lint:required,prettier --checkon the changed files: clean.test:integration:allis left to this PR's CI (the local box cannot run the Ollama suite); the changed code is exercised end-to-end bycert-key-reloadabove.Refs #2384
Refs #2382
Complexity: medium
Signed: Claude Fable 5.1
🤖 Generated with Claude Code
https://claude.ai/code/session_01LSCvLgqVLi4PMaUr6ZgeP4
Origin — the dispatch brief this PR was written from
Backport harper PR #2384 ('Publish TLS state transactionally so a failed rebuild never downgrades below last-good', squash-merged to main as 1e4c163, closes #2382) onto the v5.2 release branch. Open a PR from branch cherry-pick/v5.2/pr-2384 against base v5.2, titled 'cherry-pick: Publish TLS state transactionally so a failed rebuild never downgrades below last-good (#2384 → v5.2)', following the shape of the earlier manual backport PR #2827. On 5.2, a cert renewal whose key reaches a worker before the matching cert currently makes that worker fall back to the self-signed cert until the 5-minute re-read (#1394) heals it; with #2384 the worker keeps serving the last-good pair instead.
Acceptance
Dispatch: task
harper-2384-backport-v52· queued by kris-session · ran by claude/fable/high · worker kzyp-xps-1Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-kimi,cursor-muse; rounds=1; full=1 @ 2fb147d
Human-Review-Need: 4 (decisions: retain-over-revoke-on-replace, retain-past-expiry, backoff-before-ready, error-level-summary, ci-fail-not-skip, backport-with-known-gaps) @ 2fb147d