Repository navigation
2.2.0
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 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;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 stale
LoadStatesnapshot can no longer bypass config.rs and corrupt a
listener's routing table. Seedoc/configure.mdand
lib/src/protocol/tcp_preread/LIFECYCLE.mdfor 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, 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 nightly
tcp-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.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 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_innerobservedHUP
before 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 frontend
READABLEinterest), 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_frontend
references 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 withE0599
while 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-Encoding
header 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 the
http.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 present
Transfer-Encodingnow elides everyContent-Lengthregardless of header
order (previously only once chunked framing had been adopted), repeated
Transfer-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.kawa0.7.1 (CleverCloud/kawa#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 aTransfer-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 theContent-Lengthelided, 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 wayContent-Length: 5was being rejected. What sozu
guarantees is that it never forwards aTransfer-Encodingthat differs from
the framing it applied: it normalizes, or it rejects. The
on_request_headersguard above is kept as defense-in-depth for what remains
genuinely ambiguous — more than one survivingTransfer-Encodingheader, or a
value whose final coding is notchunked. Requests using a legitimate
chunkedorgzip, chunkedcoding are unaffected.