Skip to content

2.2.1

Latest

Choose a tag to compare

@github-actions github-actions released this 28 Aug 14:54
· 416 commits to main since this release

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 from Debug output.
    Certificate payloads, runtime certified keys and resolvers, HTTP(S) listeners and listener
    patches, HTTP frontend configuration, top-level Request/RequestType values, retained command
    tasks, and generated frontend requests now use bounded Debug summaries 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 use Debug formatting. 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. StateError is bounded at its Display/Debug boundary before worker and
    audit sinks, and retained-task logs no longer delegate to concrete task payloads.
    Wire, Serde, JSON, raw retained state keys, raw StateError, RouterError, ListenerError, and
    RetrieveClusterError::SniAuthorityMismatch variant fields, RequestHttpFrontend::Display, and
    certificate-query response payloads remain unchanged.

🐛 Fixed

  • fix(router): reject a malformed frontend hostname instead of panicking the worker.
    TrieNode::insert asserted that its recursive insert never reports InsertResult::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 an AddHttpFrontend carrying one panicked every worker the main process fanned the
    request out to — and panicked them again on each restart state replay. add_tree_rule now
    surfaces the rejection as RouterError::AddRoute, so the worker answers Failure and 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 unconditional DomainRule parse, upstream of
    the trie; hostnames are now bounded to MAX_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; and CertificateResolver::add_certificate now validates every
    certificate name against the same trie grammar up front — with the insert no longer panicking, a
    discarded InsertResult::Failed would have registered the certificate while the SNI trie never
    learned the name, silently failing every handshake for it.
    With workers answering Failure instead of dying, the unanimous-rejection rollback from
    #1301 also becomes reachable for this class on
    the live fan-out path, so the main process's ConfigState is reverted and no phantom route
    persists. Two replay holes remain open and are tracked as follow-ups: the LoadState path has no
    rollback at all (a poisoned saved state re-injects the entry and SaveState re-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's ConfigState before the worker rejected it,
    reserving the address; a corrected reload was then refused with StateError::Exists and never
    reached the workers, so the listener stayed down until an explicit RemoveListener or a restart.
    The main process now validates every Add{Http,Https,Tcp,Udp}Listener the way the worker builds
    it — reusing the worker's own construction check, before ConfigState is 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. The StateError::Exists message 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 own ConfigState
    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). ConfigState has no
    route-grammar check — add_http_frontend only reparses the address and maps the position — so an
    Add{Http,Https}Frontend whose hostname every worker's router refuses was still recorded by the
    main process. That state is authoritative: SaveState re-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 same Router::add_http_front, against
    a disposable empty router — before ConfigState is mutated, on all three apply paths: a live
    Add{Http,Https}Frontend is 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 at warn! instead of debug! — an entry silently dropped from the state the
    operator saved must be visible at the default log level — and a state load that 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 answer Failure, so it was skipped whenever the fan-out timed out instead — and a
    worker that panics answers nothing at all: it emits no synthetic Failure and 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's ConfigState forever, with SaveState re-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 whose Ok arrives 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-coded timed_out = false into every task completion handler, so a command whose
    worker fan-out timed out was reported to the operator as Successfully applied request to all workers and its audit line mislabeled the FanoutStatus/result. The real timed_out flag is
    now forwarded, so a timed-out command correctly returns a failure (and the #1301 rollback
    safety-net's timeout guard now engages as intended).

🔄 Changed

  • chore: refresh every workspace dependency requirement to its latest published version
    (#1317). Cargo.toml requirements move to
    explicit ^x.y.z form: 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 audit reports no advisory; the transitive h2 0.4.19
    closing RUSTSEC-2026-0258 had already landed on main). rustls-openssl 0.4.0 fixes the
    TLS 1.2 + ECDSA handshake regression that 0.3.x carried, so test_tls_1_2_ecdsa runs under
    crypto-openssl again and that CI cell is back to full parity with the other providers.