Isolate per-route cert failures and hot-reload rotated cert files#14
Conversation
A single tenant's Let's Encrypt cert rotation could take a whole Fabric host dark for TLS: symphony's config.json inlined the tenant's old cert chain while referencing its live key by path, so after rotation the leaf pubkey no longer matched the key. rustls raised KeyMismatch, and two behaviors turned that one-tenant fault into a host-wide outage — the whole reconcile aborted on the first bad route, and cert changes were only picked up on a config.json write or restart (so it detonated days later on the next restart). Two fixes: 1. Per-route fault isolation (src/router.rs). build_route_table no longer propagates a single route's build error with `?`. A route whose cert can't be built is logged and skipped; every other route on the port survives. On a hot-swap the last-good route is carried forward for that SNI (mid-rotation the old cert is still valid), so a transient cert+key non-atomic-write mismatch doesn't drop the SNI from the live table — it heals on the next reconcile. The server-side per-proxy reconcile guard in doReconcile is widened to cover SymphonyProxy construction + start(), so a bad port-set can't skip the rest. 2. Cert-file hot reload (ts/server.ts). The standalone server now also watches the cert/key files referenced by the config (grouped by parent dir, deduped, re-derived and torn down each reconcile so watchers don't leak) and reconciles on change, giving a running symphony live pickup of a renewal without a config.json write. The per-proxy listenerSig is computed over the resolved listeners (cert contents included) so a listener-level defaultCert/mTLS rotation forces a recreate rather than a route-only hot-swap against the frozen default_listener_tls. Tests: router.rs unit tests for skip-not-fatal and last-good retention; server.spec.ts for route-cert rotation, listener-defaultCert rotation, and per-route isolation (healthy co-tenant survives a broken route on the same port). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address a review finding: build_route_table isolates cert *build* failures (KeyMismatch, bad PEM), but a route's cert/key FILE being unreadable (ENOENT while a cert rotates) threw out of toProxyConfig and aborted the reconcile for the entire port-set — so a single unreadable route file blocked every other route's update, and the route never reached Rust's last-good carry-forward. resolveRoute() now catches a per-route file-read failure and substitutes an unbuildable cert, so build_route_table drops just that SNI (or carries its last-good route forward on a hot-swap) while every healthy route on the port updates. Listener-level cert failures still propagate (per-proxy guard leaves the existing proxy running rather than recreating against an unbuildable default). Test: a co-tenant whose cert file goes missing keeps its last-good cert while another route on the same port rotates successfully. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
CI note for reviewers: the red Integration Test – Harper UDS proxy is pre-existing and environmental, not a regression from this PR — it fails identically on Also unrelated: (comment generated by an LLM — Claude Opus 4.8) |
Devin-Holland
left a comment
There was a problem hiding this comment.
Reviewed the full change — per-route cert isolation, carry-forward-last-good on hot-swap, and the server.ts cert-file watching. Approving: this is a solid, well-tested fix. Coverage is thorough (cert-file hot reload, per-route KeyMismatch isolation, listener defaultCert reload forcing a recreate, and the co-tenant ENOENT-mid-rotation case, plus Rust unit tests exercising the non-atomic key-then-chain write window).
Two minor, non-blocking notes:
-
Carry-forward retains the whole old route, not just the cert. On a hot-swap where a route's upstreams change and its cert transiently fails to build, the old upstreams also persist for that SNI until the next reconcile heals it. It self-heals (the cert watcher fires a reconcile), so this is fine in practice — worth a one-line comment noting it's intentional so a future reader doesn't mistake it for a bug.
-
Log volume on a persistently-bad cert. The
eprintln!("retaining last-good" / "skipping route") fires on every reconcile, so a cert that stays mismatched long-term (not just mid-rotation) will log on each cert-file event. Consider logging only on the good→bad transition to keep steady-state noise down.
host-manager PR #133 reorders the version upgrade to start the replacement before retiring the incumbent (SO_REUSEPORT overlap): the new process writes status.json with its own pid, waits healthy, then the old process is stopped. With an unconditional unlink, the retiring process's stop() would delete the status.json now owned by the successor → host-manager's health check reads null → respawns a duplicate. stop() now reads status.json and only unlinks it when its pid matches process.pid. Best-effort: a missing/garbage status file must not throw out of stop(). #133's start-before-stop handoff depends on this guard. Test: an owned status.json is removed on stop; a status.json rewritten with a successor's pid survives stop() untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses non-blocking review feedback: a persistently-broken cert re-logged "retaining last-good" / "skipping route" on every reconcile, since each cert-file event rebuilds the whole table. RouteTable now tracks the set of SNIs that failed to build, carried across a hot-swap so the eprintln fires only when a route first transitions good→bad, not every rebuild while it stays broken. Also clarifies in a comment that the hot-swap carry-forward intentionally retains the whole previous route (cert and upstreams), which self-heals on the next reconcile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the thorough read, @Devin-Holland — much appreciated, and great callouts. Both notes are addressed in
All green locally (33 JS tests + (reply generated by an LLM — Claude Opus 4.8; Kris will confirm before merge) |
Summary
Makes symphony resilient to TLS cert rotation. Three fixes in one PR:
Per-route fault isolation (
src/router.rs+ts/server.ts) —build_route_tableno longer aborts the whole table on the first route that fails to build. A route whose cert can't be built (e.g. rustlsKeyMismatch) is logged (eprintln!, matching theproxy.rsroute-warning convention) and skipped; every other route on the port survives. This feeds both initial construction and theupdateConfighot-swap.doReconcileis widened to coverSymphonyProxyconstruction +start(), so a failing port-set can no longer skip the remaining ones.seenstill holds the key, so a skipped existing proxy is left running, not torn down.resolveRoute()catches a per-route ENOENT/unreadable cert file and hands Rust an unbuildable cert, sobuild_route_tabledrops/carries-forward just that SNI rather than throwing out oftoProxyConfigand aborting the whole port-set (a review finding fromgemini-code-assist).Cert-file hot reload (
ts/server.ts) — the standalone server now watches the cert/key files referenced by the config (grouped by parent dir, deduped, re-derived + torn down every reconcile so watchers don't leak;'error'listener on each) and reconciles on change. A running symphony picks up an on-disk renewal without aconfig.jsonwrite or restart. Debounced (300ms) and coalesced into the already-serializedreconcile().status.jsonownership guard (ts/server.ts) —stop()now only deletesstatus.jsonwhen itspidmatchesprocess.pid(best-effort; a missing/garbage file must not throw). During a version upgrade the replacement starts first (SO_REUSEPORT overlap) and rewritesstatus.jsonwith its own pid before the incumbent retires; an unconditional unlink would clobber the successor's file and make host-manager's health check read null → respawn a duplicate.Why
A single tenant's LE cert rotated on disk, but symphony's
config.jsonstill inlined that tenant's old cert chain while referencing its live key by path → leaf pubkey ≠ key → rustlsKeyMismatch. Two symphony behaviors turned a one-tenant rotation into a host-wide TLS outage (every TLS listener — 443/9925/8883/9933 — failed to bind, only:80stayed up): the reconcile aborted on the first bad route, and cert changes were only observed on aconfig.jsonwrite/restart, so it detonated days later on the next restart.Coordination (host-manager — land in order)
Layered story: hm #132 stops emitting a bad pair; Fix 1 tolerates one if it arrives; Fix 2 gives live rotation pickup; hm #133 + Fix 3 make the upgrade handoff fail-safe.
status.json.Where to look / open items for the reviewer
build_route_tablelast-good retention (src/router.rs) — the hot-swap now carries the previous route forward on a transient build failure. Note the persistent-failure behavior: a route that stays broken keeps serving its last-good (still on-disk) cert indefinitely (logged each reconcile), trading staleness for availability. This was the sharpest cross-model finding (a partial-table hot-swap would otherwise drop the SNI where the pre-change code retained the last-good table).listenerSignow hashes resolved cert contents (ts/server.ts) — a listener-leveldefaultCert/mTLS rotation only applies if it forces a proxy recreate (the Rustdefault_listener_tlsis frozen at construction;updateConfigonly swaps routes). Keying the signature off the resolved listeners (cert bytes included) makes a rotation change the signature. Covered by a test.ts/server.ts) — onefs.watchper distinct parent dir, filtered to referenced basenames against a livecertFilesByDirset so a config repoint is honored without recreating watchers; stale dirs are closed each reconcile...datasymlink-swap rotation isn't covered (the real basename never gets a direct event) — a follow-up if symphony ever runs under that model.SymphonyProxy.start()resolves before the actual bind (the bind + its errors live in the spawned Rust listener task), so the widened guard gives no protection against an async bind failure. Unchanged by this diff; worth tracking separately.sni::tests::test_is_greasefails onmain(theis_greasebitmask only matches0x0A0A, not0x1A1A/0xFAFA); and theIntegration Test – Harper UDS proxyCI check fails onmaintoo (theharperdev-dep can't resolve its own../core/*paths). Neither is touched here.Tests
src/router.rs:bad_route_is_skipped_not_fatal,transient_failure_retains_last_good_on_hot_swap.__test__/server.spec.ts: route-cert rotation, listener-defaultCertrotation, per-route isolation (healthy co-tenant survives a broken route on the same port + skip log), route cert file-read isolation (co-tenant missing cert file keeps last-good while another route rotates), andstatus.jsonownership guard (owned file removed on stop; successor-owned file untouched).npm test(33 pass) andcargo test(new tests pass) green. Harper integration leg needs a bootable Harper instance (env-gated + pre-existing-red onmain); relying on CI for the rest.Review
Cross-model review run (thorough): Codex + Harper-domain adjudication. The Gemini/
agyleg failed to run (stochastic print-mode hang, 3 attempts) — no second-model perf/maintainability coverage from Gemini; correctness/concurrency were covered by Codex + the domain pass. Confirmed findings (listener-cert rotation gap, hot-swap last-good retention, route file-read isolation) are all addressed above.🤖 Generated by an LLM (Claude Opus 4.8, 1M context). Reviewed by Kris before merge.