Skip to content

0.13.1

Choose a tag to compare

@github-actions github-actions released this 15 Jun 15:09
a13e2b0

This release focuses on the elegance of 'fast, simple, and clean'.

Bugfix

  • Fix: parse Forwarded for= node-port per RFC 7239 §6 (port / obfport). The parser stored the port as a free-form String and validated it as a u16 only 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 the unknown / obfuscated-nodename arms any byte sequence was silently accepted as the port (so for=unknown:abc, for=_node:99999 round-tripped non-port garbage through rpxy; with an unquoted " inside the body, the regenerated Forwarded header could even break the quoted-string syntax on the wire). The port field is now a ForwardedPort sum type (Numeric(u16) / Obfuscated(String)) with a valid-by-construction invariant on the obfuscated payload (non-empty body, every byte in the ABNF set ALPHA / DIGIT / "." / "_" / "-"); parse_forwarded_port returns 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 strict 1*5DIGIT ABNF (rejecting +443, -1, "", 000000, etc. that u16::from_str would otherwise canonicalize). The writer (write_forwarded_node) emits Numeric via write! (matching the existing IPv6 / Unknown / Obfuscated arms) and Obfuscated via push_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 existing generate_forwarded_header_rare_node_arms test); 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 the parse → to_string round-trip the previous shape carried, so this brings the three arms into line. The type refactor also drops one u16 -> String round-trip per IP-bearing for= entry. New tests cover the newly accepted IP+obfport case, the newly rejected unknown: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's starts_with('_')) keeps its existing looseness - tightening it is a separate question. No public API surface is affected (ForwardedNode is private).
  • Fix: detect the TE: trailers token per RFC 9110 (case-insensitive, OWS includes HTAB). Detecting whether the incoming request signalled willingness to accept trailers compared the token as raw bytes against b"trailers" and split list items on comma or space only, so any non-lowercase spelling - Trailers, TRAILERS, or Trailers inside a list (gzip, Trailers) - was silently missed, as was a tab-separated list (gzip,\tTrailers), and the upstream-bound request was sent without the te: trailers header 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 in extract_upgrade/hop.rs already uses eq_ignore_ascii_case. The match now uses <[u8]>::eq_ignore_ascii_case and 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 to is_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 a trailers-extra substring 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 emit te: trailers upstream, which - per RFC 9110 - may let the backend send response trailers that are forwarded downstream.

Improvement

  • Simplify remove_connection_header by taking the Connection header out of the map upfront. Removing the per-hop headers listed by a request's / response's Connection value cloned the whole HeaderValue so its to_str() borrow detached from the HeaderMap, allowing the subsequent headers.remove(name) calls. The function now removes the Connection header itself first (yielding an owned HeaderValue whose borrow is independent of the map), then iterates the comma-separated name list and removes each listed header - no HeaderValue clone, no scratch allocation. The contract is widened to "this function also removes Connection itself"; both call sites (handler_manipulate_messages.rs:30, 90) pair this function with remove_hop_header (which already lists header::CONNECTION), so the end-to-end behaviour is unchanged. HOP_HEADERS keeps CONNECTION as defensive redundancy. Pinned by new hop::tests cases covering listed-name removal, case-insensitivity, whitespace/empty-segment handling, an absent header, unparseable names being silently skipped, and the new Connection-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_host stripped the :port suffix via three .split(..).next().ok_or_else(..) chains whose Err branches 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 returning Vec<u8> directly: bracketed IPv6 uses strip_prefix(b"[") + position(b']') (the previous lenient unterminated-bracket case is preserved), bare IPv6 uses iter().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 in inspect_parse_host collapses its now-unreachable Some(Ok)/Some(Err) distinction into Some(_) / (None, None) => Err(..), and the InspectParseHost trait'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 [v6 no-] case), pinned by a mod tests covering those eleven cases. Readability cleanup only; no observable behavior change.
  • Render ServerName directly via Display instead of a TryInto<String> roundtrip everywhere it was logged or formatted. Five sites (handler_main redirect debug log, the H/3 connection-established debug log, the two NoTlsServingApp error 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. ServerName now implements Display (zero-allocation on the UTF-8 path the request flow normally produces; falls back to U+FFFD via from_utf8_lossy instead 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 one String allocation per emitted line (the disabled-callsite path was already a no-op); the error and URL sites are unchanged allocation-wise. The TryInto<String> for &ServerName impl 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-cookie feature). StickyCookieValue::try_from split the cookie token on every = into a Vec<&str> and required exactly two slices, allocating a two-element vector per sticky-LB request and rejecting any value that itself contained =. It now uses split_once('='): no allocation, and the value keeps any further = (a cookie name=value pair 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 in open_server_id rather 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 new try_from cases (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. The http3-s2n backend 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 with RUST_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 resolved RUST_LOG level 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 &str keys, so the http crate parsed each string into a HeaderName and 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-built HeaderName constants (constructed at compile time), so removal skips the string parse entirely and standard names take the http crate'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-For built one String per hop plus a list and a joined copy before the header value was created, and generating the RFC 7239 Forwarded header built up to four Strings 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-For hands that buffer to the header value without re-copying, X-Real-IP is sliced out of its first element (same bytes as before, one copy — previously the copy direction was reversed), and the Forwarded writer 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 rare for= 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; each Forwarded hop: ~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 Host header) was recomputed — with a fresh String allocation each time — at up to four places per request: the peer Forwarded entry, the X-Forwarded-Host rewrite, the optional RFC 7239 Forwarded generation (forwarded_header upstream option), and the access log. It is now computed once at the top of the request-forwarding path, from the original URI and Host header captured before any header mutation, and handed by reference to every consumer; this is equivalent by construction because every Host rewrite (set_upstream_host, the default_app hardening, the missing-Host insertion) 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 emitted X-Forwarded-Host, Forwarded (host=), upstream Host handling, 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 the X-Real-IP it is sliced from) and into the RFC 7239 Forwarded for= node went through std's Ipv4Addr/IpAddr Display, which routes each octet through the general-purpose core::fmt integer 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 std Ipv4Addr Display at ~1075 instructions per address versus ~262 with itoa (about 4x fewer). The IPv4 octets are now written with the itoa crate (already present in the dependency tree, now a direct dependency of rpxy-lib); IPv6 deliberately stays on the std formatter, which already emits the RFC 5952 canonical (::-compressed) form that itoa (decimal-only) cannot produce, and the port already used HeaderValue::from(u16). The emitted X-Forwarded-For, X-Real-IP, and Forwarded bytes are unchanged, pinned by a new boundary test asserting byte-equality with IpAddr::to_string() across the IPv4 octet ranges plus the existing forwarding/Forwarded test suite. CPU cleanup on the forwarding path only; end-to-end throughput was not re-measured.
  • Skip rebuilding the Cookie header when there is nothing to merge. Collapsing a request's Cookie lines into one line (HTTP/2 clients may split a cookie across several Cookie headers) scanned the entire header map on every request, collected the matching values into a Vec, join-ed them into a fresh String, and then removed and re-inserted the header — even when zero or one Cookie line was present and there was nothing to merge. It now reads the values directly via get_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-Cookie request (typical browser traffic) at roughly 1480 instructions before versus 200 after, with two fewer heap allocations (the Vec and the joined String) and the remove/insert skipped; the cookieless path is instruction-neutral. The merged bytes, the line ordering, and the lossy to_str fallback 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 Host header value instead of rebuilding it per request. The set_upstream_host upstream option overwrites the request Host with 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 second format!("{host}:{port}") that discarded the first allocation, then HeaderValue::from_str re-validating the result — even though the upstream URI, and hence this Host value, is fixed at configuration-build time. The value (host or host:port) is now rendered once when the Upstream is built and stored on it; the per-request override clone-inserts the ready HeaderValue (a Bytes refcount bump — no formatting, no validation, no allocation). A callgrind microbench measured the value production at roughly 2170 instructions before versus 55 after. The emitted Host is byte-identical and the KeepOriginalHost precedence 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 existing set_upstream_host/keep_original_host tests plus a new no-host case. CPU/allocation cleanup only on the set_upstream_host path; end-to-end throughput was not re-measured.
  • Read the forwarding headers by borrowing instead of join-then-resplit. Parsing X-Forwarded-For and Forwarded (and the sticky-cookie proto readers) first called a helper that collected every field-line value into a Vec<String> and join-ed them into one String, which the caller then immediately tore apart again with split(',') — so the common single-field-line case spent three heap allocations (a per-value String, the Vec, and the joined String) to produce a value that is only borrowed and split. The helper now returns a Cow<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 / Secure decision 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 options HashSet and matched each variant, with the KeepOriginalHost-overrides-SetUpstreamHost precedence expressed as a contains check nested inside the SetUpstreamHost arm. 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: the Host override (with its KeepOriginalHost precedence as a top-level boolean), the Upgrade-Insecure-Requests insertion, and the Forwarded generation each run from a plain if. 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 new Upgrade-Insecure-Requests cases (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-cookie feature). Two cleanups in the same code path. (1) The "{name}=" cookie prefix used to locate the sticky token was rebuilt with format! on every sticky-LB request, in both the takeout and the set paths — yet name is a config-fixed value. StickyCookieConfig already precomputes its AEAD AAD in try_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 via sticky_config.name_prefix() instead of formatting. (2) The takeout helper used to to_string every sticky token into a Vec<String> to release the immutable borrow before mutating headers, 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 a multiple_sticky boolean; the multi-sticky rejection still fires (and still runs after the upstream-Cookie rewrite, 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-request format! and the surplus Vec<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 a name_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