Repository navigation
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, and
RetrieveClusterError::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_rule
indexed 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 ownConfigState
with 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 an
Add{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 live
Add{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_task
passed 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 under
crypto-opensslagain and that CI cell is back to full parity with the other providers.