Skip to content

Fix MQTT UDS listener exporting an empty TLS certificate list to a fronting proxy - #1999

Merged
kriszyp merged 10 commits into
mainfrom
kris/fix-mqtt-uds-cert-export
Jul 30, 2026
Merged

Fix MQTT UDS listener exporting an empty TLS certificate list to a fronting proxy#1999
kriszyp merged 10 commits into
mainfrom
kris/fix-mqtt-uds-cert-export

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 29, 2026

Copy link
Copy Markdown
Member

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 empty certificates: 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 before updateTLS() ran, rejecting .ready — was falsified on the affected node: the yaml existed (written twice, ~2 h apart, both with valid pid/tid/port and empty certificates:), which requires updateTLS() to have completed both times, and neither predicted error signature appears anywhere in hdb.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_certificate at a time exactly bracketing the second empty write. An engine migration replaces databases.system.hdb_certificate with a brand-new table object (the hazard documented by schemaMigrationFragility.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:

  • subscribedTable instance tracking re-subscribes whenever databases.system.hdb_certificate is a different object than the one subscribed to (with teardown of the orphaned subscription).
  • The zero-certificates retry refuses to publish/resolve a completed pass that produced an empty context map (the exact artifact observed), retrying on the debounce instead.
  • The original readiness guard (don't touch/resolve when the table isn't loaded yet) remains as hardening for the boot-order race it was written for — that race is real even if it wasn't this incident's mechanism, and .ready must 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.1 patch cherry-pick:

What remains here is exactly the customer-facing fix, which applies to v5.1 as-is.

Where to look

  • security/keys.tscreateTLSSelector'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 on liveReload so getReplicationCert'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 and hdb_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):

  • [Codex, blocker, fixed] First pass at the retry branch called resolve() on .ready before 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.
  • [Gemini, blocker, fixed] Guard checked databases.system === undefined, missing databases.system present but .hdb_certificate not yet attached. Now checks databases.system?.hdb_certificate.
  • [Codex, significant, fixed] usageType: 'mqtt' compat break for uses: ['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):

  • [gemini-code-assist, fixed] Boolean subscribe flag → subscribedTable instance tracking (which, per the corrected root cause above, turned out to be the load-bearing fix).
  • [gemini-code-assist, fixed] Subscribe gated on liveReload so transient selectors don't pin scheduleRebuild onto the table's subscriber list.
  • [human review, fixed] Denylist → allowlist for the 'server' fallback — now in feat(tls): give MQTT's raw-socket listener its own TLS usage type (split from #1999) #2003.
  • [human review, fixed] Race test rewritten from Sinon fake timers/rewire internals to the real module + real timers + waitFor().

Round 4 (experimental Grok leg on the post-round-3 diff):

  • [fixed] Race test leaked a permanent liveTLSRebuilders registration (liveReload defaulted true); now passes false.
  • [fixed] node:assert/strict → plain node:assert per repo convention.
  • [fixed] Two coverage gaps: recovery tests now assert secureContexts is actually non-empty, and the system-present-but-table-missing case is covered.

Round 5 (PR review — Devin-Holland/Claude verification pass + dispatch fix):

  • [fixed] Silent retry → latched breadcrumb log (tlsSelectorWaitedForSystemDb).
  • [fixed] subscribedTable left pointing at a table whose subscribe() rejected → reset in the .catch.
  • [fixed] One unparseable certificate row aborted the whole pass before any context was built → per-row guard in the CA-collection loop.
  • [fixed] A completed pass resolving zero certificates still published the empty list (the observed artifact) → retry instead, gated on liveReload because getReplicationCert()'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).
  • [review's root-cause challenge, confirmed] The mechanism mismatch flagged in that review was verified on the affected node and led to the corrected root cause above.

Known caveats (deliberately not changed further)

  • Cipher/@SECLEVEL can 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.
  • The retry never gives up. If the table genuinely never loads (or legitimately never yields a cert), .ready stays 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.
  • Zero-certs retry vs. a truly cert-less listener type: a live selector for a type with genuinely zero matching certs now retries on the debounce instead of resolving empty. In practice a node always has at least its self-signed default cert once bootstrap completes; the transient (liveReload=false) path is exempt.

Testing

  • unitTests/security/keys.test.js: 54/54 passing, including the exact-race tests (both missing-system and 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, oxlint clean 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.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread security/keys.ts Outdated
Comment thread security/keys.ts Outdated
@kriszyp
kriszyp marked this pull request as ready for review July 29, 2026 22:29
@kriszyp
kriszyp requested review from a-ronjohnson and harper-joseph and removed request for a-ronjohnson July 29, 2026 22:33
@kriszyp kriszyp added the patch label Jul 30, 2026
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Patch cherry-pick: conflict

Cherry-pick onto v5.1 produced conflicts on commit(s): bcf9cb9055467e7bf9f9c7bb857a2b6d6e300a7c ec80172a5684c30de1caf757a3baf0ab47b4d527 051c75271f09369ffb2bc393f3cdbcca2b609027 ffb161d71b11d0d70935d20f8c56c56e19d7f29d a306d0bef79621a95a8bf98b50b15b227954bf84 a7baa4349254b150c53aa83dd52452da9e76ae77 13ed49f936d991646da4bc14e3b3359bc2fdb12f

The conflict markers are committed on branch cherry-pick/v5.1/pr-1999.
A pull request has been opened to land this patch: #2005

@Devin-Holland Devin-Holland left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts restored to origin/main and 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, the hdb_certificate-not-attached variant, and expected 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 --check and oxlint clean on all five changed files.
  • The recovery path is end-to-end real. updateTLS() fires server.secureContextsListeners before resolve(), onSocket() pushes writeMetadata onto 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:

  1. 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 — presenting keys/fullchain.pem for 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.
  2. 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.yaml exist 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), or Error 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 caCerts map (:996) and returns without repopulating it. Harmless in practice — every updateTLS() pass clears and rebuilds it before use, and the live availableCAs reference is never read anywhere — but hoisting the two guards above the .clear() calls would make the retry path non-destructive by construction.
  • Cipher/@SECLEVEL freezing 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, .ready stays pending forever. For onSocket() that's benign, but listenOnPortsBun() (server/threads/threadServer.js:426) awaits config.tlsSelector.ready inside its per-port for loop, so a permanently-pending .ready there stops the loop, skips every later port, and never posts CHILD_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.

Comment thread security/keys.ts
Comment thread security/keys.ts Outdated
Comment thread server/threads/threadServer.js Outdated
Comment thread unitTests/resources/auditLog.test.js Outdated

@Devin-Holland Devin-Holland left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread security/keys.ts Outdated
Comment thread security/keys.ts
@kriszyp

kriszyp commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Addressed 4 of the 6 open review threads (commit a9b808c):

  • Silent retry breadcrumb (security/keys.ts:1018): added a latched logger.warn when the selector starts waiting for system.hdb_certificate to load.
  • subscribedTable stranded on subscribe failure (:1038): the activeSubscription.catch now clears subscribedTable so a failed subscribe doesn't permanently strand the retry.
  • Unparseable cert aborts the whole pass (:1045): the CA-collection loop now guards X509Certificate parsing per row and skips a bad record instead of throwing before any context is built.
  • Empty secureContexts after a completed pass (:1002's sibling guard, ~:1173): extended the "not ready" treatment — if a completed pass resolves zero certificates, retry instead of publishing the empty list. Gated on liveReload: I found that applying this unconditionally would have hung getReplicationCert()'s one-shot existence check forever on a fresh install (the bootstrap flow that creates the first replication cert depends on that call resolving empty/falsy). Added tests for both the retry-then-recover path and the transient-selector-still-resolves-empty path.

Also hoisted the two readiness guards above secureContexts.clear()/caCerts.clear() per the "minor" note, so the retry path never wipes previously-published state.

Ran npm run build, typecheck:fast (same 4 pre-existing unrelated errors as main), prettier --check, oxlint, and unitTests/security/** (390 passing) + unitTests/security/keys.test.js (53 passing, up from 50) + unitTests/server/udsMirror.test.js (35 passing) — all clean.

Not addressed (leaving open — see the two remaining threads and my question on the PR):

  • Splitting the usageType/cipher-scoring half and the auditLog.test.js commit into separate PRs to unblock the v5.1 cherry-pick.
  • The open question on whether the diagnosed race actually matches the reported customer artifact.

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

kriszyp added a commit that referenced this pull request Jul 30, 2026
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>
@kriszyp

kriszyp commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

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 hdb_certificate brackets the second empty write exactly, engine migration replaces the table object (the schemaMigrationFragility "F4" hazard), and the bulk copy bypasses the subscribe/event path. The round-3 subscribedTable instance-tracking fix covers that mechanism directly, and the new zero-certs retry refuses to publish the empty artifact regardless of which path produces it. PR description rewritten accordingly (see "Root cause (corrected)").

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

Comment thread security/keys.ts
Comment thread security/keys.ts Outdated
kriszyp and others added 8 commits July 30, 2026 11:26
…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>
@kriszyp
kriszyp force-pushed the kris/fix-mqtt-uds-cert-export branch from 9ee2274 to a306d0b Compare July 30, 2026 17:33

@Devin-Holland Devin-Holland left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.js52 passing; unitTests/security/** + unitTests/server/udsMirror.test.js426 passing, 0 failing; build 0 errors; oxlint and prettier --check clean.
  • Both new warn lines fire on the real paths (saw waiting for system.hdb_certificate to load and resolved zero certificates; retrying in the run output), and the unparseable-record guard logs Skipping unparseable certificate record and continues rather than aborting.

Two things I want to credit before the finding:

  • The getReplicationCert catch is a good save on my suggestion. I proposed the zero-certs retry without checking the bootstrap path; gating it on liveReload is 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 === 0 is the right condition for "don't publish an empty list", but it isn't quite "no TLS available": defaultContext is assigned outside the per-hostname loop, so a cert whose hostnames resolves to [] (no SAN entries survive parsing and no CN — hostnamesFromCert returns [] there) sets a usable default context while leaving the map empty. That selector's .ready now never resolves, where before it resolved and served via the default. Narrow enough that I'd only note it, but && !defaultContext would 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.
@kriszyp

kriszyp commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

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 subscribeCalls=0, searchCalls=0 exactly as you saw, but after firing the rebuilder I get subscribeCalls=1, searchCalls=2. So the swap-detection does run when updateTLS is re-entered, and your harness was right to be suspected on that one leg. Your trigger analysis stands, though — the idle result is the true and important part: nothing spontaneously re-enters updateTLS after a swap when contexts are non-empty.

The regression test you asked for is in (re-subscribes to the new table instance...): real-module trigger chain (cert-table write → old subscription fires → debounced rebuild → swap detected), sentinel hostname so the assertion is selector-specific rather than racing the cross-selector counter burst (in the full suite, live selectors leaked by earlier tests also react to the swap — likely what confused both our probes at first: my first version asserted subscribeCalls === 1 and the burst jumped straight past it). Bisected against the boolean-flag version: fails at the re-subscribe assertion, passes with instance-tracking.

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 secureContexts.size === 0 && !defaultContext per your note — a no-hostname cert's default-only state resolves and serves rather than retrying forever.

— Claude (Fable 5), for Kris

Comment thread security/keys.ts Outdated
…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.
@kriszyp
kriszyp merged commit b992970 into main Jul 30, 2026
43 checks passed
@kriszyp
kriszyp deleted the kris/fix-mqtt-uds-cert-export branch July 30, 2026 18:34

@DavidCockerill DavidCockerill left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread security/keys.ts
}
}
if (liveReload && secureContexts.size === 0 && !defaultContextSetThisPass) {
// The not-loaded-yet guard above only covers the table object being absent, not the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

kriszyp added a commit that referenced this pull request Jul 30, 2026
… 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>
kriszyp added a commit that referenced this pull request Jul 30, 2026
…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
github-actions Bot pushed a commit that referenced this pull request Jul 30, 2026
…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
github-actions Bot pushed a commit that referenced this pull request Jul 30, 2026
…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
kriszyp added a commit that referenced this pull request Jul 30, 2026
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>
kriszyp added a commit that referenced this pull request Jul 30, 2026
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>
kriszyp added a commit that referenced this pull request Jul 30, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MQTT UDS socket exports an empty TLS certificate list to a fronting proxy

3 participants