0.13.1
This release focuses on the elegance of 'fast, simple, and clean'.
Bugfix
- Fix: parse Forwarded
for=node-portper RFC 7239 §6 (port / obfport). The parser stored the port as a free-formStringand validated it as au16only on the IP-bearing arms. So an obfuscated port (obfport, RFC 7239 §6:"_" 1*(ALPHA / DIGIT / "." / "_" / "-")) - a valid RFC representation - was rejected outright when paired with an IP node (e.g.for="192.0.2.1:_p",for="[2001:db8::1]:_p"), while on theunknown/ obfuscated-nodename arms any byte sequence was silently accepted as the port (sofor=unknown:abc,for=_node:99999round-tripped non-port garbage through rpxy; with an unquoted"inside the body, the regeneratedForwardedheader could even break the quoted-string syntax on the wire). The port field is now aForwardedPortsum type (Numeric(u16) / Obfuscated(String)) with a valid-by-construction invariant on the obfuscated payload (non-empty body, every byte in the ABNF setALPHA / DIGIT / "." / "_" / "-");parse_forwarded_portreturns one variant or the other and is now called on every node arm, so all four producer paths enforce the same RFC contract and IP+obfport pairs are preserved end-to-end. The numeric branch additionally enforces the strict1*5DIGITABNF (rejecting+443,-1,"",000000, etc. thatu16::from_strwould otherwise canonicalize). The writer (write_forwarded_node) emitsNumericviawrite!(matching the existing IPv6 / Unknown / Obfuscated arms) andObfuscatedviapush_str(safe because the parser's ABNF check guarantees the payload bytes). Output bytes are unchanged for every input the IP-bearing arms reached previously (pinned by the existinggenerate_forwarded_header_rare_node_armstest); on the Unknown / Obfuscated arms a leading-zero numeric port (unknown:00443) now canonicalizes on emit to its decimal form (unknown:443) - the IP arms already did this via theparse → to_stringround-trip the previous shape carried, so this brings the three arms into line. The type refactor also drops oneu16 -> Stringround-trip per IP-bearingfor=entry. New tests cover the newly accepted IP+obfport case, the newly rejectedunknown:abc/_node:abc/ out-of-range / malformed-obfport ("_","_bad!","_a\"b") / non-1*5DIGIT(+443,000000) cases, the leading-zero canonicalization, and the writer's obfport rendering. The nodename-obfuscated arm (parse_forwarded_node'sstarts_with('_')) keeps its existing looseness - tightening it is a separate question. No public API surface is affected (ForwardedNodeis private). - Fix: detect the
TE: trailerstoken per RFC 9110 (case-insensitive, OWS includes HTAB). Detecting whether the incoming request signalled willingness to accept trailers compared the token as raw bytes againstb"trailers"and split list items on comma or space only, so any non-lowercase spelling -Trailers,TRAILERS, orTrailersinside a list (gzip, Trailers) - was silently missed, as was a tab-separated list (gzip,\tTrailers), and the upstream-bound request was sent without thete: trailersheader it should have had. RFC 9110 defines TE values as#t-codings, specifies transfer-coding tokens as case-insensitive (§5.6.6 / §10.1.4), and defines OWS around the comma as*(SP / HTAB)(§5.6.3); the local convention inextract_upgrade/hop.rsalready useseq_ignore_ascii_case. The match now uses<[u8]>::eq_ignore_ascii_caseand the splitter additionally accepts\t, with the detection extracted into a small private free function for direct unit testing; the surrounding.map().unwrap_or(false)collapses tois_some_and(...). Pinned by new tests covering lowercase / titlecase / uppercase, position-in-list with both SP and HTAB separators, unrelated tokens, the empty header, and atrailers-extrasubstring safety net. No unrelated code path is changed; lowercase TE-trailers inputs keep the same upstream request bytes. Non-lowercase or tab-separated TE-trailers inputs now correctly emitte: trailersupstream, which - per RFC 9110 - may let the backend send response trailers that are forwarded downstream.
Improvement
- Simplify
remove_connection_headerby taking theConnectionheader out of the map upfront. Removing the per-hop headers listed by a request's / response'sConnectionvalue cloned the wholeHeaderValueso itsto_str()borrow detached from theHeaderMap, allowing the subsequentheaders.remove(name)calls. The function now removes theConnectionheader itself first (yielding an ownedHeaderValuewhose borrow is independent of the map), then iterates the comma-separated name list and removes each listed header - noHeaderValueclone, no scratch allocation. The contract is widened to "this function also removesConnectionitself"; both call sites (handler_manipulate_messages.rs:30, 90) pair this function withremove_hop_header(which already listsheader::CONNECTION), so the end-to-end behaviour is unchanged.HOP_HEADERSkeepsCONNECTIONas defensive redundancy. Pinned by newhop::testscases covering listed-name removal, case-insensitivity, whitespace/empty-segment handling, an absent header, unparseable names being silently skipped, and the newConnection-self removal. Readability cleanup only. - Simplify
drop_port(the Host-header / URI host port-stripping helper) and drop its dead error arms.Request::inspect_parse_hoststripped the:portsuffix via three.split(..).next().ok_or_else(..)chains whoseErrbranches were unreachable (.split()on a slice always yields at least one element), and detected bare IPv6 by subtracting the sum of segment lengths from the total slice length (a colon-count computed the long way round). The helper is now a small private free function returningVec<u8>directly: bracketed IPv6 usesstrip_prefix(b"[")+position(b']')(the previous lenient unterminated-bracket case is preserved), bare IPv6 usesiter().filter(|&&b| b == b':').take(2).count() == 2(short-circuits after the second colon instead of scanning the whole value), and the v4/hostname branch cuts at the first:and ASCII-lowercases. The outer match ininspect_parse_hostcollapses its now-unreachableSome(Ok)/Some(Err)distinction intoSome(_)/(None, None) => Err(..), and theInspectParseHosttrait's public signature is unchanged. Same byte output for every input the previous code reached (v4 / hostname / bracketed v6 / bare v6, with and without port; empty Host; the lenient[v6no-]case), pinned by amod testscovering those eleven cases. Readability cleanup only; no observable behavior change. - Render
ServerNamedirectly viaDisplayinstead of aTryInto<String>roundtrip everywhere it was logged or formatted. Five sites (handler_mainredirect debug log, the H/3 connection-established debug log, the twoNoTlsServingApperror arms in the TLS acceptor, and the HTTPS redirect URL builder) rendered the server name with<&ServerName as TryInto<String>>::try_into(..).unwrap_or_default()and silently substituted""on UTF-8 failure.ServerNamenow implementsDisplay(zero-allocation on the UTF-8 path the request flow normally produces; falls back to U+FFFD viafrom_utf8_lossyinstead of swallowing the host when the byte constructors are fed non-UTF-8 input); the two debug logs read the value through{}directly, and the two error / one URL sites use.to_string()(same byte output, same allocation count, no turbofish). The two debug-log sites also drop oneStringallocation per emitted line (the disabled-callsite path was already a no-op); the error and URL sites are unchanged allocation-wise. TheTryInto<String> for &ServerNameimpl is kept so external/downstream callers are unaffected. Cosmetic cleanup only. - Parse the sticky cookie token by splitting on the first
=instead of collecting every=-separated slice (sticky-cookiefeature).StickyCookieValue::try_fromsplit the cookie token on every=into aVec<&str>and required exactly two slices, allocating a two-element vector per sticky-LB request and rejecting any value that itself contained=. It now usessplit_once('='): no allocation, and the value keeps any further=(a cookiename=valuepair is defined by the first=; the value may contain more). The wire value is a base64 NO_PAD AEAD blob that never contains=today, so this is observably unchanged - a malformed multi-=token is still ignored, now rejected one step later by the sticky-blob length / NO_PAD decode / AEAD verification inopen_server_idrather than by the structural check - while removing the brittleness against a future padded-base64 value. The exact-name and empty-value rejections are unchanged, pinned by the existing sticky-cookie suite plus newtry_fromcases (empty value, missing=, and a value containing=). Allocation/readability cleanup on the sticky-LB path only. - Enable stateless TLS session resumption (session tickets) for non-mTLS apps and HTTP/3, and drop server-side TLS session caching for the app-serving TLS configurations. rpxy previously relied on rustls's built-in fallback session cache (256 entries per server config, mutex-guarded, discarded on every certificate hot-reload), so returning clients effectively never resumed and every TLS handshake paid the full certificate/key-exchange cost. Non-mTLS server configurations — including the HTTP/3 (
http3-quinn) one — now share a single process-wide RFC 5077 session-ticket issuer (AES-256/HMAC-SHA256, keys rotated every 6 hours, tickets valid up to 12 hours): resumption works regardless of traffic volume, keeps working across certificate hot-reloads, and the in-memory session cache (and its per-handshake lock) is gone — TLS 1.2 clients resume via tickets as well, while legacy TLS 1.2 clients without session-ticket support lose only the previously ineffective cache. mTLS apps now never resume a TLS session: they issue no tickets and no longer use the fallback session cache either. Previously a returning mTLS client could occasionally resume from that cache, silently skipping client-certificate re-verification — and thereby escaping the handshake-failure audit, which can only log verification attempts; now client-certificate verification runs on every mTLS connection, matching the industry practice of disabling resumption for mutual TLS. Restarting rpxy invalidates outstanding tickets; clients then silently perform one full handshake. Thehttp3-s2nbackend is unaffected: it builds its TLS configuration through s2n-quic's own rustls provider and reuses only the certificate resolver and ALPN list from rpxy's configuration. The ACME TLS-ALPN challenge listener is likewise a separate configuration and is unchanged. - Skip building the access-log record when no access log can be emitted, and write the file access log through a dedicated minimal formatter. Two related cleanups driven by an instruction-level profile that attributed ~31% of the plaintext keep-alive data-path instructions to access logging. First, every request used to construct the access-log record (
HttpMessageLog: method/version capture, four header clones, the upstream URI clone) unconditionally, with the level check happening only at the final emit — so configurations whose logger can never emit an access line (stdio logging withRUST_LOG=warn|error) paid the full capture cost for nothing. Whether the installed logger can emit access lines is now determined once at startup (file logging: always; stdio: only when the resolvedRUST_LOGlevel admits INFO) and threaded into the request handler, which skips the record entirely when no line can result; the predicate shares its level resolution with the logger setup and is pinned to the actual installed filters by tests, so it cannot silently disagree with them. One visible trade-off in those quiet-stdio configurations only: ERROR system-log lines for failed requests now carry the error and client address instead of the full request summary (which no longer exists). Second, the file-mode access log — always on, the normal production configuration — used to render each line through the generic compact formatter, which re-processes the already-formatted message character by character through an ANSI-sanitizing writer; since every byte that sanitizer could transform is unreachable in access lines (header values and URI components are restricted to visible ASCII by HTTP parsing), the access-log file layer now writes the timestamp and message in a single pass. The emitted bytes are identical (pinned by golden tests against the generic formatter, including header values containing quotes and backslashes); the stdio log format, system logs, and the mTLS handshake audit logs are unchanged. CPU/allocation cleanup; end-to-end throughput was not re-measured. - Stop re-parsing hop-by-hop header names on every request. Removing the hop-by-hop headers (
Connection,TE,Trailer,Keep-Alive,Proxy-Connection,Proxy-Authenticate,Proxy-Authorization,Transfer-Encoding,Upgrade) from the proxied request and response passed the names as&strkeys, so thehttpcrate parsed each string into aHeaderNameand hashed it on every removal — 9 names twice per request (request towards the upstream and response towards the client), i.e. 18 string parses plus hashes per proxied request. An instruction-level profile (callgrind) of the plaintext HTTP/1.1 keep-alive path with access logging disabled attributed roughly 10% of the data-path instructions to this name parsing and hashing. The names are now pre-builtHeaderNameconstants (constructed at compile time), so removal skips the string parse entirely and standard names take thehttpcrate's pre-hashed fast path. The set of removed headers, case-insensitive matching, and all proxied bytes are unchanged. CPU cleanup only; end-to-end throughput was not re-measured. - Assemble the outgoing forwarding headers in single buffers instead of intermediate string lists. Generating the outgoing
X-Forwarded-Forbuilt oneStringper hop plus a list and a joined copy before the header value was created, and generating the RFC 7239Forwardedheader built up to fourStrings per hop plus two levels of join; the trusted-proxy path additionally cloned the immediate-peer chain entry on every request only to feed a defensive branch that is unreachable (the trust-boundary reduction always retains the appended peer hop — the branch is kept, but now rebuilds the entry instead of pre-cloning it). Each emitted header is now written left to right into one buffer:X-Forwarded-Forhands that buffer to the header value without re-copying,X-Real-IPis sliced out of its first element (same bytes as before, one copy — previously the copy direction was reversed), and theForwardedwriter reproduces the exact RFC 7230/7239 quoting rules (tchar fast path, quoted-pair escaping, IPv6 bracketing), pinned by the existing byte-exact tests plus new cases covering the rarefor=node forms (unknown/obfuscated, with and without ports) and quote/backslash escaping. Header parsing, the trust-boundary normalization logic, and every emitted byte are unchanged; this is a CPU/allocation cleanup of the output assembly only (common single-hop path: 4 allocations → 2; eachForwardedhop: ~4–6 → a shared buffer). End-to-end throughput was not re-measured. - Compute the authoritative request host once per request. The "authoritative host" of the incoming request (URI host preferred, port included, falling back to the
Hostheader) was recomputed — with a freshStringallocation each time — at up to four places per request: the peerForwardedentry, theX-Forwarded-Hostrewrite, the optional RFC 7239Forwardedgeneration (forwarded_headerupstream option), and the access log. It is now computed once at the top of the request-forwarding path, from the original URI andHostheader captured before any header mutation, and handed by reference to every consumer; this is equivalent by construction because everyHostrewrite (set_upstream_host, thedefault_apphardening, the missing-Hostinsertion) runs only after the forwarding headers are built. The helper itself now also borrows instead of allocating in the common cases (URI host without an explicit port,Host-header fallback), which additionally removes one allocation per emitted access-log line. The emittedX-Forwarded-Host,Forwarded(host=), upstreamHosthandling, trust-boundary normalization, routing, and access-log bytes are all unchanged, guarded by the existing forwarding/trusted-proxy test suite plus new pinning tests for every authoritative-host source. CPU/allocation cleanup only (roughly one to two heap allocations per request plus the duplicate computations); end-to-end throughput was not re-measured. - Render IPv4 addresses in the forwarding headers without the generic formatter. Writing the client/peer IPv4 into
X-Forwarded-For(and theX-Real-IPit is sliced from) and into the RFC 7239Forwardedfor=node went throughstd'sIpv4Addr/IpAddrDisplay, which routes each octet through the general-purposecore::fmtinteger path (pad_integral). An instruction-level profile of the behind-proxy path attributed a noticeable slice (~3.4% of per-request instructions) to this IP/integer formatting, and a callgrind microbench on the build toolchain measured stdIpv4AddrDisplay at ~1075 instructions per address versus ~262 withitoa(about 4x fewer). The IPv4 octets are now written with theitoacrate (already present in the dependency tree, now a direct dependency ofrpxy-lib); IPv6 deliberately stays on the std formatter, which already emits the RFC 5952 canonical (::-compressed) form thatitoa(decimal-only) cannot produce, and the port already usedHeaderValue::from(u16). The emittedX-Forwarded-For,X-Real-IP, andForwardedbytes are unchanged, pinned by a new boundary test asserting byte-equality withIpAddr::to_string()across the IPv4 octet ranges plus the existing forwarding/Forwardedtest suite. CPU cleanup on the forwarding path only; end-to-end throughput was not re-measured. - Skip rebuilding the
Cookieheader when there is nothing to merge. Collapsing a request'sCookielines into one line (HTTP/2 clients may split a cookie across severalCookieheaders) scanned the entire header map on every request, collected the matching values into aVec,join-ed them into a freshString, and then removed and re-inserted the header — even when zero or oneCookieline was present and there was nothing to merge. It now reads the values directly viaget_all, returns immediately when fewer than two lines exist (a single line is already single-line), and builds the joined buffer — sized exactly up front — only when two or more lines are present. A callgrind microbench of this step measured the common single-Cookierequest (typical browser traffic) at roughly 1480 instructions before versus 200 after, with two fewer heap allocations (theVecand the joinedString) and the remove/insert skipped; the cookieless path is instruction-neutral. The merged bytes, the line ordering, and the lossyto_strfallback for non-ASCII values are unchanged, pinned by new tests for the zero-, one-, and multi-line cases. CPU/allocation cleanup only; end-to-end throughput was not re-measured. - Precompute the upstream
Hostheader value instead of rebuilding it per request. Theset_upstream_hostupstream option overwrites the requestHostwith the chosen upstream's host. This re-derived the value from the upstream URI on every such request —host().to_string(), then (when a port is present) a secondformat!("{host}:{port}")that discarded the first allocation, thenHeaderValue::from_strre-validating the result — even though the upstream URI, and hence thisHostvalue, is fixed at configuration-build time. The value (hostorhost:port) is now rendered once when theUpstreamis built and stored on it; the per-request override clone-inserts the readyHeaderValue(aBytesrefcount bump — no formatting, no validation, no allocation). A callgrind microbench measured the value production at roughly 2170 instructions before versus 55 after. The emittedHostis byte-identical and theKeepOriginalHostprecedence is unchanged; an upstream URI without a host still yields the same "No hostname is given" error (now raised at the override when the precomputed value is absent), pinned by the existingset_upstream_host/keep_original_hosttests plus a new no-host case. CPU/allocation cleanup only on theset_upstream_hostpath; end-to-end throughput was not re-measured. - Read the forwarding headers by borrowing instead of join-then-resplit. Parsing
X-Forwarded-ForandForwarded(and the sticky-cookie proto readers) first called a helper that collected every field-line value into aVec<String>andjoin-ed them into oneString, which the caller then immediately tore apart again withsplit(',')— so the common single-field-line case spent three heap allocations (a per-valueString, theVec, and the joinedString) to produce a value that is only borrowed and split. The helper now returns aCow<str>: a single field-line is borrowed directly from the header value (no allocation), while the rare multi-field-line case still validates every value and joins them with ", " exactly as before — so an unreadable line after the first still errors, preserving the fail-closed behavior the proto /Securedecision depends on. The first-value helper likewise returns a borrowed&str(only the one owned copy that is actually stored is kept). A callgrind microbench of the single-line read measured roughly 1140 instructions before versus 835 after (the three eliminated allocations). The parsed chain, the multi-line and empty-segment handling, and every error path are unchanged, pinned by the existing forwarding/trusted-proxy suite plus new helper-level tests (single-line borrows, multi-line joins, and a non-first unreadable line still errors). CPU/allocation cleanup on the behind-proxy parse path only; end-to-end throughput was not re-measured. - Dispatch the upstream header options by set membership instead of iterating the option set. Applying the per-upstream options (
set_upstream_host,upgrade_insecure_requests,forwarded_header) iterated the optionsHashSetand matched each variant, with theKeepOriginalHost-overrides-SetUpstreamHostprecedence expressed as acontainscheck nested inside theSetUpstreamHostarm. Since the set already answers membership in O(1) and the three actions are independent (each writes a different header and reads none that the others write), the function now tests membership directly: theHostoverride (with itsKeepOriginalHostprecedence as a top-level boolean), theUpgrade-Insecure-Requestsinsertion, and theForwardedgeneration each run from a plainif. The emitted headers on the success path are byte-identical and the option precedence is unchanged, now pinned by the existing host/forwarded tests plus newUpgrade-Insecure-Requestscases (inserted when the option is set, absent when unset, and a pre-existing value preserved). This is a readability/maintainability cleanup, not a performance change. - Precompute the sticky-cookie name prefix and stop owning every sticky token (
sticky-cookiefeature). Two cleanups in the same code path. (1) The"{name}="cookie prefix used to locate the sticky token was rebuilt withformat!on every sticky-LB request, in both the takeout and the set paths — yetnameis a config-fixed value.StickyCookieConfigalready precomputes its AEAD AAD intry_new; the name prefix is now precomputed alongside it (same all-private/try_new-only/immutable invariant, so the prefix can never disagree with the name), and both call sites read it viasticky_config.name_prefix()instead of formatting. (2) The takeout helper used toto_stringevery sticky token into aVec<String>to release the immutable borrow before mutatingheaders, even though it immediately rejects the request unless exactly one sticky token exists — so at most one token was ever used. It now owns only the first token plus amultiple_stickyboolean; the multi-sticky rejection still fires (and still runs after the upstream-Cookierewrite, unchanged). A callgrind microbench of the takeout parse-and-partition step (single-sticky request) measured roughly 2580 instructions before versus 1850 after (~28%), with the per-requestformat!and the surplusVec<String>gone. The matched bytes, the multi-sticky rejection, the AEAD open/verify, and every reject/ignore path are unchanged, pinned by the existing sticky-cookie suite plus aname_prefix()precompute test that mirrors the AAD precompute test. CPU/allocation cleanup on the sticky-LB path only; end-to-end throughput was not re-measured.
What's Changed
- feat(tls): enable stateless session tickets for non-mTLS apps and disable server-side TLS session caching by @junkurihara in #603
- perf(headers): remove hop-by-hop headers via pre-built HeaderName constants by @junkurihara in #604
- perf(logging): skip building the access-log record when no line can be emitted by @junkurihara in #605
- perf(headers): compute the authoritative request host once per request by @junkurihara in #606
- perf(headers): assemble forwarding-header output in single buffers by @junkurihara in #607
- perf(headers): format IPv4 in forwarding headers via itoa instead of core::fmt by @junkurihara in #608
- perf(headers): skip Cookie header rebuild when there is nothing to merge by @junkurihara in #609
- perf(headers): precompute the upstream Host header value instead of rebuilding per request by @junkurihara in #610
- perf(headers): read forwarding headers by borrowing instead of join-then-resplit by @junkurihara in #611
- refactor(headers): dispatch upstream options by set membership instead of a loop+match by @junkurihara in #612
- perf(sticky-cookie): precompute cookie name prefix and stop owning every sticky token by @junkurihara in #613
- refactor(sticky-cookie): parse token via split_once('=') instead of collecting an '=' split by @junkurihara in #614
- refactor(name_exp): render ServerName via Display instead of TryInto roundtrip by @junkurihara in #615
- Feat/inspect parse host simplify by @junkurihara in #616
- fix(message_handler): detect TE: trailers token case-insensitively (RFC 9110) by @junkurihara in #617
- refactor(hop): take Connection out of HeaderMap upfront in remove_connection_header by @junkurihara in #618
- fix(forwarding): parse Forwarded for= node-port per RFC 7239 6 (port / obfport) by @junkurihara in #619
- 0.13.1 by @junkurihara in #620
- Merge pull request #620 from junkurihara/develop by @junkurihara in #621
Full Changelog: 0.13.0...0.13.1