Download Latest Version 2.2.0 source code.zip (2.2 MB)
Email in envelope

Get an email when there's a new version of Sōzu

Home / 2.2.0
Name Modified Size InfoDownloads / Week
Parent folder
SHA256SUMS 2026-07-16 1.2 kB
SHA256SUMS.pem 2026-07-16 3.2 kB
SHA256SUMS.sig 2026-07-16 96 Bytes
sozu-2.2.0-aarch64-unknown-linux-gnu-crypto-aws-lc-rs.tar.gz 2026-07-16 5.7 MB
sozu-2.2.0-aarch64-unknown-linux-gnu-crypto-openssl.tar.gz 2026-07-16 4.8 MB
sozu-2.2.0-aarch64-unknown-linux-gnu-crypto-ring.tar.gz 2026-07-16 5.1 MB
sozu-2.2.0-aarch64-unknown-linux-gnu-fips.tar.gz 2026-07-16 5.7 MB
sozu-2.2.0-x86_64-unknown-linux-gnu-crypto-aws-lc-rs.tar.gz 2026-07-16 6.3 MB
sozu-2.2.0-x86_64-unknown-linux-gnu-crypto-openssl.tar.gz 2026-07-16 5.3 MB
sozu-2.2.0-x86_64-unknown-linux-gnu-crypto-ring.tar.gz 2026-07-16 5.6 MB
sozu-2.2.0-x86_64-unknown-linux-gnu-fips.tar.gz 2026-07-16 6.3 MB
sozu-2.2.0-x86_64-unknown-linux-musl-crypto-aws-lc-rs.tar.gz 2026-07-16 6.4 MB
sozu-2.2.0-x86_64-unknown-linux-musl-crypto-ring.tar.gz 2026-07-16 5.7 MB
2.2.0 source code.tar.gz 2026-07-16 2.0 MB
2.2.0 source code.zip 2026-07-16 2.2 MB
README.md 2026-07-16 10.4 kB
Totals: 16 Items   61.1 MB 1

Minor release: TCP passthrough routing by TLS SNI + ALPN, HTTP/1 Transfer-Encoding smuggling hardening, and frontend half-close data-loss fixes across the pipe and splice paths.

