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). Aprotocol = "tcp"listener can now fan out to multiple clusters on the sameaddress:portby 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 frontendhostname(mapped to the wiresnifield; exact host or a single leading*.wildcard) andalpn(repeatable; empty is the per-(address, hostname)catch-all), and listenersni_preread_timeout(default 5s) /sni_preread_max_bytes(default 16384; config-load rejects a value above the globalbuffer_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>andsozu listener tcp add --sni-preread-timeout <secs> --sni-preread-max-bytes <bytes>(add-only — no wire support yet for patching either knob vialistener tcp update). New metrics:tcp.sni_preread.{routed,active,duration}and eleventcp.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) plustcp.upgrade.sni_preread.failed. A listener cannot mix SNI-scoped and no-SNI (legacy single-cluster) frontends;alpnrequireshostname(ConfigError::AlpnWithoutSni— a no-SNI frontend would silently never enforce it);sni_preread_max_bytesmust be >= 5 bytes, a full TLS record header (ConfigError::SniPrereadMaxBytesTooSmall—0would 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 hotAddTcpFrontendpath (TcpListener::validate_new_tcp_front,lib/src/tcp.rs), so a request sent directly over the command socket or replayed from a staleLoadStatesnapshot can no longer bypass config.rs and corrupt a listener's routing table. Seedoc/configure.mdandlib/src/protocol/tcp_preread/LIFECYCLE.mdfor the full config surface and state lifecycle. New test coverage: unit tests inlib/src/protocol/tcp_preread/{mod,shell}.rs, e2e suitee2e/src/tests/tcp_sni_tests.rs, thefuzz_tcp_clienthellocargo-fuzz target (wired into thefuzzCI job — nightly toolchain, 300s per target on every push/PR — andsimulation-sweep.yml's daily extended-fuzz matrix), and a moonpool-driven deterministic simulation (sim/tests/tcp_preread_sim.rs,sozu-simcrate — wired into both the per-PRudp-simulationCI job and a new nightlytcp-preread-simulation-sweepjob insimulation-sweep.yml).
🔄 Changed
chore(deps):moonpool-simtracks its published crates.io release. Thesozu-simdev-dependency moves from a pinnedmaingit rev to^0.8.0on 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 refresh —http-body-util0.1.3 → 0.1.4,mio1.2.1 → 1.2.2,regex1.12.4 → 1.13.0,rustls0.23.41 → 0.23.42,socket20.6.4 → 0.6.5,toml1.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 whilefrontend_bufferstill held bytes queued for the backend,Pipe::readablereturnedCloseunconditionally, 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.readablenow transitions to the half-closedWriteOpenstatus (matching the existingWouldBlock/Continuehandling), arms backend-writable to drain the queue, and defers the actual teardown tocheck_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, soTcpSession::ready_innerobservedHUPbefore its processing loop even ran and calledPipe::frontend_hup, which also returnedCloseunconditionally; it now drains in-flight request bytes the same way (Continue+ backend-writable + retained frontendREADABLEinterest), andready_innerfalls through into its same-pass loop on thatContinueinstead of returning immediately, so the drain completes without waiting on a frontend event that — the client already sent FIN — will never arrive. Thesplicefast 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 untilsplice_backend_writabledrains them.fix(command):count_tcp_frontends_rawcompiles unconditionally. The helper was gated#[cfg(debug_assertions)]whileadd_tcp_frontendreferences it insidedebug_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 withE0599while every debug-profile gate stayed green (command/src/state.rs).fix(packaging): the RPM specVersiontracks the release.os-build/linux-rpm/sozu.specstill declared2.1.0after the 2.1.1 release. ItsSource0resolves…/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 therust-toolchainpin (it still requested 1.88.0 after the MSRV bump).fix(ci): the release preflight validates every crate version. The drift check coveredbin/lib/command/e2eonly, whilesim/(a workspace member) and the out-of-workspacefuzz/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 alignfuzz/. The loop now covers all six manifests andRELEASE.mdnames them.chore(e2e):sozu-e2ecarriespublish = false. The integration-test harness is not published to crates.io.
🔐 Security
-
fix(h1): harden HTTP/1Transfer-Encodingframing (reopen of #726).HttpContext::on_request_headers(lib/src/protocol/kawa_h1/editor.rs) now answers400when a request carries aTransfer-Encodingheader that the parser did not accept as chunked framing, rather than forwarding it alongside aContent-Length, and also when more than one non-elidedTransfer-Encodingheader survives kawa's header pass (kawa evaluates each TE field line independently, so a firstchunkedline 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 thehttp.frontend.transfer_encoding_smugglingmetric; it mirrors the existing HTTP/2 → H1 framing-conflict rejection. -
chore: updatekawato 0.7.1 — the complementary upstream parser fix.kawa0.7.0 resolvesTransfer-EncodingbeforeContent-Lengthand applies RFC 9110 §6.3 / RFC 9112 §6.1 at the parser level: a presentTransfer-Encodingnow elides everyContent-Lengthregardless of header order (previously only once chunked framing had been adopted), repeatedTransfer-Encodingfield lines combine with the last line deciding the final coding (so a leadingchunkedline no longer latches chunked framing on its own), and a request whose combined final coding is notchunkedis 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.