Fix MQTT secure-port UDS metadata publishing an empty certificate list (SNI proxy served the node cert on 8883) - #2010
Conversation
…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
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Code Review
This pull request resolves a bug (#1998) where registering both a plain TCP port and a secure port in a single socket call caused the shared socketServer binding to be reassigned to the plain TCP server, leading to empty certificates in the secure-port UDS metadata. The fix introduces a local secureSocketServer constant to correctly preserve the secure socket server instance within the metadata-write closure. A regression test has also been added to verify this behavior. There are no review comments, so I have no feedback to provide.
|
Reviewed; no blockers found. |
- Destructure readdirSync once instead of re-importing node:fs per use. - Unlink any yaml a crashed prior run left for this port before polling — a stale populated file would false-pass even if the current write regressed. - Note the (pre-existing, shared-with-sibling-tests) liveReload selector registration that onSocket exposes no teardown for.
Devin-Holland
left a comment
There was a problem hiding this comment.
Verified — no blockers
Independently verified
I re-derived the diagnosis from the code rather than taking the write-up on trust, and it holds — this is the mechanism, and it explains every observation that didn't add up before.
The reassignment really does outlive the closure. On the shipped v5.1.25 tag, onSocket: let socketServer (:492) → secure branch assigns it (:497) → const writeMetadata = () => writeUdsMetadata(yamlPath, options.securePort, socketServer) (:542) → secureContextsListeners.push(writeMetadata) (:544) → if (options.port) { … socketServer = createSocketServer(…) } (:547, :549). The closure captures the binding, the binding is rebound to the plain-TCP server, and both write paths (the .ready.then() microtask and every later listener fan-out) run after onSocket returns. secureContexts is undefined on that object, writeUdsMetadata gates on contexts?.size > 0, so it emits a bare certificates:.
The precondition is the shipped default, not a config edge case. static/defaultConfig.yaml ships mqtt.network.port: 1883 and securePort: 8883, so every default install passes both, hits the reassignment, and publishes an empty 8883 list on every worker. That's the "deterministically, fleet-wide" claim, confirmed.
And it explains why the HTTP mirror was always fine — which is the part I most wanted an independent structural reason for, rather than an absence of evidence. In http.ts the same closure shape is safe on both counts: const server = (httpServers[port] = …) (:529) is a const, and the port it closes over (:600) is a parameter of getHTTPServer(port, secure, options), never rebound. Two files, same pattern, one has a mutable binding and one doesn't, and the symptom tracked exactly that.
Class sweep — this was the only instance. Since a finding like this is usually a sample of a class, I scripted a hunt across threadServer.js and http.ts for the same shape (a function-scope let, reassigned, captured by a closure that outlives the function). Four other candidates surfaced and all resolve to false positives with reasons: threadServer.js listen_on is declared inside the loop body (per-iteration) and passed to .listen() synchronously; port at :43 is a different scope from the for (let port in …) loop variables captured at :430/:451/:465 (also per-iteration); listening is a local const shadowing the module-level let; http.ts's socket/response/body are request-scoped with synchronous consumers. socketServer was the real one.
Fix completeness. writeMetadata was the only deferred closure in onSocket, so converting it plus every synchronous use to secureSocketServer is the whole surface. SERVERS[securePort] gets the TLS server and SERVERS[port] the plain one, unchanged; socketServer = secureSocketServer preserves the return value for the securePort-only case, so return semantics are identical either way — and the MQTT caller doesn't consume the return anyway.
The test is the right test. Driving global.server.socket() with both ports and asserting the written yaml carries BEGIN CERTIFICATE targets the artifact the proxy actually consumes, which is exactly the seam every prior round missed by driving createTLSSelector with a pseudo-server. It runs in CI and passes (✔ … 103ms), so it's genuinely gating and not just present.
The one CI failure isn't yours
Unit Test (Node.js v22) is red, and it's worth being explicit that it has nothing to do with this change: 1333 passing, 1 failing, and the failure is Audit log > check log after writes and prune — AssertionError: Should have at least a couple of update events. That's the pre-existing flake, and main still carries the old assertion wording, so it will keep reddening every PR until #2002 lands. Landing #2002 is the unblock; nothing to do here. Your new test passed in that same job.
Marked COMMENT rather than approving only because this is still a draft — flip it to ready and I'll approve on the re-read.
On my own miss
Worth recording, since I reviewed #1999 twice and pushed hard on the root cause. I got as far as proving the yaml had been written and therefore updateTLS() must have completed with an empty map — that part was right, and it's why the original subscribe-throws diagnosis was wrong. But I then enumerated "exactly two ways to complete a pass with an empty map" and both were selector-side: every cert failing the per-cert build, or the table having no rows. The third way — the map is perfectly healthy and writeUdsMetadata is simply handed the wrong object — never occurred to me, even though I had read writeUdsMetadata and grep-traced all three of its call sites. I verified that socketServer was passed and never asked what it was bound to at call time. Reading a call site isn't reading the value.
Still open after this (not blockers)
- The empty-map-with-a-default-context case that #2008 targeted is now unaddressed with it closed. Narrow (needs a cert whose hostnames resolve to
[]— no usable SANs and no CN) and a different trigger from the customer's, but with this fix the write reaches the right object and still finds an empty map, so it would still publish an empty list. Worth an issue rather than scope here. - #2004 (swap while
secureContextsis non-empty leaves a dead subscription with no armed retry) is unrelated to this and still stands.
No code changes requested — the absence of inline threads here is deliberate, not an omission. Really nice piece of debugging; the mapSize=3, defaultSet=true / ctxSize=undefined instrumentation pair is what turns this from a plausible story into a proven one.
Reviewed by Claude (Opus 5). Verification commands reproducible against the v5.1.25 tag and both branches.
|
CI status, since two consecutive red runs will read as "this PR is broken" at a glance — it isn't. Both failures on Three things that put it outside this PR:
#2002 is the unblock — it's still open, and Nothing needed on this PR from my side; flip it out of draft and I'll approve. Claude (Opus 5) |
…ion) Rules out any timing pressure on later tests in the same mocha process from the unclosed 21887/28887/UDS-mirror servers, per Devin's note on the v22 audit-log flake analysis.
|
Re the patch-ci conflict report: expected — v5.1 drifted in this region (ciphers line, no dedicatedListener). The hand-resolved backport is #2011 (green, approved). |
|
Applied your suggestion in 14a0235 (both branches) — the finally block now closes every server the call created before restoring the registries, so the perturbation theory is ruled out structurally rather than by counterfactual. Flipping out of draft per your note; expecting the v22 job may still redden on the audit-log flake until #2002 lands. |
…adata-closure # Conflicts: # server/threads/threadServer.js
Summary
Fixes #1998's surviving symptom (still broken on 5.1.25 with #1999/#2005). Root cause is not in the TLS selector at all — it's a closure-over-reassigned-variable in
onSocket()(server/threads/threadServer.js):port: 1883andsecurePort: 8883in oneserver.socket()call (the default config).let socketServer, and the UDS metadata-write closure captures that binding.socketServerto the 1883 server, which has nosecureContexts..ready.then()microtask (which runs afteronSocketreturns, i.e. after the reassignment) and every later rebuild's listener fan-out — readssecureContexts === undefinedoff the wrong server and publishes an emptycertificates:list.Symphony selects the SNI certificate from that metadata, so 8883 fell back to the node certificate for every custom-domain SNI — deterministically, on every worker of every node with both MQTT ports enabled, fleet-wide (host-manager's code even carries a comment treating empty MQTT metadata as known behavior).
Fix: the secure server gets its own
const secureSocketServer, and the closure captures that;socketServerremains the branch-shared return value. Two-variable change plus comments.Evidence
<n>-8883.yaml42–50 bytes (emptycertificates:) while<n>-9926.yamlfully populated, first boot, every worker. Instrumented dist showed the 8883 selector's pass completing healthy (mapSize=3, defaultSet=true) while its metadata write fired withctxSize=undefined.us-lax-1.prod.ibm, read-only CDP): in-memory 8883 selector maps fully populated (all four customer domains) while disk yamls stayed empty since boot — the selector was never unhealthy; only the write's target object was wrong.Why four rounds of review missed it
Every selector-level fix and test (#1999, #2005, #2008) drove
createTLSSelectordirectly with a pseudo-server. The bug lives inonSocket's wiring and only manifests when onesocket()call registers both ports. The new regression test goes throughserver.socket()with both ports — the real wiring — and asserts the written yaml carries certificates (the artifact the proxy actually consumes). Bisected: fails against unfixed code at exactly that assertion, passes with the fix.Relationship to in-flight work
patchlabel applied; the touched region is near-identical on v5.1 (modulo the ciphers line), so the cherry-pick should be clean or trivially resolvable. I'll follow the auto-cherry-pick and hand-resolve if needed.Testing
unitTests/apiTests/mqtt-test.mjs(both-ports registration → written yaml contains certificates), bisect-verified.retained record with upsert,mTLS subscribe) reproduce identically on pristine main — pre-existing local-env flake (known: local unit suites are unreliable on this box), CI is the arbiter.npm run build, prettier, oxlint clean.Generated by Claude (Fable 5); investigation and live-prod evidence with Kris. Local repro artifacts referenced in the FDE Slack thread.