✨ Added

  • feat(tcp): TCP passthrough routing by TLS SNI + ALPN (#1279). A protocol = "tcp" listener can now fan out to multiple clusters on the same address:port by reading the TLS ClientHello's SNI (RFC 6066 §3) and, per route entry, ALPN (RFC 7301 §3.1) during a bounded preread phase — without decrypting or terminating the connection; the backend still completes its own handshake with the untouched bytes. New TOML keys: TCP frontend hostname (mapped to the wire sni field; exact host or a single leading *. wildcard) and alpn (repeatable; empty is the per-(address, hostname) catch-all), and listener sni_preread_timeout (default 5s) / sni_preread_max_bytes (default 16384; config-load rejects a value above the global buffer_size, and the worker defensively clamps the effective cap to the session buffer at runtime). New CLI flags: sozu frontend tcp add|remove --sni <host> --alpn <proto> and sozu listener tcp add --sni-preread-timeout <secs> --sni-preread-max-bytes <bytes> (add-only — no wire support yet for patching either knob via listener tcp update). New metrics: tcp.sni_preread.{routed,active,duration} and eleven tcp.sni_preread.rejected.<reason> counters (not_tls, malformed_record, malformed_handshake, fragmented, too_large, no_sni, ech_outer_absent, sni_unmatched, alpn_unmatched, proxy_header_invalid, front_closed) plus tcp.upgrade.sni_preread.failed. A listener cannot mix SNI-scoped and no-SNI (legacy single-cluster) frontends; alpn requires hostname (ConfigError::AlpnWithoutSni — a no-SNI frontend would silently never enforce it); sni_preread_max_bytes must be >= 5 bytes, a full TLS record header (ConfigError::SniPrereadMaxBytesTooSmall0 would spin the preread shell on zero-progress reads). These routing-shape invariants (the mixing ban, ALPN-overlap/catch-all uniqueness, and ALPN-without-SNI) are now enforced BOTH at TOML config-load (command/src/config.rs) AND defensively on the worker's hot AddTcpFrontend path (TcpListener::validate_new_tcp_front, lib/src/tcp.rs), so a request sent directly over the command socket or replayed from a stale LoadState snapshot can no longer bypass config.rs and corrupt a listener's routing table. See doc/configure.md and lib/src/protocol/tcp_preread/LIFECYCLE.md for the full config surface and state lifecycle. New test coverage: unit tests in lib/src/protocol/tcp_preread/{mod,shell}.rs, e2e suite e2e/src/tests/tcp_sni_tests.rs, the fuzz_tcp_clienthello cargo-fuzz target (wired into the fuzz CI job — nightly toolchain, 300s per target on every push/PR — and simulation-sweep.yml's daily extended-fuzz matrix), and a moonpool-driven deterministic simulation (sim/tests/tcp_preread_sim.rs, sozu-sim crate — wired into both the per-PR udp-simulation CI job and a new nightly tcp-preread-simulation-sweep job in simulation-sweep.yml).

🔄 Changed

  • chore(deps): moonpool-sim tracks its published crates.io release. The sozu-sim dev-dependency moves from a pinned main git rev to ^0.8.0 on crates.io, closing the follow-up 2.1.1 flagged when the then-published 0.7.0 could not build on stable.
  • chore(deps): dependency refreshhttp-body-util 0.1.3 → 0.1.4, mio 1.2.1 → 1.2.2, regex 1.12.4 → 1.13.0, rustls 0.23.41 → 0.23.42, socket2 0.6.4 → 0.6.5, toml 1.1.2 → 1.1.3.

🐛 Fixed

  • fix(pipe): stopped truncating in-flight bytes on a frontend half-close (surfaced by #1279). When the frontend read side reached EOF while frontend_buffer still held bytes queued for the backend, Pipe::readable returned Close unconditionally, silently dropping them instead of flushing first — any front-to-back tail still in flight at the exact moment of frontend close was lost. The reproducer that surfaced it was a request payload coalesced with the SNI ClientHello in the same read (TCP passthrough routing above), but the bug was general: any plain-TCP upload racing a frontend close could hit it. readable now transitions to the half-closed WriteOpen status (matching the existing WouldBlock/Continue handling), arms backend-writable to drain the queue, and defers the actual teardown to check_connections, which only closes once nothing is in flight. The HUP path had the same defect under a heavier load: EPOLLRDHUP can coalesce with the payload tail into a single epoll batch, so TcpSession::ready_inner observed HUP before its processing loop even ran and called Pipe::frontend_hup, which also returned Close unconditionally; it now drains in-flight request bytes the same way (Continue + backend-writable + retained frontend READABLE interest), and ready_inner falls through into its same-pass loop on that Continue instead of returning immediately, so the drain completes without waiting on a frontend event that — the client already sent FIN — will never arrive. The splice fast path's EOF arm (Pipe::splice_readable) had the same unconditional close, dropping bytes already spliced into the kernel pipe; it now defers teardown the same way until splice_backend_writable drains them.
  • fix(command): count_tcp_frontends_raw compiles unconditionally. The helper was gated #[cfg(debug_assertions)] while add_tcp_frontend references it inside debug_assert! expressions — which still typecheck in release builds even though they compile out — so the bench and release profiles (CI's Criterion step, the Docker image build) failed with E0599 while every debug-profile gate stayed green (command/src/state.rs).
  • fix(packaging): the RPM spec Version tracks the release. os-build/linux-rpm/sozu.spec still declared 2.1.0 after the 2.1.1 release. Its Source0 resolves …/archive/%{version}.tar.gz, so the stale value made the spec fetch the wrong source tarball; it is now bumped in lockstep with the crates.

🤖 CI

  • fix(ci): the release build uses the pinned MSRV toolchain. The per-target release job installs Rust 1.91.0 with its matrix target, matching the rust-toolchain pin (it still requested 1.88.0 after the MSRV bump).
  • fix(ci): the release preflight validates every crate version. The drift check covered bin/lib/command/e2e only, while sim/ (a workspace member) and the out-of-workspace fuzz/ track the same version, so a partially-bumped tree could ship a green preflight — which is why 2.1.1 needed two follow-up commits to align fuzz/. The loop now covers all six manifests and RELEASE.md names them.
  • chore(e2e): sozu-e2e carries publish = false. The integration-test harness is not published to crates.io.

🔐 Security

  • fix(h1): harden HTTP/1 Transfer-Encoding framing (reopen of #726). HttpContext::on_request_headers (lib/src/protocol/kawa_h1/editor.rs) now answers 400 when a request carries a Transfer-Encoding header that the parser did not accept as chunked framing, rather than forwarding it alongside a Content-Length, and also when more than one non-elided Transfer-Encoding header survives kawa's header pass (kawa evaluates each TE field line independently, so a first chunked line can latch chunked framing while a second, differently-framed line rides along uncaught). Per RFC 9110 §7.6 / RFC 9112 §6.1 an intermediary must not forward ambiguous message framing. The rejection runs before routing and increments the http.frontend.transfer_encoding_smuggling metric; it mirrors the existing HTTP/2 → H1 framing-conflict rejection.

  • chore: update kawa to 0.7.1 — the complementary upstream parser fix. kawa 0.7.0 resolves Transfer-Encoding before Content-Length and applies RFC 9110 §6.3 / RFC 9112 §6.1 at the parser level: a present Transfer-Encoding now elides every Content-Length regardless of header order (previously only once chunked framing had been adopted), repeated Transfer-Encoding field lines combine with the last line deciding the final coding (so a leading chunked line no longer latches chunked framing on its own), and a request whose combined final coding is not chunked is rejected by the parser instead of forwarded.

kawa 0.7.1 ([CleverCloud/kawa#21](https://github.com/CleverCloud/kawa/issues/21)) completes it by excluding leading and trailing optional whitespace from every header field value, per RFC 9112 §5 ("A field value does not include leading or trailing whitespace"). Two behaviours change for sozu. A legal Content-Length: 5 (trailing space) is no longer rejected as an invalid field value. And a Transfer-Encoding: chunked\t — which 0.7.0 already framed as chunked, since the value is whitespace-trimmed to determine the final coding, but then forwarded verbatim — is now forwarded as the canonical chunked. Framing on one reading of a value while forwarding another is a desync primitive in its own right: with the Content-Length elided, a backend that does not itself trim would see neither a coding it recognizes nor a length, and would read the chunked body bytes as a pipelined request.

An whitespace-obfuscated coding is therefore now handled rather than refused — it is a legal chunked request, and rejecting it would reject legal traffic the same way Content-Length: 5 was being rejected. What sozu guarantees is that it never forwards a Transfer-Encoding that differs from the framing it applied: it normalizes, or it rejects. The on_request_headers guard above is kept as defense-in-depth for what remains genuinely ambiguous — more than one surviving Transfer-Encoding header, or a value whose final coding is not chunked. Requests using a legitimate chunked or gzip, chunked coding are unaffected.

Source: README.md, updated 2026-07-16