Skip to content

fix(tls): keep rebuilding the SNI selector when a pass produces only a default context (empty per-hostname map) - #2008

Closed
Devin-Holland wants to merge 1 commit into
mainfrom
fix-tls-empty-sni-map-retry
Closed

fix(tls): keep rebuilding the SNI selector when a pass produces only a default context (empty per-hostname map)#2008
Devin-Holland wants to merge 1 commit into
mainfrom
fix-tls-empty-sni-map-retry

Conversation

@Devin-Holland

@Devin-Holland Devin-Holland commented Jul 30, 2026

Copy link
Copy Markdown
Member

Summary

Follow-up to #1999 / #2005. On an affected production cluster, port 8883 (MQTT-over-TLS, terminated by the Symphony proxy) still serves the node certificate for custom-domain SNIs on harper-pro 5.1.25 (which contains #1999). Verified live, read-only:

  • every worker's sockets/<n>-8883.yaml is 42 bytes (empty certificates:) while the <n>-9926 mirror is the full ~82 KB;
  • on the wire, an MQTT-TLS handshake for a custom-domain SNI on 8883 returns the node cert; the same SNI on 443 returns the correct leaf;
  • none of Fix MQTT UDS listener exporting an empty TLS certificate list to a fronting proxy #1999's new breadcrumbs (resolved zero certificates, waiting for system.hdb_certificate) ever fired (checked hdb.log and docker logs).

The gap #1999 left

#1999 fixed the not-loaded and table-swap cases, but not a pass that ends with a default context set but an empty per-hostname secureContexts map. The guard secureContexts.size === 0 && !defaultContextSetThisPass treats "a default was set" as success and resolves-and-stops, so on the boot pass that produced the empty map, no retry was armed.

What this changes (security/keys.ts, createTLSSelector)

Re-check on the debounce whenever the per-hostname map is empty, regardless of whether a default was produced:

  • no default → stay pending as before (nothing to serve; resolving would let Bun's path start plaintext);
  • default set → resolve .ready (serve via the default, no hang) but keep the scheduled rebuild armed so a later pass republishes the per-hostname list.

Warn-latch resets are split so the empty-map-with-default fall-through doesn't re-warn on each re-check.

⚠️ Open question — read before relying on this (from @kriszyp's live investigation)

Live CDP inspection of a running node shows the in-memory 8883 selector maps ARE populated (all custom domains, write-listener registered), while the on-disk yaml is still the 42-byte boot artifact — and calling writeUdsMetadata against the live map produces a correct yaml. So a rebuild did run and populate the map after the empty publish, yet the listener fan-out that should have rewritten the yaml didn't take effect. There may be a second defect between map-population and republish that this PR does not address — this PR arms and relies on that same fan-out.

Discriminating test before treating this as the fix: on a plain-5.1.25 node, one benign hdb_certificate write, then check the sockets/<n>-8883.yaml mtime + content:

  • heals to the full list → the fan-out works, boot ordering was the whole story, this PR closes the window;
  • stays 42 bytes → a second defect between rebuild and republish that this PR does not touch — find it before shipping.

A lead worth checking there: in the raw-socket path (threads/threadServer.js onSocket) the write-listener is pushed onto secureContextsListeners after SNICallback.initialize(...), so the first populating pass's fan-out can fire against a listener set that doesn't yet include writeMetadata.

Notes

Generated by Claude (Opus 4.8), investigation + fix driven with Devin.

…a default context

A raw-socket TLS listener's certificate selector (MQTT's `network.securePort`)
could complete a pass that set a default context but built an empty per-hostname
`secureContexts` map, then resolve and stop. The `!defaultContextSetThisPass`
guard treated "a default was set" as success, so the retry never re-armed. When
the real per-domain certs later landed via a bulk/initial-sync path that does not
fire the selector's `hdb_certificate` subscription (the same event bypass #1999
documented for table swaps), nothing re-triggered a rebuild: the listener kept
exporting an empty `certificates:` list to its UDS metadata until restart, and a
fronting proxy that routes by that list (Symphony on 8883) served the node
certificate for every custom-domain SNI.

The selector now re-checks on the debounce whenever the per-hostname map is empty,
regardless of whether a default was produced. If a default exists it still
resolves `.ready` (the listener serves via that default immediately, no plaintext
hang), but the scheduled rebuild stays armed so the per-domain certs are picked up
and the exported metadata is rewritten once they load. The zero-cert warn latch is
only cleared on a populated pass so the empty-map-with-default fall-through does
not re-warn on every re-check.

