Patch release: the main process validates listeners and HTTP/HTTPS
frontends before committing them to its state and rolls back unanimously
rejected or unacknowledged fan-outs, the router rejects malformed
frontend hostnames instead of panicking workers, and a full dependency
refresh re-enables TLS 1.2 + ECDSA under crypto-openssl.
🔐 Security
fix(command): redact TLS certificate and private-key material fromDebugoutput. Certificate payloads, runtime certified keys and resolvers, HTTP(S) listeners and listener patches, HTTP frontend configuration, top-levelRequest/RequestTypevalues, retained command tasks, and generated frontend requests now use boundedDebugsummaries without exposing PEM contents, certificate DER, custom answer bodies, header values, string-valued command payloads, or other raw fields carried by those types through nested requests, retained state, or log statements that useDebugformatting. Direct log and failure projections now also replace certificate names, HTTP frontend keys, route-miss hosts/paths/custom methods, rewrites, cluster IDs, custom-answer keys, runtime SNI/ALPN/authority/certificate-SAN values, certificate fingerprints, and certificate-query domains and results with bounded addresses, kinds, counts, and byte lengths.StateErroris bounded at itsDisplay/Debugboundary before worker and audit sinks, and retained-task logs no longer delegate to concrete task payloads. Wire, Serde, JSON, raw retained state keys, rawStateError,RouterError,ListenerError, andRetrieveClusterError::SniAuthorityMismatchvariant fields,RequestHttpFrontend::Display, and certificate-query response payloads remain unchanged.
🐛 Fixed
-
fix(router): reject a malformed frontend hostname instead of panicking the worker.TrieNode::insertasserted that its recursive insert never reportsInsertResult::Failed, but the route-table grammar rejects a whole class of hostnames the two cheap guards above the assert let through: a host ending in/with no openable regex segment (example.com/), a regex segment that is not.-anchored (abc/[0-9]+/.example.com), a segment that is not a valid regex (/[/.example.com), and an empty label (.example.com). Those hostnames arrive from the control plane, so anAddHttpFrontendcarrying one panicked every worker the main process fanned the request out to — and panicked them again on each restart state replay.add_tree_rulenow surfaces the rejection asRouterError::AddRoute, so the worker answersFailureand stays up. Three sibling holes in the same input class are closed with it:convert_regex_domain_ruleindexed one byte past the end of a hostname whose last segment is followed by a bare trailing.(/a/.) — a release panic reached through the unconditionalDomainRuleparse, upstream of the trie; hostnames are now bounded toMAX_HOSTNAME_LENGTH(4096 bytes) before any parse, since the trie recurses once per label (a ~100k-label hostname aborted the worker with an uncatchable stack overflow) and a/-segment compiles a control-plane-supplied regex whose compilation time grows with the pattern; andCertificateResolver::add_certificatenow validates every certificate name against the same trie grammar up front — with the insert no longer panicking, a discardedInsertResult::Failedwould have registered the certificate while the SNI trie never learned the name, silently failing every handshake for it. With workers answeringFailureinstead of dying, the unanimous-rejection rollback from #1301 also becomes reachable for this class on the live fan-out path, so the main process'sConfigStateis reverted and no phantom route persists. Two replay holes remain open and are tracked as follow-ups: theLoadStatepath has no rollback at all (a poisoned saved state re-injects the entry andSaveStatere-persists it), and the rollback is skipped when the scatter times out — a single unpatched worker panicking during a rolling upgrade commits the malformed entry permanently. -
fix(command): validate a listener configuration before committing it to the main-process state (#1301). An HTTPS listener whose configuration the worker cannot build (an unusable TLS version/cipher set, or an unparseable answer template) was recorded in the main process'sConfigStatebefore the worker rejected it, reserving the address; a corrected reload was then refused withStateError::Existsand never reached the workers, so the listener stayed down until an explicitRemoveListeneror a restart. The main process now validates everyAdd{Http,Https,Tcp,Udp}Listenerthe way the worker builds it — reusing the worker's own construction check, beforeConfigStateis mutated and fanned out — so an invalid listener never reserves its address and a corrected reload applies cleanly. Invalid listeners in the static config or a loaded state file are likewise skipped without reserving their address. TheStateError::Existsmessage now also points at the remedy (remove it first, or apply the corresponding update, instead of re-adding). As defense-in-depth, when a committed change is rejected by every worker it was fanned out to, the main process now reverts its ownConfigStatewith the inverse request (listener and HTTP/HTTPS-frontend adds), so its authoritative state — the one replayed into restarted and upgraded workers — never permanently holds an add the whole fleet refused. -
fix(command): validate HTTP/HTTPS frontends before committing them to the main-process state (#1313).ConfigStatehas no route-grammar check —add_http_frontendonly reparses the address and maps the position — so anAdd{Http,Https}Frontendwhose hostname every worker's router refuses was still recorded by the main process. That state is authoritative:SaveStatere-serialised the malformed entry and every replay re-injected it, and the replay path (LoadState, static-config load) has no rollback at all, so the entry survived a unanimous worker rejection and poisoned each subsequent restart. The main process now runs the worker's own insertion path — the sameRouter::add_http_front, against a disposable empty router — beforeConfigStateis mutated, on all three apply paths: a liveAdd{Http,Https}Frontendis answered with a failure and never fanned out, and a malformed entry in a saved state file or the static configuration is skipped without entering the state, so it can never be re-persisted. Skipped entries (frontends and, as before, unbuildable listeners) are now reported atwarn!instead ofdebug!— an entry silently dropped from the state the operator saved must be visible at the default log level — and astate loadthat skipped any entry says how many. Rejection messages report byte lengths only, never the operator-supplied hostname. -
fix(command): roll back a fanned-out change when the scatter times out with zero worker acknowledgements (#1314). The unanimous- rejection rollback from #1301 required at least one worker to answerFailure, so it was skipped whenever the fan-out timed out instead — and a worker that panics answers nothing at all: it emits no syntheticFailureand no expected-response decrement, so the task can only end through its timeout. One unpatched worker panicking on a malformed frontend during a rolling upgrade therefore left the malformed entry committed in the main process'sConfigStateforever, withSaveStatere-persisting it, even though the operator was already told the command had failed. The revert now fires on either trigger — every scattered worker rejected the change, or the scatter timed out — as long as no worker acknowledged it; an entry at least one worker applied is still never reverted. Residual: a slow-but-healthy worker whoseOkarrives after the deadline is invisible to the finished task, so it keeps the frontend until its next restart or state replay while the main process reverts — a bounded divergence that self-heals on replay and matches the failure already reported to the client. -
fix(command): report a fan-out timeout as a failure, not a success.handle_finishing_taskpassed a hard-codedtimed_out = falseinto every task completion handler, so a command whose worker fan-out timed out was reported to the operator asSuccessfully applied request to all workersand its audit line mislabeled theFanoutStatus/result. The realtimed_outflag is now forwarded, so a timed-out command correctly returns a failure (and the#1301rollback safety-net's timeout guard now engages as intended).
🔄 Changed
chore: refresh every workspace dependency requirement to its latest published version (#1317).Cargo.tomlrequirements move to explicit^x.y.zform: async-trait^0.1.92, flate2^1.1.10, futures^0.3.34, http-body-util^0.1.5, log^0.4.34, rustls-openssl^0.4.0, and tui-big-text^0.8.9, with both lockfiles regenerated (cargo auditreports no advisory; the transitive h2 0.4.19 closing RUSTSEC-2026-0258 had already landed onmain). rustls-openssl 0.4.0 fixes the TLS 1.2 + ECDSA handshake regression that 0.3.x carried, sotest_tls_1_2_ecdsaruns undercrypto-opensslagain and that CI cell is back to full parity with the other providers.