Fix MQTT UDS listener exporting an empty TLS certificate list to a fronting proxy - #1999
Conversation
|
Reviewed; no blockers found. |
There was a problem hiding this comment.
Code Review
This pull request updates the TLS selector and cipher relevance logic to support legacy 'server'-tagged certificates for non-HTTP listeners (like MQTT) while excluding 'operations-api'. It also addresses a boot-order race condition where the system database is not yet loaded when a listener is initialized, ensuring the .ready promise remains pending instead of resolving early or throwing. The review feedback highlights two critical issues in the subscription logic: a potential memory leak in transient selectors (when liveReload is false) and a loss of subscription during database resets. To resolve these, the reviewer suggests tracking the actual subscribed table instance and checking the liveReload flag before subscribing.
Patch cherry-pick: conflictCherry-pick onto The conflict markers are committed on branch |
Devin-Holland
left a comment
There was a problem hiding this comment.
Verified
Ran this locally on a fresh worktree of kris/fix-mqtt-uds-cert-export (node 24, clean npm ci + tsc --project tsconfig.build.json):
- Bisect confirms the tests guard the fix. With
security/keys.tsrestored toorigin/mainand everything else on the branch, exactly the three new tests fail — with the predicted messages (.ready must stay pending while the system database has not loaded, thehdb_certificate-not-attached variant, andexpected undefined to equal 'DEFAULT@SECLEVEL=0'for the allowlist test) — and 47 others pass. Restored: 50/50. unitTests/security/**+unitTests/server/udsMirror.test.js: 422 passing, 0 failing.unitTests/resources/auditLog.test.js: 3/3 runs green, the reworked assertion completing in 51–69 ms against its 500 ms-per-write budget.prettier --checkandoxlintclean on all five changed files.- The recovery path is end-to-end real.
updateTLS()firesserver.secureContextsListenersbeforeresolve(),onSocket()pusheswriteMetadataonto that list, and host-manager's_createSocketWatcher()watches the sockets dir (500 ms debounce) and re-reads entries — so a yaml written late by the retry does propagate into Symphony's routes without a restart.
The readiness guard, the subscribedTable-identity fix, the liveReload gating, and the previous-subscription teardown all look right to me, and the reasoning in the comments is the kind of thing I wish more of this file had.
One thing I'd want confirmed before we tell the customer it's fixed
I can't reconcile the diagnosed mechanism with the reported artifact, and they look mutually exclusive.
The diagnosis is that databases?.system.hdb_certificate.subscribe(...) throws before updateTLS() runs, so .ready rejects. But writeUdsMetadata() has exactly three call sites, and every one of them is a writeMetadata closure reached only via SNICallback.ready.then(...) or server.secureContextsListeners — and secureContextsListeners is invoked from exactly one place, updateTLS() itself (security/keys.ts:1166). So on the throwing path neither fires, and no yaml is written at all. The reported artifact is a yaml that exists with an empty certificates: list, which means updateTLS() ran to completion with an empty secureContexts — the guard this PR adds would never have been reached.
Two more things point the same way:
- host-manager's
_buildCertConfig()has carried this comment since the original Symphony commit (db606a7, 2026-06-01): "…or ships an empty certificate list (Harper's MQTT secure socket, whose secureContexts aren't populated when the metadata is written)". That fallback — presentingkeys/fullchain.pemfor every SNI — is exactly the customer symptom, and it's been the documented expected state of the MQTT socket for two months, on every node, not a race that landed on 2 of 3. - If the yaml were missing,
_readSocketEntries()would return no entry for-8883.yaml, so there'd be no UDS route for 8883 at all — a different symptom than "wrong cert".
An alternative that produces the observed artifact exactly: updateTLS() completes, but every certificate is skipped by the inner catch at security/keys.ts:1143 — most plausibly getPrivateKeyByName() throwing ENOENT on the readFileSync fallback when privateKeys isn't populated on that thread yet. That empties secureContexts without throwing, the yaml gets written empty, and it never self-heals: the cert-table subscription is registered but omitCurrent: true means only a future cert-table change retriggers it, and private keys arriving isn't one. This PR's guard doesn't cover that.
Cheap discriminators on an affected node, both of which you have access to and I don't:
- Does
sockets/<worker>-8883.yamlexist at all on an affected worker (vs. missing)? - Does the worker log carry
Unhandled promise rejection … Cannot read properties of undefined (reading 'hdb_certificate')at boot (the diagnosed path), orError applying TLS for <cert name> …at error level (the alternative)?
If it's the latter, this PR is still a good hardening of a real latent bug — but the customer's issue would still be open. The inline suggestion on the retry branch is partly aimed at making that question answerable from a log next time instead of from a live cluster.
Suggested scope split for the v5.1 patch
origin/v5.1 has no getEffectiveTlsCiphers / ciphersCandidateRelevant / resolveEffectiveTlsCiphers at all — its onSocket() still reads tlsConfig.ciphers ?? tlsConfig[0]?.ciphers inline. That's why the cherry-pick conflicts are so wide (a single 150-line HEAD vs. branch block in keys.ts, plus threadServer.js and both test files).
Meanwhile the usageType half is a no-op for every stock deployment: Harper only ever writes uses: [], ['operations-api'], or ['replication'] (security/keys.ts:274, :455, :464, :704, :725), and for all three of those, both ciphersCandidateRelevant() and the quality scoring give identical results for type = 'server' and type = 'mqtt'. A 'server'- or 'mqtt'-tagged cert can only come from hand-written tls config.
So the half that's causing the v5.1 conflicts is the half that changes nothing for anyone today, and the half the customer needs (the readiness guard + subscription lifecycle) applies to v5.1 as-is — v5.1 has the identical pre-updateTLS() subscribe() call. Landing them as two PRs would make the patch cherry-pick nearly clean and shrink what has to be justified for a customer hotfix. Same for the auditLog.test.js change, which is one of the three conflicting commits and unrelated to TLS.
Minor
- The retry branch clears the module-level shared
caCertsmap (:996) and returns without repopulating it. Harmless in practice — everyupdateTLS()pass clears and rebuilds it before use, and the liveavailableCAsreference is never read anywhere — but hoisting the two guards above the.clear()calls would make the retry path non-destructive by construction. - Cipher/
@SECLEVELfreezing against a not-yet-loaded cert set (your second known caveat) gets slightly more reachable now that the selector recovers instead of staying dead: pre-fix a stranded selector never served anything, so a stale cipher string was moot. Agreed it's out of scope, but it may deserve its own issue rather than only a PR-body note. - The retry never gives up, so if the table genuinely never loads,
.readystays pending forever. ForonSocket()that's benign, butlistenOnPortsBun()(server/threads/threadServer.js:426)awaitsconfig.tlsSelector.readyinside its per-portforloop, so a permanently-pending.readythere stops the loop, skips every later port, and never postsCHILD_STARTED. Pre-fix that was a caught-and-logged per-port failure. Normal boot resolves within a tick or two of the debounce, so this is the pathological tail only — but it's another reason the retry deserves a log line.
Reviewed by Claude (Opus 5) at Devin's request — flagged as customer-critical, so I leaned on empirical checks over reading. Nice work on the round-by-round writeup; it made this much faster to audit.
Devin-Holland
left a comment
There was a problem hiding this comment.
Two more, both terse. Also a correction to my earlier body: I cited host-manager's _buildCertConfig fallback as evidence the exported list was empty — that fallback also fires for a populated list with no SNI match, and deliberately for the keyPath === privkeyPath case, so it doesn't distinguish the two. The rest of that comment stands: _readSocketEntries enumerates *-8883.yaml, so any UDS route at all requires the yaml to exist, which requires updateTLS() to have completed.
Claude (Opus 5)
|
Addressed 4 of the 6 open review threads (commit a9b808c):
Also hoisted the two readiness guards above Ran Not addressed (leaving open — see the two remaining threads and my question on the PR):
Independent pre-push review (codex, opposite-family) did not complete — the machine has heavy contention from other concurrent dispatch reviews right now and it didn't finish inside the available time budget after 3 attempts (~30 min). Flagging this so it isn't mistaken for having passed; happy to re-run once load clears. — Claude (Opus 5), dispatch fix-harper-1999 |
A raw-socket TLS listener (onSocket, used by MQTT's network.securePort) always resolved its TLS usage type as the generic 'server', so a certificate tagged for a specific listener type (uses: ['mqtt']) never got its intended priority over a generic node certificate. onSocket() now accepts a usageType option (falling back to 'server') and MQTT passes 'mqtt', for both certificate quality scoring and effective cipher/@SECLEVEL resolution. Backward compat: certs tagged uses: ['server'] targeted MQTT under the old default, so 'server' earns the legacy generic-use credit for an explicit allowlist (LEGACY_SERVER_FALLBACK_TYPES, currently just 'mqtt'). It is an allowlist rather than "everything except operations-api" because every other existing type (operations-api, replication, ...) has always had its own dedicated identity and must not newly start accepting a ['server']-tagged record's ciphers. Split from #1999 per review feedback: this half is a no-op for stock deployments (Harper only writes uses: [] / ['operations-api'] / ['replication']) and is what made the v5.1 patch cherry-pick of the customer-facing readiness fix conflict, since v5.1 predates getEffectiveTlsCiphers. Co-Authored-By: Claude Opus <noreply@anthropic.com>
|
Follow-up on the two remaining threads, per Kris's rulings (commit 9ee2274 + two new PRs): Root cause — corrected. The live investigation on the affected node answered the review's discriminators: the yaml exists (written twice, both times valid-but-empty) and neither predicted log signature appears anywhere in the log history. That falsifies the original subscribe-throws diagnosis. The evidence points to the cert-table subscription being orphaned by a table-object swap during the node's live v4.x→v5.1 upgrade — the LMDB→RocksDB migration of Scope split — done.
I resolved both threads; the inline reply endpoint was blocked by an existing pending review draft on this PR, so the replies live here instead. — Claude (Opus 5), dispatch fix-harper-1999 |
…sn't loaded yet createTLSSelector's only rebuild trigger (the hdb_certificate subscription) was registered inside the same synchronous pass that first read the cert table. A listener whose selector initializes before this thread's system database is loaded (selector creation doesn't control that ordering) hit a TypeError there, rejecting the selector permanently with an empty cert list and no path to recover except an unrelated private-key reload elsewhere. MQTT's raw-socket listener (server.socket()/onSocket) is exactly such a case: it creates its own per-worker selector independent of the HTTP listeners, so it can lose this race while they don't. Retry on the same debounce used for cert-table changes instead, and register the subscription lazily once the system database is actually available. Also fixes onSocket always resolving certs as usage type 'server' instead of the caller's actual type (mqtt.ts now passes usageType: 'mqtt'), so a certificate tagged uses: mqtt gets its intended quality bonus. Fixes #1998
…pe 'server' legacy match Cross-model review (Gemini + Codex) on the previous commit found three real issues, all addressed here: - The "system db not loaded yet" retry branch called resolve() before real certs were available. server/threads/threadServer.js's Bun listener path awaits .ready exactly once and treats a resolved promise with no defaultContext as "no TLS configured" — so this would have silently started a secure listener (MQTT's securePort, or any other raw-socket caller) as plaintext for the life of the process, which is worse than the bug being fixed. The retry branch no longer resolves; .ready now stays pending until a real pass completes. - The readiness guard checked `databases.system === undefined`, missing the case where `databases.system` exists but `.hdb_certificate` isn't attached to it yet as a property. Now checks `databases.system?.hdb_certificate`. - Passing usageType 'mqtt' instead of the old hardcoded 'server' changes cipher/cert quality matching: a cert tagged uses: ['server'] to target MQTT would silently stop matching. Extended the existing 'https' legacy-generic-use precedent to also cover 'server', scoped to exclude operations-api (which has always had its own dedicated identity and never defaulted to 'server' — a follow-up domain review pass flagged the unscoped version as newly, unintentionally affecting cipher/quality selection on that listener). The new unit test was strengthened to assert .ready's pending/resolved timing (not just non-throw), and a new resolveEffectiveTlsCiphers test pins the operations-api scoping. Both regressions were bisected: each fails against the code before its fix and passes after.
- Track the actual subscribed hdb_certificate table instance
(subscribedTable) instead of a boolean, and gate the subscribe on
liveReload. Fixes two issues gemini-code-assist caught: resetDatabases()
(copy_db, ITC restart handling) swaps in a new table instance that a
boolean flag would never re-subscribe to, permanently losing live
cert-table updates after a reset; and a transient, single-use selector
(getReplicationCert, liveReload=false) was pinning scheduleRebuild onto
the long-lived table's subscriber list forever on every call.
- Replace the 'server' legacy-fallback denylist (type !== 'operations-api')
with an explicit allowlist (LEGACY_SERVER_FALLBACK_TYPES, currently just
'mqtt'). kriszyp pointed out the denylist would also apply to
createTLSSelector('replication', ...) and any other non-raw-socket
caller, none of which ever defaulted to 'server' either — only mqtt is
actually migrating away from onSocket()'s old unconditional default.
Added a negative replication case to the existing cipher-relevance test.
- Rewrote the system-db-not-loaded-yet race test to drop Sinon fake timers
and rewire internals (liveTLSRebuilders, TLS_REBUILD_DEBOUNCE_MS) per
kriszyp's comment that new tests should use plain node:assert/strict
against the real module and the repo's waitFor() condition-wait helper
instead. Same coverage (.ready stays pending, then resolves once the
table loads), now against real timers.
- Use plain node:assert + assert.strictEqual instead of node:assert/strict:
.oxlintrc.json explicitly bans the /strict import by name (with a message
pointing at this exact convention) — oxlint only enforces it against ES
import declarations, not the require() call this file used, so it wasn't
actually failing CI, but the rule's own intent applies regardless.
- Fix a real leak Grok found: the race test's createTLSSelector('mqtt')
call defaulted to liveReload=true, permanently registering scheduleRebuild
in the module-level liveTLSRebuilders set with no cleanup (unlike the
sibling live-reload-registration test, which snapshots/restores it). Now
passes liveReload=false — irrelevant to what this race is actually
testing, and avoids leaking into every later test's private-key-reload
rebuilds for the rest of the suite.
- Strengthened both race tests to assert secureContexts actually becomes
non-empty after recovery, not just that .ready settles — a "fix" that
resolved early with the table still empty would have passed silently
otherwise.
- Added the second half of the production guard's own coverage: a test for
databases.system existing but hdb_certificate not yet attached to it,
alongside the existing whole-system-missing case.
check log after writes and prune asserted events.length > 2 after polling up to 200ms, but polling longer never helped: transactionBroadcast.ts coalesces 'committed' bursts landing in the same turn into one notify pass, and the subscribe() listener in Table.ts intentionally delivers only the latest value per id from that pass. When all four writes in this test land in one turn (as they reliably do on a fast/idle Node 22 CI runner), only 2 of the 4 events ever get delivered — they're dropped, not delayed, so the previous fixed-then-polling deflake (ed3ec88) could never fix it. Wait for each write's own notify drain before issuing the next write, so every commit gets its own turn and its own event. This makes delivery deterministic (4/4) instead of racing the coalescing behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
createTLSSelector already re-subscribes when resetDatabases() (copy_db, ITC restart) swaps in a new hdb_certificate table instance, but it never ended the subscription on the OLD table first. A node that cycles through repeated resets therefore accumulates one dead Subscription (and its scheduleRebuild closure) per reset instead of holding just the current one — flagged by an independent pre-push review of this branch. Track the in-flight subscribe() promise and call .end() on the previous one once the new subscription is established; also attach a rejection handler so a failed subscribe attempt surfaces via logger.warn instead of an unhandled rejection. Attempted a dedicated regression test (swap the live table, assert the old Subscription's .end() is called), but this suite mounts a real Harper instance and something in that environment reverts a manually swapped `databases.system.hdb_certificate` within ~1.5s independent of this code path, making the test unreliable to construct with the time available — noted for follow-up rather than shipped flaky. Verified via the full existing security/keys.test.js (50/50) and resources/auditLog.test.js suites instead, with no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ace fix - Add a breadcrumb log (latched per server) when the selector retries because system.hdb_certificate isn't loaded yet, so the race can be diagnosed from a log instead of a live cluster. - Reset subscribedTable when the hdb_certificate subscribe() call rejects, so a failed subscription doesn't permanently strand the selector's cert-table update retries. - Guard the CA-collection loop's X509Certificate parse per row so one unparseable certificate record can't abort the whole pass before any secure context is built. - Extend the "not ready" treatment to a completed pass that resolves zero certificates (e.g. a private key not yet available on this thread) — gated on liveReload so transient, single-use selectors (getReplicationCert) still resolve empty immediately, since the cert-bootstrap flow depends on that to know it must create one. - Hoist the not-loaded-yet guards above secureContexts/caCerts.clear() so the retry path never wipes previously-published state before failing to replace it. Co-Authored-By: Claude Opus <noreply@anthropic.com>
Split out per review feedback to unblock the v5.1 patch cherry-pick: - The usageType/'server'-allowlist half (server/mqtt.ts, server/threads/threadServer.js onSocket, LEGACY_SERVER_FALLBACK_TYPES in keys.ts, and its cipher-scoping test) moves to #2003. It is a no-op for stock deployments and was the source of the wide v5.1 conflict, since v5.1 predates getEffectiveTlsCiphers. - The unrelated auditLog.test.js deflake moves to #2002. What remains here is exactly the customer-facing fix: createTLSSelector's readiness guard/retry, the hdb_certificate subscription lifecycle (instance tracking, teardown, failure reset), the per-row cert parse guard, and the zero-certificates retry — plus their tests. Co-Authored-By: Claude Opus <noreply@anthropic.com>
9ee2274 to
a306d0b
Compare
Devin-Holland
left a comment
There was a problem hiding this comment.
Re-review of a306d0bef
All four suggestions landed faithfully, plus the guard hoisting. Re-verified from scratch on a clean npm ci + tsc --project tsconfig.build.json worktree (node 24):
unitTests/security/keys.test.js— 52 passing;unitTests/security/**+unitTests/server/udsMirror.test.js— 426 passing, 0 failing; build 0 errors;oxlintandprettier --checkclean.- Both new warn lines fire on the real paths (saw
waiting for system.hdb_certificate to loadandresolved zero certificates; retryingin the run output), and the unparseable-record guard logsSkipping unparseable certificate recordand continues rather than aborting.
Two things I want to credit before the finding:
- The
getReplicationCertcatch is a good save on my suggestion. I proposed the zero-certs retry without checking the bootstrap path; gating it onliveReloadis right, and the negative test for it is the part I'd have missed. - Thanks for running the discriminators. "The yaml exists, written twice, both valid-but-empty, and neither predicted log signature appears anywhere" is exactly the check that settles it, and rewriting the root-cause section rather than leaving the original narrative in place is the right call.
The corrected root cause is now the untested one — and I could not get it to work
The PR now names the mechanism as the cert-table subscription being orphaned by a table-object swap during the v4.x→v5.1 engine migration, with the subscribedTable instance-tracking as the fix. That comparison lives inside updateTLS, so it can only run when something calls updateTLS — and after a swap orphans the subscription, the candidate triggers are: the (now-dead) subscription, the retry timer (only pending if the last pass returned early), and rebuildLiveTLSContexts(), which has exactly one caller: handlePrivateKeyReload, gated on previous !== private_key.
I tried to demonstrate the recovery path and could not. Probe, against this branch:
const selector = keys.createTLSSelector('mqtt', undefined, true);
await selector.initialize(pseudoServer); // contexts = 4, healthy baseline
let subscribeCalls = 0, searchCalls = 0;
databases.system.hdb_certificate = { // model the engine-migration swap
subscribe: async () => { subscribeCalls++; return { end() {} }; },
search: () => { searchCalls++; return realTable.search([]); },
};
await sleep(4000); // 2.6× TLS_REBUILD_DEBOUNCE_MS
// → subscribeCalls=0 searchCalls=0
for (const r of keys.__get__('liveTLSRebuilders')) r(); // set size is 1 — this selector's
await sleep(3000);
// → subscribeCalls=0 searchCalls=0 (still nothing)Idle for 4 s: no re-subscribe, no re-read — expected, since nothing triggers. But firing the selector's own registered rebuilder directly also produced nothing, and I don't have an explanation for that. It may well be my harness (I did not chase it further), but I couldn't show updateTLS re-running after a swap by any route.
So the mechanism the PR now credits for fixing the incident is the one part with no test, and my attempt to exercise it by hand didn't show it working. That upgrades the existing "missing coverage" thread from a nice-to-have to the thing I'd want green before this ships to a customer. Please take the probe above as a starting point rather than a verdict — if it passes for you with subscribeCalls=1, my harness was wrong and a regression test pinning it down closes this out.
Worth noting what is not at risk: the customer-visible protection doesn't depend on this. The zero-certs retry refuses to publish the empty certificates: list whatever produced it, and I verified that independently. In the reported incident the pass resolved empty, so the retry timer stays live and would keep re-entering updateTLS — which is plausibly why the fix works there even if swap detection never fires on its own.
The general case is the gap: a swap that happens while secureContexts is non-empty leaves no pending timer and a dead subscription, so the selector would keep serving its current contexts and miss every later cert-table update — a renewal that only arrives via the table wouldn't land until a key-file change or a restart. Different symptom from this incident, same orphaned-subscription cause. Probably its own issue rather than more scope here.
Smaller
- The zero-certs warn isn't latched the way the system-db one is, so a genuinely stuck listener logs every 1.5 s — ~57k lines/day. Already covered by the open bot thread; just confirming the arithmetic.
secureContexts.size === 0is the right condition for "don't publish an empty list", but it isn't quite "no TLS available":defaultContextis assigned outside the per-hostname loop, so a cert whosehostnamesresolves to[](no SAN entries survive parsing and no CN —hostnamesFromCertreturns[]there) sets a usable default context while leaving the map empty. That selector's.readynow never resolves, where before it resolved and served via the default. Narrow enough that I'd only note it, but&& !defaultContextwould make the guard say what it means.- Noting for the record that the codex leg didn't complete, since you flagged it — no objection, just visible.
Reviewed by Claude (Opus 5). Verification commands and the probe are reproducible on a306d0bef.
…dings The re-review probe questioned whether the swap-detection in updateTLS ever actually runs. Verified both halves: - The probe's rebuilder-fired leg reproduces as WORKING here (re-subscribe and re-read both fire once updateTLS is triggered) — the reviewer's harness result on that leg did not reproduce. - The idle-leg observation is correct and expected: nothing spontaneously re-enters updateTLS after a swap when contexts are non-empty. In the incident, contexts were empty, so the zero-certs retry timer supplies the trigger; the general non-empty case is a pre-existing gap being filed as its own issue. New regression test drives the swap path end-to-end through real module surfaces: healthy baseline -> table object swapped (modeling the engine migration / resetDatabases replacement) -> rebuild triggered by a real cert-table write -> asserts re-subscribe and re-read through the NEW table instance, using a sentinel hostname so the assertion is specific to this selector (live selectors leaked by earlier tests in the file also react to the swap; cross-selector counters alone would race). Bisected: fails at the re-subscribe assertion when the instance-tracking is reverted to a boolean flag, passes with the fix. Also from the re-review: - Latch the zero-certs retry warn like the system-db one (it retries every 1.5s indefinitely; unlatched this was ~57k lines/day on a stuck listener), and reset both latches on a successful pass so a later recurrence logs once per episode rather than once per process. - Guard the zero-certs retry with `!defaultContext`: an empty hostname map is not "no TLS available" — a cert with no usable SANs/CN builds no per-hostname entry but still sets a serviceable default context, and that listener must resolve and serve via the default rather than retry forever.
|
Response to the re-review (commit a7baa43): Ran your probe — the rebuilder-fired leg works here. Same shape as yours (healthy baseline → swap to a counting mock → idle 4s → fire the registered rebuilder → wait past the debounce): idle phase shows The regression test you asked for is in ( The general case is now #2004 — swap while contexts are non-empty leaves a dead subscription and no pending timer, so cert-table updates are missed until a key reload or restart. Filed with your trigger analysis; agreed it's its own issue, not more scope here. Smaller items, both in: the zero-certs warn is latched (same pattern as the system-db one, resets on a successful pass), and the guard is now — Claude (Fable 5), for Kris |
…defaultContext Review caught that the `!defaultContext` guard only protected the very first pass: defaultContext is a closure variable that is deliberately never reset (a transient zero-cert pass keeps serving the prior default while retrying), so after any successful pass it stays truthy forever. A later rebuild that transiently resolved zero certificates (private key not yet synced, row missing mid-copy) would skip the retry, fall through, and publish an empty certificates list — reintroducing the #1998 symptom on the live-rebuild path, which a long-running node is far more likely to hit than the boot race. Track whether THIS pass produced a default context (set alongside the assignment in the cert loop) and gate the retry on that instead. The no-hostname-cert edge the guard exists for still resolves and serves via its default. New regression test: healthy baseline, then a cert-table write triggers a rebuild whose search transiently returns [] — asserts the pass takes the retry path (observed via the warn latch), never publishes an empty list, and republishes non-empty once certs are visible again. Bisected: against the reverted guard it fails with the transient rebuild never taking the retry path.
DavidCockerill
left a comment
There was a problem hiding this comment.
Approving at 13ed49f9. I focused on the one thing that was still open rather than re-treading the eleven resolved threads.
The !defaultContext regression is genuinely fixed and I bisect-confirmed it: defaultContextSetThisPass is scoped inside updateTLS so it resets every pass, the guard now fires on a later empty rebuild, and the new test fails against the reverted guard. 54 passing at head. Credit to @claude for catching that one first.
Worth saying about the writeup: falsifying your own original diagnosis with live evidence — yaml written twice with valid pid/tid but empty certificates:, and neither predicted error signature in hdb.log back to May — and then correcting the PR body rather than quietly shipping the fix, is the part that made this reviewable. A fix aimed at a superseded root cause is the usual way these go wrong.
One non-blocking note in the thread on comment volume; the fix commit pushed two blocks further past house style.
Cherry-pick for v5.1: per-commit replay is red on the reverted intermediates, but the squashed net diff is a single mechanical conflict — worth landing as a squash rather than fighting the replay.
— Reviewed by DAIvid (Claude Opus 5)
| } | ||
| } | ||
| if (liveReload && secureContexts.size === 0 && !defaultContextSetThisPass) { | ||
| // The not-loaded-yet guard above only covers the table object being absent, not the |
There was a problem hiding this comment.
Non-blocking, house style: 1–3 lines stating only the non-obvious constraint — narration, history and design rationale belong in the PR body, which already carries all of it here. 13ed49f9 added three more lines to this block.
Two worst at this head:
:1166–:1189 — 24 comment lines over a 9-line guard (21 before this commit):
// Retry instead of publishing the empty `certificates:` list. Per-pass flag, not the
// never-reset `defaultContext`. Excludes liveReload=false: getReplicationCert()'s
// bootstrap check depends on an empty resolution meaning "no cert yet".:984–:1000 — 17 comment lines over a 9-line guard:
// Must not resolve: Bun's listenOnPortsBun() awaits `.ready` once and treats a
// resolved no-context promise as "no TLS", permanently starting plaintext.Same treatment for the new 6-line note at :1016 → // Per-pass: defaultContext is never reset, so it can't answer "did this pass find anything?". How far to take it is your call.
… selector (#1999) Cherry-pick of #1999 onto v5.1, with the conflict markers from the automated cherry-pick resolved. The bot cherry-picked all seven commits from the original PR branch, including the ones a later commit narrowed back out, which left nested conflict markers in four files -- two of which (`server/threads/threadServer.js`, `unitTests/resources/auditLog.test.js`) are not part of the merged #1999 at all. This replaces that state with the net merged #1999 change applied to v5.1: `security/keys.ts` and `unitTests/security/keys.test.js` only, matching main commit b992970 exactly. One genuine conflict remained: the hunk carrying the new zero-certificates retry also carried main's `getEffectiveTlsCiphers` cipher-mismatch warning as trailing context. v5.1 predates `getEffectiveTlsCiphers`, so only the retry and the warn-latch reset are taken. Verified on v5.1: `unitTests/security/keys.test.js` 37 passing (including the missing-`system` and missing-`hdb_certificate` race tests, the zero-certs retry/transient-resolve pair, and the table-swap resubscribe regression test), `unitTests/security/**` 365 passing, `npm run build`, `tsc --noEmit`, `oxlint --deny-warnings`, and `prettier --check` all clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rver when a plain TCP port was also registered
onSocket() built the secure (TLS) server into the function-scoped
`socketServer` binding, and the UDS metadata-write closure captured that
binding. A caller registering BOTH ports in one server.socket() call —
which MQTT does by default ({ port: 1883, securePort: 8883 }) — then
reached the plain-TCP branch, which reassigned `socketServer` to the
1883 server. Every secure-port metadata write (the boot-time
.ready.then() and every later rebuild's listener fan-out) therefore read
`secureContexts` off the plain TCP server (undefined) and published an
empty `certificates:` list, deterministically, on every worker of every
node with both MQTT ports enabled. A fronting SNI-routing proxy
(Symphony) that selects certificates from that metadata then falls back
to the node certificate for every custom-domain SNI on 8883 — the
customer-visible symptom that survived #1999/#2005.
Give the secure server its own const and capture that in the closure;
`socketServer` remains the branch-shared return value.
The selector, its certificate map, and the publish/retry logic were
always healthy (verified live: in-memory maps fully populated while the
disk yaml stayed empty), which is why the selector-focused fixes and
tests in #1999/#2005/#2008 could not catch this: every existing test
drove createTLSSelector directly with a pseudo-server. The new
regression test goes through server.socket() with both ports — the real
wiring — and asserts the written yaml carries certificates; it fails
against the unfixed code at exactly that assertion.
Root-caused via deterministic local reproduction on v5.1.25 (fresh
default-config install: all <n>-8883.yaml empty, <n>-9926.yaml
populated; instrumented dist showed the 8883 write firing with
secureContexts=undefined; one-line dist patch produced fully populated
metadata on reboot).
Fixes #1998
…rver when a plain TCP port was also registered
onSocket() built the secure (TLS) server into the function-scoped
`socketServer` binding, and the UDS metadata-write closure captured that
binding. A caller registering BOTH ports in one server.socket() call —
which MQTT does by default ({ port: 1883, securePort: 8883 }) — then
reached the plain-TCP branch, which reassigned `socketServer` to the
1883 server. Every secure-port metadata write (the boot-time
.ready.then() and every later rebuild's listener fan-out) therefore read
`secureContexts` off the plain TCP server (undefined) and published an
empty `certificates:` list, deterministically, on every worker of every
node with both MQTT ports enabled. A fronting SNI-routing proxy
(Symphony) that selects certificates from that metadata then falls back
to the node certificate for every custom-domain SNI on 8883 — the
customer-visible symptom that survived #1999/#2005.
Give the secure server its own const and capture that in the closure;
`socketServer` remains the branch-shared return value.
The selector, its certificate map, and the publish/retry logic were
always healthy (verified live: in-memory maps fully populated while the
disk yaml stayed empty), which is why the selector-focused fixes and
tests in #1999/#2005/#2008 could not catch this: every existing test
drove createTLSSelector directly with a pseudo-server. The new
regression test goes through server.socket() with both ports — the real
wiring — and asserts the written yaml carries certificates; it fails
against the unfixed code at exactly that assertion.
Root-caused via deterministic local reproduction on v5.1.25 (fresh
default-config install: all <n>-8883.yaml empty, <n>-9926.yaml
populated; instrumented dist showed the 8883 write firing with
secureContexts=undefined; one-line dist patch produced fully populated
metadata on reboot).
Fixes #1998
…rver when a plain TCP port was also registered
onSocket() built the secure (TLS) server into the function-scoped
`socketServer` binding, and the UDS metadata-write closure captured that
binding. A caller registering BOTH ports in one server.socket() call —
which MQTT does by default ({ port: 1883, securePort: 8883 }) — then
reached the plain-TCP branch, which reassigned `socketServer` to the
1883 server. Every secure-port metadata write (the boot-time
.ready.then() and every later rebuild's listener fan-out) therefore read
`secureContexts` off the plain TCP server (undefined) and published an
empty `certificates:` list, deterministically, on every worker of every
node with both MQTT ports enabled. A fronting SNI-routing proxy
(Symphony) that selects certificates from that metadata then falls back
to the node certificate for every custom-domain SNI on 8883 — the
customer-visible symptom that survived #1999/#2005.
Give the secure server its own const and capture that in the closure;
`socketServer` remains the branch-shared return value.
The selector, its certificate map, and the publish/retry logic were
always healthy (verified live: in-memory maps fully populated while the
disk yaml stayed empty), which is why the selector-focused fixes and
tests in #1999/#2005/#2008 could not catch this: every existing test
drove createTLSSelector directly with a pseudo-server. The new
regression test goes through server.socket() with both ports — the real
wiring — and asserts the written yaml carries certificates; it fails
against the unfixed code at exactly that assertion.
Root-caused via deterministic local reproduction on v5.1.25 (fresh
default-config install: all <n>-8883.yaml empty, <n>-9926.yaml
populated; instrumented dist showed the 8883 write firing with
secureContexts=undefined; one-line dist patch produced fully populated
metadata on reboot).
Fixes #1998
A raw-socket TLS listener (onSocket, used by MQTT's network.securePort) always resolved its TLS usage type as the generic 'server', so a certificate tagged for a specific listener type (uses: ['mqtt']) never got its intended priority over a generic node certificate. onSocket() now accepts a usageType option (falling back to 'server') and MQTT passes 'mqtt', for both certificate quality scoring and effective cipher/@SECLEVEL resolution. Backward compat: certs tagged uses: ['server'] targeted MQTT under the old default, so 'server' earns the legacy generic-use credit for an explicit allowlist (LEGACY_SERVER_FALLBACK_TYPES, currently just 'mqtt'). It is an allowlist rather than "everything except operations-api" because every other existing type (operations-api, replication, ...) has always had its own dedicated identity and must not newly start accepting a ['server']-tagged record's ciphers. Split from #1999 per review feedback: this half is a no-op for stock deployments (Harper only writes uses: [] / ['operations-api'] / ['replication']) and is what made the v5.1 patch cherry-pick of the customer-facing readiness fix conflict, since v5.1 predates getEffectiveTlsCiphers. Co-Authored-By: Claude Opus <noreply@anthropic.com>
A raw-socket TLS listener (onSocket, used by MQTT's network.securePort) always resolved its TLS usage type as the generic 'server', so a certificate tagged for a specific listener type (uses: ['mqtt']) never got its intended priority over a generic node certificate. onSocket() now accepts a usageType option (falling back to 'server') and MQTT passes 'mqtt', for both certificate quality scoring and effective cipher/@SECLEVEL resolution. Backward compat: certs tagged uses: ['server'] targeted MQTT under the old default, so 'server' earns the legacy generic-use credit for an explicit allowlist (LEGACY_SERVER_FALLBACK_TYPES, currently just 'mqtt'). It is an allowlist rather than "everything except operations-api" because every other existing type (operations-api, replication, ...) has always had its own dedicated identity and must not newly start accepting a ['server']-tagged record's ciphers. Split from #1999 per review feedback: this half is a no-op for stock deployments (Harper only writes uses: [] / ['operations-api'] / ['replication']) and is what made the v5.1 patch cherry-pick of the customer-facing readiness fix conflict, since v5.1 predates getEffectiveTlsCiphers. Co-Authored-By: Claude Opus <noreply@anthropic.com>
…rver when a plain TCP port was also registered
onSocket() built the secure (TLS) server into the function-scoped
`socketServer` binding, and the UDS metadata-write closure captured that
binding. A caller registering BOTH ports in one server.socket() call —
which MQTT does by default ({ port: 1883, securePort: 8883 }) — then
reached the plain-TCP branch, which reassigned `socketServer` to the
1883 server. Every secure-port metadata write (the boot-time
.ready.then() and every later rebuild's listener fan-out) therefore read
`secureContexts` off the plain TCP server (undefined) and published an
empty `certificates:` list, deterministically, on every worker of every
node with both MQTT ports enabled. A fronting SNI-routing proxy
(Symphony) that selects certificates from that metadata then falls back
to the node certificate for every custom-domain SNI on 8883 — the
customer-visible symptom that survived #1999/#2005.
Give the secure server its own const and capture that in the closure;
`socketServer` remains the branch-shared return value.
The selector, its certificate map, and the publish/retry logic were
always healthy (verified live: in-memory maps fully populated while the
disk yaml stayed empty), which is why the selector-focused fixes and
tests in #1999/#2005/#2008 could not catch this: every existing test
drove createTLSSelector directly with a pseudo-server. The new
regression test goes through server.socket() with both ports — the real
wiring — and asserts the written yaml carries certificates; it fails
against the unfixed code at exactly that assertion.
Root-caused via deterministic local reproduction on v5.1.25 (fresh
default-config install: all <n>-8883.yaml empty, <n>-9926.yaml
populated; instrumented dist showed the 8883 write firing with
secureContexts=undefined; one-line dist patch produced fully populated
metadata on reboot).
Fixes #1998
Summary
Fixes #1998. A raw-socket TLS listener's certificate selector (used by MQTT's
network.securePort) could permanently end up with an empty certificate map, so a fronting proxy (Symphony) that terminates TLS and reads Harper's per-socket exported cert metadata to route by SNI saw an emptycertificates:list and served the instance's own node certificate for every connection to that port.Confirmed live on 2 of 3 nodes of an affected production cluster: every worker's exported MQTT UDS metadata had an empty
certificates:list, unchanged since container boot, while HTTP-based listeners on the same workers were fully populated.Root cause (corrected 2026-07-30 after live investigation)
The original diagnosis —
hdb_certificate.subscribe()throwing beforeupdateTLS()ran, rejecting.ready— was falsified on the affected node: the yaml existed (written twice, ~2 h apart, both with valid pid/tid/port and emptycertificates:), which requiresupdateTLS()to have completed both times, and neither predicted error signature appears anywhere inhdb.log(retention back to May).What the evidence supports instead: the node was mid live v4.x→v5.1 upgrade, and the table-by-table LMDB→RocksDB migration migrated
hdb_certificateat a time exactly bracketing the second empty write. An engine migration replacesdatabases.system.hdb_certificatewith a brand-new table object (the hazard documented byschemaMigrationFragility.test.js"F4"), and the bulk row-copy bypasses the subscribe/event path entirely. The selector's cert-table subscription had bound to the old object, so it was orphaned by the table swap: two completed-but-empty passes during the transition, zero exceptions, and total silence afterwards even with 7 valid cert rows in the table.The fixes here cover that mechanism directly:
subscribedTableinstance tracking re-subscribes wheneverdatabases.system.hdb_certificateis a different object than the one subscribed to (with teardown of the orphaned subscription)..readymust stay pending rather than let Bun's listener path start plaintext.Scope (split per review)
Review feedback flagged that two parts of the original PR were blocking a near-clean
v5.1patch cherry-pick:usageType/'server'-allowlist half (MQTT getting its own TLS usage type) → moved to feat(tls): give MQTT's raw-socket listener its own TLS usage type (split from #1999) #2003. No-op for stock deployments;v5.1predatesgetEffectiveTlsCiphers, which is where the wide conflict came from.auditLog.test.jsdeflake → moved to test: deflake audit log subscription-event assertion (split from #1999) #2002.What remains here is exactly the customer-facing fix, which applies to
v5.1as-is.Where to look
security/keys.ts—createTLSSelector's readiness guard and retry logic, the cert-table subscription lifecycle (instance tracking, teardown, failure reset), the per-row cert parse guard, and the zero-certificates retry (gated onliveReloadsogetReplicationCert's one-shot existence check — which the cert bootstrap flow relies on resolving empty — can't hang).unitTests/security/keys.test.js— race tests (both the whole-system-missing andhdb_certificate-not-attached cases), zero-certs retry/transient-resolve tests, unparseable-record test.Review history (five rounds — each surfaced real, fixed issues)
Round 1 (Gemini + Codex, cross-model-review skill, thorough mode):
resolve()on.readybefore real certs were available — Bun's listener path would have silently started a secure listener as plaintext, permanently. Fixed: the retry branch no longer resolves.databases.system === undefined, missingdatabases.systempresent but.hdb_certificatenot yet attached. Now checksdatabases.system?.hdb_certificate.usageType: 'mqtt'compat break foruses: ['server']certs — now in feat(tls): give MQTT's raw-socket listener its own TLS usage type (split from #1999) #2003.Round 2 (Harper-domain pass):
'server'legacy-match scoping (operations-api leak) — now in #2003.Round 3 (PR review — gemini-code-assist + human review):
subscribedTableinstance tracking (which, per the corrected root cause above, turned out to be the load-bearing fix).liveReloadso transient selectors don't pinscheduleRebuildonto the table's subscriber list.'server'fallback — now in feat(tls): give MQTT's raw-socket listener its own TLS usage type (split from #1999) #2003.waitFor().Round 4 (experimental Grok leg on the post-round-3 diff):
liveTLSRebuildersregistration (liveReloaddefaulted true); now passesfalse.node:assert/strict→ plainnode:assertper repo convention.secureContextsis actually non-empty, and thesystem-present-but-table-missing case is covered.Round 5 (PR review — Devin-Holland/Claude verification pass + dispatch fix):
tlsSelectorWaitedForSystemDb).subscribedTableleft pointing at a table whosesubscribe()rejected → reset in the.catch.certificaterow aborted the whole pass before any context was built → per-row guard in the CA-collection loop.liveReloadbecausegetReplicationCert()'s bootstrap existence check must keep resolving empty immediately (an unconditional retry would hang first-boot cert generation — caught by tracing callers, covered by a dedicated test).Known caveats (deliberately not changed further)
@SECLEVELcan freeze against a "not-yet-loaded" state if the boot race hits.getEffectiveTlsCiphers()is still called once, synchronously, at socket-construction time. If the race occurs, the listener's cipher policy is resolved against an empty cert/CA set and won't change after the selector recovers (the code warns on a later mismatch but doesn't re-create the listener). Pre-existing; independently flagged by Codex (R1), Grok (R4), and the R5 review (which also noted recovery makes it slightly more reachable — pre-fix a stranded selector never served anything). A real fix needs deferred cipher resolution; separate change..readystays pending forever; on Bun,listenOnPortsBun()awaits it in its per-port loop, so the pathological tail stalls later ports. Flagged in R5; the breadcrumb log makes this diagnosable. Accepted as the safer failure mode vs. publishing empty/plaintext.liveReload=false) path is exempt.Testing
unitTests/security/keys.test.js: 54/54 passing, including the exact-race tests (both missing-systemand missing-table variants), the zero-certs retry + transient-resolve pair (boot-time and post-success transient rebuilds — the latter bisected against the disarmed-guard version), the table-swap resubscribe regression test (bisected against the boolean-flag version), the unparseable-record skip, and the transient-selector leak fix.npm run build,npm run typecheck:fast(no new errors — 4 pre-existing, unrelated on main),prettier --check,oxlintclean on changed files.unitTests/security/**: 390 passing;unitTests/server/udsMirror.test.js: 35 passing.Generated by Claude (Sonnet 5 / Opus 5), with Kris driving the live investigation and review.