Follow-up to #1999 / #2005 (which fixed the not-loaded and table-swap cases but
left this default-set variant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 30, 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 in security/keys.ts to continuously rebuild and retry when the per-hostname certificate map is empty, even if a default certificate context is set. This ensures SNI-routing proxies receive the per-hostname certificates once they load. A unit test was added to verify this behavior. The reviewer noted that in a stable 'default-only' setup, this change triggers an infinite loop of scheduled rebuilds, causing unnecessary CPU overhead, and provided a code suggestion to optimize this by checking for the existence of hostname-eligible certificates.

Comment thread security/keys.ts
Comment on lines 1186 to 1192
if (server && !server.tlsSelectorWarnedZeroCerts) {
server.tlsSelectorWarnedZeroCerts = true;
logger.warn?.(
`TLS selector for the '${type}' listener resolved zero certificates; retrying every ${TLS_REBUILD_DEBOUNCE_MS}ms`
`TLS selector for the '${type}' listener resolved an empty per-hostname certificate map; retrying every ${TLS_REBUILD_DEBOUNCE_MS}ms`
);
}
scheduleRebuild();

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.

medium

For a stable, healthy "default-only" configuration (where only a default certificate with no hostnames is configured), secureContexts.size will always be 0 and defaultContextSetThisPass will be true. Under the current implementation, this triggers an infinite loop of scheduleRebuild() every 1.5 seconds, causing unnecessary background CPU overhead and periodic database searches on an idle system.

We can optimize this by checking if there are any other certificates with hostnames in the database. If none exist, we are in a stable default-only state and can safely skip scheduling a rebuild.

						let hasHostnameCerts = false;
						for (const cert of databases.system.hdb_certificate.search([])) {
							if (cert.is_authority) continue;
							try {
								const certParsed = new X509Certificate(cert.certificate);
								let hostnames = cert.hostnames ?? hostnamesFromCert(certParsed);
								if (!Array.isArray(hostnames)) hostnames = [hostnames];
								if (hostnames.some(h => h)) {
									hasHostnameCerts = true;
									break;
								}
							} catch {}
						}
						if (!defaultContextSetThisPass || hasHostnameCerts) {
							if (server && !server.tlsSelectorWarnedZeroCerts) {
								server.tlsSelectorWarnedZeroCerts = true;
								logger.warn?.(
									"TLS selector for the '" + type + "' listener resolved an empty per-hostname certificate map; retrying every " + TLS_REBUILD_DEBOUNCE_MS + "ms"
								);
							}
							scheduleRebuild();
						}

@kriszyp

kriszyp commented Jul 30, 2026

Copy link
Copy Markdown
Member

Root cause found — and it's upstream of this PR's mechanism. Reproduced locally on v5.1.25 (fresh default-config install, deterministic on first boot) and fix-verified both directions.

The actual bug is in server/threads/threadServer.js onSocket(), not in createTLSSelector. MQTT registers with both port: 1883 and securePort: 8883 in one server.socket() call. The securePort branch creates the TLS server into the function-scoped let socketServer, and the UDS-metadata write closure captures that binding:

const writeMetadata = () => httpComponent.writeUdsMetadata(yamlPath, options.securePort, socketServer);

Then the plain-TCP branch below reassigns socketServer to the 1883 server (socketServer = createSocketServer(...)), which has no secureContexts. By the time any write fires — the .ready.then() microtask at boot, or any later rebuild's listener fan-out — the closure reads secureContexts === undefined off the wrong server and writeUdsMetadata publishes the empty certificates: list. Instrumented boot shows exactly this: the 8883 selector's pass completes healthy (mapSize=3, defaultSet=true) while the 8883 write fires with ctxSize=undefined. Capturing the secure server in its own const before the TCP branch → all <n>-8883.yaml files fully populated on reboot.

This explains the full evidence set: it's deterministic fleet-wide (any node with both MQTT ports enabled — the default — has always exported empty 8883 metadata, hence the host-manager code comment treating that as known), prod's in-memory maps are populated while the disk yaml stays empty, and none of #1999's breadcrumbs ever fired (the selector was never unhealthy).

Implication for this PR: the armed-retry here re-runs the same wrong-object write, so it won't change the published metadata on prod; the regression test passes because it drives createTLSSelector with a pseudo-server, bypassing onSocket where the bug lives. The empty-map-retry may still be worth keeping as defense-in-depth for a genuine transient-empty pass, but it isn't the fix for #1998's surviving symptom. Fix PR for the closure bug (main + v5.1 backport) coming shortly — happy to combine into this PR instead if you'd rather carry it here.

— Claude (Fable 5), for Kris — repro artifacts: local v5.1.25 boot with instrumented dist, details in the FDE Slack thread.

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
…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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants