sozu_lib/protocol/mux/router.rs
1//! Backend routing and connection reuse for the mux layer.
2//!
3//! [`Router`] owns the map of token -> backend [`Connection`] and centralises
4//! the logic for picking (or opening) the right backend for an incoming
5//! request. The H2 reuse strategy prefers the least-loaded non-draining
6//! connection of the target cluster; H1 falls back to keep-alive reuse.
7//!
8//! ## Determinism (nothing mandates a tie-break order; issue #1338 does)
9//!
10//! [`Router::backends`] used to be a `HashMap<Token, _>`, and
11//! `Router::plan_connect` scans it with a plain `for (token, backend) in
12//! &self.backends` loop, in the map's iteration order — seeded per-`HashMap`
13//! (`RandomState`), so **which backend served a request was non-deterministic
14//! across process restarts**, for the identical set of backend connections and
15//! the identical request. That is a strictly wider leak than the wire-order
16//! ones `h2_flow_control` and `h2_stream_table` closed: it
17//! does not reorder bytes on one connection, it sends them to a different
18//! machine.
19//!
20//! Three separate decisions inside that one loop read the order, and they do
21//! not read it the same way:
22//!
23//! - the H2 least-loaded arm compares with a strict `<`, so the FIRST
24//! connection at the minimum stream count wins — on a tie, map order alone
25//! picks the backend;
26//! - the H2 `BackendStatus::Connecting` fallback assigns with neither a
27//! `break` nor an "already chosen" guard, so the LAST matching connecting
28//! connection wins;
29//! - the H1 `BackendStatus::KeepAlive` arm assigns and breaks, so the FIRST
30//! matching keep-alive socket wins.
31//!
32//! The map is now a [`BackendConnections`] (a `BTreeMap` behind one inline
33//! slot for the lowest token, #1610), so the scan is the total order on
34//! `Token` — fixed, reproducible, and free (its iteration is already sorted;
35//! no extra sort step, no injected seed to thread through construction and
36//! tests). The three decisions above resolve to the lowest `Token`, the
37//! highest `Token` and the lowest `Token` respectively. Pinning is all this
38//! does: the last-wins shape of the connecting fallback is preserved, not
39//! corrected, because changing which backend is preferred is a routing change
40//! and not a determinism one.
41//!
42//! A `Token` is the slab index the proxy's session manager handed the backend
43//! socket at dial time, and it carries no fairness or recency meaning of its
44//! own: `slab::Slab::try_remove` pushes the freed index onto the head of its
45//! vacant list (`self.next = key`) and `insert` pops that head, so a dial
46//! reuses the most recently RELEASED index, not the lowest and not the
47//! oldest. The total order is a stable arbitrary label. It does not need to
48//! be more than that for the
49//! least-loaded arm, and for the reason `h2_stream_table`'s doc had
50//! to spell out and `h2_flow_control`'s could not: a tie there does
51//! not persist across requests. Attaching the stream increments the winner's
52//! `ConnectionH2::stream_count`, so the next request sees it above the minimum
53//! and takes the next token — the load counter is itself the fairness cursor
54//! `pending_window_updates` had to note the absence of. The connecting
55//! fallback has no such counter, but every candidate it ranks is a connection
56//! that cannot serve anything yet, and it concentrated every waiting stream on
57//! one of them before this change too; the total order fixes WHICH one, it
58//! does not change how many.
59//!
60//! ## Complexity — UNMEASURED beyond the default configuration
61//!
62//! `n` here is the number of live backend connections on ONE frontend session,
63//! not a worker-wide or cluster-wide count. An H2 frontend reaching an H2
64//! cluster holds roughly one multiplex slot per cluster it touches. An H2
65//! frontend reaching an H1 cluster cannot bundle streams, so it holds up to
66//! one socket per concurrent stream — bounded by the `max_concurrent_streams`
67//! this proxy advertises, `DEFAULT_MAX_CONCURRENT_STREAMS` (100) by default
68//! and clamped by `H2ConnectionConfig::new` to `MAX_SAFE_CONCURRENT_STREAMS`
69//! (10_000).
70//!
71//! What changes complexity class is the point lookup: `get` / `get_mut` /
72//! `insert` / `remove` / `contains_key` go from `HashMap`'s amortized `O(1)`
73//! to `BTreeMap`'s `O(log n)` (the inline slot adds one comparison), and
74//! `Mux::ready` does one `get_mut`
75//! per backend event through `EndpointClient`. Every iteration site —
76//! `Router::plan_connect`'s scan, `Mux::reschedule`, the two
77//! `Mux::ready` sweeps — already walks all `n` and keeps its `O(n)`. At the
78//! default ceiling (`n <= 100`) a lookup is ~7 `usize` comparisons against one
79//! SipHash-1-3 of a `usize` plus a bucket probe, which is not expected to be
80//! measurable; at a listener configured near `MAX_SAFE_CONCURRENT_STREAMS`
81//! (`n` up to 10_000, ~14 comparisons) it stops being self-evidently
82//! negligible. That expectation has **not been benchmarked**, neither before
83//! nor after this change. Closing it requires a before/after run of this
84//! repository's `Bombardier bench` CI job (or an equivalent local
85//! `bombardier` run) against an H1 cluster behind an H2 frontend at or near
86//! that ceiling; no such run has been produced.
87
88use std::{
89 cell::RefCell,
90 collections::{BTreeMap, HashMap},
91 net::{IpAddr, SocketAddr},
92 rc::Rc,
93 time::Duration,
94};
95
96use kawa::Block;
97use mio::Token;
98use sozu_command::{
99 logging::ansi_palette,
100 proto::command::{
101 Cluster, ListenerType, LoadBalancingAlgorithms, RedirectPolicy, RedirectScheme,
102 },
103 state::ClusterId,
104};
105
106#[cfg(debug_assertions)]
107use super::DebugEvent;
108use super::{
109 BackendId, BackendStatus, Connection, Context, GenericHttpStream, GlobalStreamId, Position,
110 StreamState,
111};
112use crate::{
113 BackendConnectionError, L7ListenerHandler, ListenerHandler, Readiness, RetrieveClusterError,
114 backends::BackendError,
115 load_balancing::{affinity_key_from_ip, affinity_key_from_value},
116 protocol::http::{
117 editor::{HeaderEditMode, HeaderEditSnapshot, HttpContext, emits_x_forwarded},
118 parser::compare_no_case,
119 },
120 router::{HeaderEdit, RequestHeaders, RouteResult},
121 socket::SessionTcpStream,
122};
123
124use crate::metrics::names;
125
126/// Module-level prefix used on every log line emitted from the router.
127///
128/// Two arms:
129/// * `log_module_context!()` — zero-arg, legacy `MUX-ROUTER\t >>>` output.
130/// Kept for sites without an `HttpContext` in scope. No call site in this
131/// module currently uses this arm (every one has an `HttpContext` reachable
132/// via [`Context::http_context`] or a direct `&mut HttpContext`
133/// parameter), but the arm is retained so the macro name stays stable for
134/// future sessionless callers.
135/// * `log_module_context!($http_context)` — rich form. `$http_context` must be
136/// `&HttpContext` (or coerce to one). Produces the same
137/// `[session req cluster backend]` bracket as RUSTLS/PIPE/TCP followed by a
138/// `Session(frontend=..., method=..., authority_bytes=...)` block, so router
139/// lines are filterable by session ULID or request ULID. `cluster_id` is
140/// already carried by the bracket's third slot — not duplicated inside
141/// `Session(...)`.
142macro_rules! log_module_context {
143 () => {{
144 let (open, reset, _, _, _) = ansi_palette();
145 format!("{open}MUX-ROUTER{reset}\t >>>", open = open, reset = reset)
146 }};
147 ($http_context:expr) => {{
148 let (open, reset, grey, gray, white) = ansi_palette();
149 let http_ctx: &HttpContext = &$http_context;
150 let ctx = http_ctx.log_context();
151 format!(
152 "{gray}{ctx}{reset}\t{open}MUX-ROUTER{reset}\t{grey}Session{reset}({gray}frontend{reset}={white}{frontend:?}{reset}, {gray}method{reset}={white}{method:?}{reset}, {gray}authority_bytes{reset}={white}{authority_bytes:?}{reset})\t >>>",
153 open = open,
154 reset = reset,
155 grey = grey,
156 gray = gray,
157 white = white,
158 ctx = ctx,
159 frontend = http_ctx.session_address,
160 method = http_ctx.method,
161 authority_bytes = http_ctx.authority.as_ref().map(String::len),
162 )
163 }};
164}
165
166fn log_coalescing_accepted(context: &HttpContext, authority: &str, sni: &str, matched_name: &str) {
167 debug!(
168 "{} accepted coalesced authority (authority_bytes={}, sni_bytes={}, matched_san_kind={}, matched_san_bytes={})",
169 log_module_context!(context),
170 authority.len(),
171 sni.len(),
172 if matched_name.starts_with("*.") {
173 "wildcard"
174 } else {
175 "exact"
176 },
177 matched_name.len(),
178 );
179}
180
181fn log_sni_authority_mismatch(
182 context: &HttpContext,
183 authority: &str,
184 sni: &str,
185 certificate_names: Option<&[String]>,
186) {
187 let certificate_sans_count = certificate_names.map_or(0, <[String]>::len);
188 let certificate_sans_bytes = certificate_names
189 .into_iter()
190 .flatten()
191 .map(String::len)
192 .fold(0usize, usize::saturating_add);
193 warn!(
194 "{} rejecting request: TLS cert SANs do not cover authority (authority_bytes={}, sni_bytes={}, certificate_sans_count={}, certificate_sans_bytes={})",
195 log_module_context!(context),
196 authority.len(),
197 sni.len(),
198 certificate_sans_count,
199 certificate_sans_bytes,
200 );
201}
202
203/// The cluster-side configuration the routing decision reads, borrowed from
204/// the embedder for the length of one decision.
205///
206/// Question 6 of [#1340](https://github.com/sozu-proxy/sozu/issues/1340) puts
207/// routing inside the core; this is how the data that routing needs gets
208/// there without the core holding the handle it used to reach through.
209/// `L7Proxy::clusters` and `L7Proxy::kind` were two of the three reads
210/// `Router::plan_connect` made against `Rc<RefCell<dyn L7Proxy>>`, and both
211/// are pure. They arrive as this instead.
212///
213/// The mechanism is Question 12's, applied a second time rather than
214/// reinvented: a view in with a defined staleness, a result out. The
215/// staleness here is **one routing decision** — the view is built once per
216/// `Router::plan_connect` call and every read inside that call sees the same
217/// cluster map.
218///
219/// That is marginally *stronger* than what it replaces, and deliberately so:
220/// the two sites used to take separate `proxy.borrow()`s, one in
221/// `Router::plan_connect` and one in `Router::route_from_request`, so a
222/// cluster update landing between them would have been half-observed. No
223/// such update can interleave — `clusters()` is owned by the worker's
224/// command loop and a stream is drained from `pending_links` inside the very
225/// `ready()` pass that queued it — so this closes a window that was never
226/// open rather than changing a behaviour that was.
227///
228/// A simulator can build one of these from whatever cluster map it likes,
229/// which is what Q6's determinism argument wants and what an
230/// `Rc<RefCell<dyn L7Proxy>>` could never give it.
231pub(super) struct RoutingView<'a> {
232 clusters: &'a HashMap<ClusterId, Cluster>,
233 listener_kind: ListenerType,
234 /// Whether the backend a pooled connection was dialled to has left the
235 /// configuration. `None` treats every backend as current.
236 backend_retired: Option<&'a dyn Fn(&BackendId) -> bool>,
237 /// The worker's backend connection attempt budget, for a cluster that
238 /// sets no `max_connection_attempts` of its own.
239 max_connection_attempts: u32,
240}
241
242impl<'a> RoutingView<'a> {
243 pub(super) fn new(
244 clusters: &'a HashMap<ClusterId, Cluster>,
245 listener_kind: ListenerType,
246 ) -> Self {
247 Self {
248 clusters,
249 listener_kind,
250 backend_retired: None,
251 max_connection_attempts: sozu_command::config::DEFAULT_MAX_CONNECTION_ATTEMPTS,
252 }
253 }
254
255 /// Use `attempts` as the backend connection attempt budget of a request
256 /// whose cluster sets none: the worker's global value,
257 /// `SessionManager::max_connection_attempts` (`lib/src/server.rs`).
258 pub(super) fn with_max_connection_attempts(mut self, attempts: u32) -> Self {
259 self.max_connection_attempts = attempts;
260 self
261 }
262
263 /// The backend connection attempt budget of a request routed to
264 /// `cluster_id`: the cluster's own `max_connection_attempts` when it sets
265 /// one, the worker's otherwise (sozu-proxy/sozu#1800).
266 fn max_connection_attempts(&self, cluster_id: Option<&str>) -> u32 {
267 cluster_id
268 .and_then(|cluster_id| self.cluster(cluster_id))
269 .and_then(|cluster| cluster.max_connection_attempts)
270 .unwrap_or(self.max_connection_attempts)
271 }
272
273 /// Answer the pool-reuse scan's question "is this connection's backend
274 /// still configured?" with `retired`.
275 ///
276 /// A backend connection names its cluster by id, and a cluster id outlives
277 /// a `RemoveCluster` followed by an `AddCluster` of the same id, as it
278 /// outlives a `RemoveBackend`. Only the embedder can tell whether the
279 /// backend behind a [`BackendId`] is still part of the configuration, so
280 /// the reuse scan asks it here and never reuses a connection whose backend
281 /// is retired: that connection drains the streams it carries and takes no
282 /// new one.
283 pub(super) fn with_backend_retired(mut self, retired: &'a dyn Fn(&BackendId) -> bool) -> Self {
284 self.backend_retired = Some(retired);
285 self
286 }
287
288 /// Whether `backend` has left the configuration since a connection was
289 /// dialled to it.
290 fn is_retired(&self, backend: &BackendId) -> bool {
291 self.backend_retired.is_some_and(|retired| retired(backend))
292 }
293
294 /// The cluster `cluster_id` names, if the worker still holds one.
295 fn cluster(&self, cluster_id: &str) -> Option<&Cluster> {
296 self.clusters.get(cluster_id)
297 }
298
299 /// Whether this listener is plaintext HTTP, which is what both legacy
300 /// `https_redirect` sites gate on.
301 fn is_http_listener(&self) -> bool {
302 matches!(self.listener_kind, ListenerType::Http)
303 }
304}
305
306/// What [`Router::plan_connect`] answers: either a finished decision, or a
307/// step it needs the embedder to perform before it can finish.
308///
309/// The per-`(cluster, source-IP)` limit gate is the reason this exists. It is
310/// not a read — it consults `SessionManager::cluster_ip_at_limit` **and**
311/// calls `SessionManager::track_cluster_ip`, which mutates
312/// `connections_per_cluster_ip`, worker-global state keyed on
313/// `(cluster, ip)` across every session and read again by `lib/src/tcp.rs`'s
314/// own gate. A read-only view cannot carry a mutation, so the gate becomes a
315/// returned step instead: the core resolves the cluster, hands the embedder
316/// what to ask, and the embedder answers.
317#[derive(Debug)]
318pub(super) enum ConnectStep {
319 /// No gate consultation was needed — the stream carries no source IP —
320 /// so this is already the decision.
321 Decided(ConnectPlan),
322 /// The core resolved a cluster and needs the limit consulted, and
323 /// updated, before it can go on. Answer with
324 /// [`Router::plan_connect_resume`].
325 CheckIpLimit(ConnectResume),
326}
327
328/// The phase-one results the core needs handed back to finish deciding, plus
329/// what the embedder needs to consult the gate.
330///
331/// Opaque on purpose: the embedder reads the gate inputs through accessors
332/// and cannot reach — or forge — the routing results the core parked here.
333/// The cluster the gate is keyed on is not among them: `plan_connect` has
334/// already stored it in the stream's `HttpContext`, and the embedder borrows
335/// it from there rather than the resume carrying a copy of its own (#1583).
336/// [`Router::plan_connect_resume`] takes it **by value**, so a resume cannot
337/// be replayed; that is `H2Shell::settled` taking its parked target, applied
338/// to this seam.
339#[derive(Debug)]
340pub(super) struct ConnectResume {
341 h2: bool,
342 frontend_should_stick: bool,
343 ip: IpAddr,
344 max_connections_per_ip: Option<u64>,
345 cluster_retry_after: Option<u32>,
346 max_connections_per_subnet: Option<u64>,
347}
348
349impl ConnectResume {
350 /// The source IP the gate is keyed on — proxy-protocol-aware when
351 /// present, falling back to `peer_addr`.
352 pub(super) fn ip(&self) -> IpAddr {
353 self.ip
354 }
355 /// The cluster's per-IP connection limit override, if it set one.
356 pub(super) fn max_connections_per_ip(&self) -> Option<u64> {
357 self.max_connections_per_ip
358 }
359 /// The cluster's `Retry-After` override, if it set one.
360 pub(super) fn cluster_retry_after(&self) -> Option<u32> {
361 self.cluster_retry_after
362 }
363 /// The cluster's per-SUBNET connection limit override, if it set
364 /// one. Independent of `max_connections_per_ip`: the embedder
365 /// consults both caps and admits only when both allow it.
366 pub(super) fn max_connections_per_subnet(&self) -> Option<u64> {
367 self.max_connections_per_subnet
368 }
369}
370
371/// The embedder's answer to [`ConnectStep::CheckIpLimit`].
372///
373/// `Admitted` carries no payload on purpose: it means the embedder has
374/// already *performed* the track, so there is nothing left for the core to
375/// decide about it and no binding a later edit could reuse to ask a question
376/// this variant has answered — the discipline
377/// `H2WritableStateTarget::Flush` set by carrying no `bool`.
378#[derive(Debug)]
379pub(super) enum IpGateVerdict {
380 /// Under the limit, and the embedder has tracked this `(cluster, ip)`.
381 Admitted,
382 /// At the limit. Carries the resolved `Retry-After` the core stashes on
383 /// the stream for the 429 mapping to render or elide.
384 AtLimit { retry_after: u32 },
385}
386
387/// What [`Router::plan_connect`] decided, for the embedder to fulfil.
388///
389/// Question 6 of [#1340](https://github.com/sozu-proxy/sozu/issues/1340): the
390/// core decides and the embedder performs. This is the "returned request"
391/// half of that, in the shape the socket-boundary extraction used four times
392/// (`finalize_write` / `H2FinalizeTarget`, `flush_pending_control_frames` /
393/// `H2ControlFlushTarget`, `dispatch_writable_state` / `H2WritableStateTarget`,
394/// and `force_disconnect`'s inversion): the core answers a step, the caller
395/// performs the effect, the caller calls back with the answer —
396/// [`Router::commit_dialed`] here.
397#[derive(Debug)]
398pub(super) enum ConnectPlan {
399 /// The stream was attached to a backend connection the router already
400 /// held, and nothing is left to perform. Carries no token on purpose: the
401 /// attach is complete, so there is no binding a later edit could reuse to
402 /// ask a question this variant has already answered — the discipline
403 /// `H2WritableStateTarget::Flush` set by carrying no `bool`.
404 Attached,
405 /// No connection could be reused. The embedder must select a backend,
406 /// dial it, build the `Connection`, register it, and hand the result
407 /// back to [`Router::commit_dialed`].
408 Dial {
409 /// The cluster routing resolved for this stream.
410 cluster_id: ClusterId,
411 /// Whether that cluster's backends speak HTTP/2.
412 h2: bool,
413 /// Whether the frontend asked for sticky-session affinity.
414 frontend_should_stick: bool,
415 },
416}
417
418/// The backend connections of one session, keyed and iterated by [`Token`].
419///
420/// A session almost always holds exactly one backend connection, and a
421/// `Connection<SessionTcpStream>` is about 1.7 KiB. A `BTreeMap` stores its
422/// values inline in 11-slot leaves, so the first insertion into one allocated
423/// ~18.8 KiB to hold that single connection (#1610). The lowest-token
424/// connection therefore lives inline in `first`, and only the others reach
425/// the `BTreeMap`: one backend costs no allocation at all, and every point
426/// operation stays `O(log n)` for the multi-backend sessions the module
427/// header sizes.
428///
429/// Invariant: `first` is `None` only when `rest` is empty, and its token is
430/// below every token in `rest`. Iteration is `first` then `rest`, which is the
431/// total order on `Token` #1338 requires.
432///
433/// HAProxy needs no container here because a stream has one server
434/// connection, reached by pointer (`include/haproxy/stream-t.h`, `scb`); a
435/// mux session can hold several, hence the overflow map.
436pub struct BackendConnections {
437 first: Option<(Token, Connection<SessionTcpStream>)>,
438 rest: BTreeMap<Token, Connection<SessionTcpStream>>,
439}
440
441impl BackendConnections {
442 pub const fn new() -> Self {
443 Self {
444 first: None,
445 rest: BTreeMap::new(),
446 }
447 }
448
449 pub fn len(&self) -> usize {
450 usize::from(self.first.is_some()) + self.rest.len()
451 }
452
453 pub fn is_empty(&self) -> bool {
454 self.first.is_none()
455 }
456
457 pub fn contains_key(&self, token: &Token) -> bool {
458 self.get(token).is_some()
459 }
460
461 pub fn get(&self, token: &Token) -> Option<&Connection<SessionTcpStream>> {
462 match &self.first {
463 Some((first, connection)) if first == token => Some(connection),
464 _ => self.rest.get(token),
465 }
466 }
467
468 pub fn get_mut(&mut self, token: &Token) -> Option<&mut Connection<SessionTcpStream>> {
469 match &mut self.first {
470 Some((first, connection)) if first == token => Some(connection),
471 _ => self.rest.get_mut(token),
472 }
473 }
474
475 /// Insert `connection` under `token`, returning the one it replaces.
476 pub fn insert(
477 &mut self,
478 token: Token,
479 connection: Connection<SessionTcpStream>,
480 ) -> Option<Connection<SessionTcpStream>> {
481 match &mut self.first {
482 None => {
483 self.first = Some((token, connection));
484 None
485 }
486 Some((first, held)) if *first == token => Some(std::mem::replace(held, connection)),
487 Some((first, held)) if token < *first => {
488 let displaced_token = std::mem::replace(first, token);
489 let displaced = std::mem::replace(held, connection);
490 self.rest.insert(displaced_token, displaced)
491 }
492 Some(_) => self.rest.insert(token, connection),
493 }
494 }
495
496 /// Remove and return the connection under `token`.
497 pub fn remove(&mut self, token: &Token) -> Option<Connection<SessionTcpStream>> {
498 match &self.first {
499 Some((first, _)) if first == token => {
500 let removed = std::mem::replace(&mut self.first, self.rest.pop_first());
501 removed.map(|(_, connection)| connection)
502 }
503 _ => self.rest.remove(token),
504 }
505 }
506
507 /// The connections in ascending `Token` order.
508 pub fn iter(&self) -> BackendConnectionsIter<'_> {
509 let split: fn(&BackendEntry) -> (&Token, &Connection<SessionTcpStream>) =
510 |(token, connection)| (token, connection);
511 self.first.iter().map(split).chain(self.rest.iter())
512 }
513
514 /// The connections in ascending `Token` order, mutably.
515 pub fn iter_mut(&mut self) -> BackendConnectionsIterMut<'_> {
516 let split: fn(&mut BackendEntry) -> (&Token, &mut Connection<SessionTcpStream>) =
517 |(token, connection)| (&*token, connection);
518 self.first.iter_mut().map(split).chain(self.rest.iter_mut())
519 }
520
521 pub fn keys(&self) -> impl Iterator<Item = &Token> {
522 self.iter().map(|(token, _)| token)
523 }
524
525 pub fn values_mut(&mut self) -> impl Iterator<Item = &mut Connection<SessionTcpStream>> {
526 self.iter_mut().map(|(_, connection)| connection)
527 }
528}
529
530type BackendEntry = (Token, Connection<SessionTcpStream>);
531
532/// [`BackendConnections::iter`]: the inline connection, then the overflow map.
533pub type BackendConnectionsIter<'a> = std::iter::Chain<
534 std::iter::Map<
535 std::option::Iter<'a, BackendEntry>,
536 fn(&'a BackendEntry) -> (&'a Token, &'a Connection<SessionTcpStream>),
537 >,
538 std::collections::btree_map::Iter<'a, Token, Connection<SessionTcpStream>>,
539>;
540
541/// [`BackendConnections::iter_mut`]: the inline connection, then the overflow map.
542pub type BackendConnectionsIterMut<'a> = std::iter::Chain<
543 std::iter::Map<
544 std::option::IterMut<'a, BackendEntry>,
545 fn(&'a mut BackendEntry) -> (&'a Token, &'a mut Connection<SessionTcpStream>),
546 >,
547 std::collections::btree_map::IterMut<'a, Token, Connection<SessionTcpStream>>,
548>;
549
550impl<'a> IntoIterator for &'a BackendConnections {
551 type Item = (&'a Token, &'a Connection<SessionTcpStream>);
552 type IntoIter = BackendConnectionsIter<'a>;
553
554 fn into_iter(self) -> Self::IntoIter {
555 self.iter()
556 }
557}
558
559impl<'a> IntoIterator for &'a mut BackendConnections {
560 type Item = (&'a Token, &'a mut Connection<SessionTcpStream>);
561 type IntoIter = BackendConnectionsIterMut<'a>;
562
563 fn into_iter(self) -> Self::IntoIter {
564 self.iter_mut()
565 }
566}
567
568impl Default for BackendConnections {
569 fn default() -> Self {
570 Self::new()
571 }
572}
573
574impl std::fmt::Debug for BackendConnections {
575 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
576 f.debug_map().entries(self.iter()).finish()
577 }
578}
579
580#[derive(Debug)]
581pub struct Router {
582 pub backends: BackendConnections,
583 pub configured_backend_timeout: Duration,
584 pub configured_connect_timeout: Duration,
585 /// Fallback readiness used when a backend token is missing from the map.
586 /// This prevents panicking in the Endpoint trait methods that return references.
587 pub(super) fallback_readiness: Readiness,
588 /// Tokens of the backend connections a `Mux::ready` pass found dead,
589 /// awaiting their removal from [`Self::backends`] later in that pass.
590 ///
591 /// Emptied at the start of every sweep and kept for its capacity, so a
592 /// session whose backends close one after another allocates it once
593 /// rather than once per pass that closes one (#1610).
594 pub(super) dead_backends: Vec<Token>,
595}
596
597impl Router {
598 pub fn new(configured_backend_timeout: Duration, configured_connect_timeout: Duration) -> Self {
599 Self {
600 backends: BackendConnections::new(),
601 configured_backend_timeout,
602 configured_connect_timeout,
603 fallback_readiness: Readiness::new(),
604 dead_backends: Vec::new(),
605 }
606 }
607
608 /// Decide what this stream needs, without performing any of it.
609 ///
610 /// Routing, the cluster gate, the per-(cluster, source-IP) limit and the
611 /// pool-reuse scan all happen here. Dialling, the slab session and the
612 /// epoll registration do not: those are the embedder's, and this answers
613 /// a [`ConnectPlan`] asking for them instead of reaching a
614 /// `Rc<RefCell<dyn ProxySession>>` to do them itself.
615 ///
616 /// `session` is gone from this signature entirely — its single use was
617 /// `L7Proxy::add_session`, which now happens in `Mux::ready_inner`.
618 /// `proxy` remains for the three reads this still makes (`clusters`,
619 /// `kind`, `sessions`) and for backend selection, both of which Question
620 /// 6's borrowed-view step takes next.
621 pub(super) fn plan_connect<L: ListenerHandler + L7ListenerHandler>(
622 &mut self,
623 stream_id: GlobalStreamId,
624 context: &mut Context<L>,
625 // The cluster-side configuration this decision reads, borrowed from
626 // the embedder for the length of the call.
627 view: &RoutingView<'_>,
628 ) -> Result<ConnectStep, BackendConnectionError> {
629 let stream = &mut context.streams[stream_id];
630 // when reused, a stream should be detached from its old connection, if not we could end
631 // with concurrent connections on a single endpoint
632 if !matches!(stream.state, StreamState::Link) {
633 error!(
634 "{} stream {} expected to be in Link state, got {:?}",
635 log_module_context!(stream.context),
636 stream_id,
637 stream.state
638 );
639 return Err(BackendConnectionError::MaxSessionsMemory);
640 }
641 #[cfg(debug_assertions)]
642 context
643 .debug
644 .push(DebugEvent::Str(stream.context.get_route()));
645 // The budget is resolved from the cluster the previous attempt routed
646 // to. The first attempt has none yet, and needs none: every budget
647 // allows at least one attempt.
648 let max_attempts = view.max_connection_attempts(stream.context.cluster_id.as_deref());
649 if u32::from(stream.attempts) >= max_attempts {
650 incr!(
651 names::backend::CONNECT_RETRIES_EXHAUSTED,
652 stream.context.cluster_id.as_deref(),
653 stream.context.backend_id.as_deref(),
654 stream.context.cluster_metrics_incarnation()
655 );
656 return Err(BackendConnectionError::MaxConnectionRetries(
657 stream.context.cluster_id.clone(),
658 ));
659 }
660 stream.attempts += 1;
661
662 // Borrow front mutably (so route_from_request can rewrite the request
663 // line authority/path and inject request-side header edits before we
664 // forward to the backend) plus context mutably (so it can stash
665 // redirect_location / www_authenticate / original_authority /
666 // headers_response). We split-borrow manually to keep the rest of
667 // `connect` working with `stream_context` aliasing `stream.context`.
668 //
669 // A REPLAY skips routing entirely. `front.consumed` is the exact
670 // discriminator: it is false on every first connect and on the
671 // untouched-request `EndStreamAction::Reconnect`, and true only once
672 // request bytes have left the front kawa — which is precisely the
673 // `EndStreamAction::ReplayOnFreshBackend` case. Routing already ran
674 // on the first attempt and every decision it made (cluster, legacy
675 // HTTPS redirect, SNI/authority binding, authentication, request-side
676 // header edits) is baked into the captured bytes.
677 //
678 // Re-running it against a front kawa whose blocks are drained cannot
679 // reproduce them: the request line and headers it rewrites are no
680 // longer in `front.blocks`, and `end_of_headers_index` has no anchor
681 // to place injected headers at. Whatever the next `prepare` did emit
682 // would land BEHIND the replayed bytes, because `Kawa::prepare`
683 // appends to `out` while `Stream::queue_upstream_replay` prepends —
684 // a second, partial copy of the request trailing the first on the
685 // same connection.
686 //
687 // So a replay whose routing decision did not survive is refused
688 // outright rather than re-routed against that drained kawa: 502 is
689 // what the stream would have received before the replay existed.
690 //
691 // The routed id, a handle on the route table's `ClusterId` and not a
692 // copy, moves into `HttpContext::cluster_id`; everything after this
693 // borrows or re-counts it, the embedder's gate included (#1583).
694 let replaying = stream.front.consumed;
695 if !replaying {
696 let (front_ref, stream_context_ref) = {
697 let stream_split = &mut *stream;
698 (&mut stream_split.front, &mut stream_split.context)
699 };
700 let routed = self
701 .route_from_request(stream_context_ref, front_ref, &context.listener, view)
702 .map_err(BackendConnectionError::RetrieveClusterError)?;
703 // Derived here, where the routed cluster and the request's header
704 // blocks are both in hand, and stored beside the cluster id: a
705 // replay finds the blocks drained and reuses this key, as it
706 // reuses the cluster.
707 let context = &stream.context;
708 let key = view.cluster(&routed).and_then(|cluster| {
709 affinity_key(
710 cluster,
711 &stream.front,
712 context.session_address,
713 StickyCookie {
714 name: &context.sticky_name,
715 value: context.sticky_session_found.as_deref(),
716 },
717 )
718 });
719 stream.context.affinity_key = key;
720 stream.context.set_cluster_metrics_incarnation(
721 crate::metrics::capture_cluster_metrics(routed.as_ref()),
722 );
723 stream.context.cluster_id = Some(routed);
724 }
725 let stream_context = &stream.context;
726 // Only a replay can find it unset: routing has just stored it otherwise.
727 let Some(cluster_id) = stream_context.cluster_id.as_deref() else {
728 return Err(BackendConnectionError::ReplayRefused(
729 "no cluster_id survived the first attempt",
730 ));
731 };
732
733 let (
734 frontend_should_stick,
735 frontend_should_redirect_https,
736 h2,
737 cluster_max_connections_per_ip,
738 cluster_retry_after,
739 cluster_max_connections_per_subnet,
740 ) = view
741 .cluster(cluster_id)
742 .map(|cluster| {
743 (
744 cluster.sticky_session,
745 cluster.https_redirect,
746 cluster.http2.unwrap_or(false),
747 cluster.max_connections_per_ip,
748 cluster.retry_after,
749 cluster.max_connections_per_subnet,
750 )
751 })
752 .unwrap_or((false, false, false, None, None, None));
753
754 // A replay carries H1 wire bytes in `front.out` (the H1 write path is
755 // the only one that captures), so its cluster must still resolve to
756 // H1 or the H2 converter would frame raw H1 text as a DATA payload.
757 // It is expected to: `clusters()` is owned by the worker's command
758 // loop, and a replay is drained from `pending_links` inside the very
759 // `ready()` pass that queued it, so no cluster update should
760 // interleave. That argument is not load-bearing here — the cost of it
761 // being wrong is silent protocol corruption on the wire, which is not
762 // a thing to leave to an assertion that compiles out of every release
763 // build. Refuse the replay instead and let the caller answer 502.
764 if replaying && h2 {
765 return Err(BackendConnectionError::ReplayRefused(
766 "the cluster switched to HTTP/2 between attempts",
767 ));
768 }
769
770 // ── Legacy `cluster.https_redirect` short-circuit ──
771 //
772 // Resolve the legacy HTTP→HTTPS redirect BEFORE per-(cluster,
773 // source-IP) accounting so a redirect-only request never
774 // consumes an IP slot. Otherwise a same-IP client iterating an
775 // HTTP→HTTPS hop could trip 429 ahead of the 301 even though no
776 // backend would have been opened. A duplicate guard that lived
777 // here previously (rebase artefact — two identical
778 // `if frontend_should_redirect_https && …` blocks back-to-back)
779 // is folded into this single early-return.
780 // Frontend-scoped `RedirectPolicy::PERMANENT` already returns
781 // from `route_from_request` with the same error, so this only
782 // handles the legacy cluster-level path that doesn't surface
783 // from `route_from_request`.
784 if frontend_should_redirect_https && view.is_http_listener() {
785 return Err(BackendConnectionError::RetrieveClusterError(
786 RetrieveClusterError::HttpsRedirect,
787 ));
788 }
789
790 // Per-(cluster, source-IP) connection limit gate. Runs AFTER cluster
791 // resolution AND legacy redirect emission (so a 401/421/redirect
792 // frontend never trips the limit) and BEFORE any backend selection
793 // (so a rejection consumes neither a backend pool slot nor a retry
794 // budget). The check uses the source IP from the per-stream
795 // `HttpContext.session_address`, which is the proxy-protocol-aware
796 // client address when present, falling back to `peer_addr`. The
797 // limit governs distinct **frontend connections** per
798 // `(cluster, ip)`: an H2 session multiplexing N streams to the same
799 // cluster from the same IP still consumes a single slot.
800 // This is where phase one ends. The gate needs `SessionManager`, which
801 // the core does not hold and cannot be handed as a read-only view
802 // because consulting it is only half of what happens here — the other
803 // half is `track_cluster_ip`, a mutation of worker-global state. So
804 // the core stops, says what to ask, and resumes on the answer.
805 //
806 // A stream with no source address skips the gate entirely, exactly as
807 // before, and is decided outright.
808 let Some(ip) = stream_context.session_address.map(|sa| sa.ip()) else {
809 return self
810 .decide_after_gate(stream_id, context, view, h2, frontend_should_stick)
811 .map(ConnectStep::Decided);
812 };
813 Ok(ConnectStep::CheckIpLimit(ConnectResume {
814 h2,
815 frontend_should_stick,
816 ip,
817 max_connections_per_ip: cluster_max_connections_per_ip,
818 cluster_retry_after,
819 max_connections_per_subnet: cluster_max_connections_per_subnet,
820 }))
821 }
822
823 /// Finish a decision the embedder paused at
824 /// [`ConnectStep::CheckIpLimit`].
825 ///
826 /// Takes `resume` **by value**, so a parked decision cannot be resumed
827 /// twice. The return type is [`ConnectPlan`], not [`ConnectStep`], so a
828 /// second gate consultation is not merely wrong but unrepresentable.
829 pub(super) fn plan_connect_resume<L: ListenerHandler + L7ListenerHandler>(
830 &mut self,
831 stream_id: GlobalStreamId,
832 context: &mut Context<L>,
833 view: &RoutingView<'_>,
834 resume: ConnectResume,
835 verdict: IpGateVerdict,
836 ) -> Result<ConnectPlan, BackendConnectionError> {
837 if let IpGateVerdict::AtLimit { retry_after } = verdict {
838 // Stash the resolved retry value on the stream so the mux's
839 // `BackendConnectionError` -> 429 mapping can render (or elide)
840 // the `Retry-After` header without re-deriving the override
841 // chain. The embedder resolved it; the core only records it.
842 context.streams[stream_id].context.retry_after_seconds =
843 Some(retry_after).filter(|v| *v > 0);
844 return Err(BackendConnectionError::TooManyConnectionsPerIp {
845 cluster_id: context.streams[stream_id]
846 .context
847 .cluster_id
848 .clone()
849 .unwrap_or_default(),
850 });
851 }
852 self.decide_after_gate(
853 stream_id,
854 context,
855 view,
856 resume.h2,
857 resume.frontend_should_stick,
858 )
859 }
860
861 /// Everything the decision does once the gate has answered: the pool
862 /// reuse scan, and the dial request when nothing is reusable.
863 ///
864 /// One body shared by both the gated and the ungated entry, so the two
865 /// cannot drift — the thing a split like this most easily gets wrong.
866 ///
867 /// The cluster is read from the stream's `HttpContext`, where
868 /// `plan_connect` stored the routed id, rather than handed in as a copy.
869 fn decide_after_gate<L: ListenerHandler + L7ListenerHandler>(
870 &mut self,
871 stream_id: GlobalStreamId,
872 context: &mut Context<L>,
873 view: &RoutingView<'_>,
874 h2: bool,
875 frontend_should_stick: bool,
876 ) -> Result<ConnectPlan, BackendConnectionError> {
877 let stream_context = &context.streams[stream_id].context;
878 let Some(cluster_id) = stream_context.cluster_id.as_ref() else {
879 error!(
880 "{} stream {} reached the backend decision without a routed cluster",
881 log_module_context!(stream_context),
882 stream_id
883 );
884 return Err(BackendConnectionError::MaxSessionsMemory);
885 };
886
887 /*
888 H2 connecting strategy (least-loaded):
889 - look at every backend connection
890 - among connected backends for this cluster, pick the one with the fewest active streams
891 - fall back to a connecting backend if no connected one exists
892 - if no backend is to reuse, ask the router for a socket to the "next in line" backend
893
894 H1 strategy: reuse the first KeepAlive backend for this cluster.
895
896 Either way, a connection whose backend has left the configuration is
897 never reused, even when a cluster of the same id is configured again.
898 */
899
900 let mut reuse_token = None;
901 let mut best_h2_stream_count = usize::MAX;
902 for (token, backend) in &self.backends {
903 match (h2, backend.position()) {
904 (_, Position::Server) => {
905 error!(
906 "{} Backend connection unexpectedly behaves like a server",
907 log_module_context!(stream_context)
908 );
909 continue;
910 }
911 (_, Position::Client(_, _, BackendStatus::Disconnecting)) => {}
912 // The cluster id matches, the backend no longer does.
913 (_, Position::Client(other_cluster_id, backend, _))
914 if other_cluster_id == cluster_id && view.is_retired(backend) => {}
915
916 (true, Position::Client(other_cluster_id, _, BackendStatus::Connected)) => {
917 if other_cluster_id == cluster_id && !backend.is_draining() {
918 // Pick the non-draining H2 connection with the fewest active streams
919 let Connection::H2(h2c) = backend else {
920 continue;
921 };
922 let stream_count = h2c.core.stream_count();
923 if stream_count
924 >= h2c.core.peer_settings.settings_max_concurrent_streams as usize
925 {
926 continue;
927 }
928 if stream_count < best_h2_stream_count {
929 best_h2_stream_count = stream_count;
930 reuse_token = Some(*token);
931 }
932 }
933 }
934 (true, Position::Client(other_cluster_id, _, BackendStatus::Connecting(_))) => {
935 // Only use a connecting backend if no connected one was found
936 if other_cluster_id == cluster_id
937 && best_h2_stream_count == usize::MAX
938 && matches!(backend, Connection::H2(_))
939 {
940 reuse_token = Some(*token)
941 }
942 }
943 (true, Position::Client(other_cluster_id, _, BackendStatus::KeepAlive)) => {
944 if other_cluster_id == cluster_id && matches!(backend, Connection::H2(_)) {
945 error!(
946 "{} ConnectionH2 unexpectedly behaves like H1 with KeepAlive",
947 log_module_context!(stream_context)
948 );
949 }
950 }
951
952 (false, Position::Client(old_cluster_id, _, BackendStatus::KeepAlive)) => {
953 if old_cluster_id == cluster_id {
954 reuse_token = Some(*token);
955 break;
956 }
957 }
958 // can't bundle H1 streams together
959 (false, Position::Client(_, _, BackendStatus::Connected))
960 | (false, Position::Client(_, _, BackendStatus::Connecting(_))) => {}
961 }
962 }
963 trace!(
964 "{} connect: (stick={}, h2={}) -> (reuse={:?})",
965 log_module_context!(stream_context),
966 frontend_should_stick,
967 h2,
968 reuse_token
969 );
970
971 if let Some(token) = reuse_token {
972 // Pool reuse: an existing backend connection (H2 multiplex slot or
973 // H1 keep-alive socket) is being reattached to this stream. Pair
974 // with `backend.pool.miss` below — together they describe the
975 // pool's hit/miss ratio. Counted before any commit so the metric
976 // is consistent with the trace log.
977 incr!(names::backend::POOL_HIT);
978 trace!(
979 "{} reused backend: {:#?}",
980 log_module_context!(stream_context),
981 self.backends.get(&token)
982 );
983 // Link backend to stream for the reused connection path. We check
984 // that the backend can accept a new stream before committing any
985 // per-stream state.
986 let Some(backend_conn) = self.backends.get_mut(&token) else {
987 error!(
988 "{} reused backend token {:?} missing from backends map",
989 log_module_context!(stream_context),
990 token
991 );
992 return Err(BackendConnectionError::MaxSessionsMemory);
993 };
994 if !backend_conn.start_stream(stream_id, context) {
995 // Use `context.http_context(stream_id)` instead of reusing
996 // `stream_context`: `start_stream` above takes `&mut
997 // context`, which reborrows the slab mutably and ends any
998 // outstanding `stream_context` reference. A fresh shared
999 // borrow via the accessor is borrow-check clean.
1000 error!(
1001 "{} Backend rejected stream start (max concurrent streams reached)",
1002 log_module_context!(context.http_context(stream_id))
1003 );
1004 return Err(BackendConnectionError::MaxSessionsMemory);
1005 }
1006 // For reused backends: set context fields and metrics lifecycle.
1007 // Both values are read off the connection's own `BackendId`; the id
1008 // is an `Rc` clone, not a heap copy (#1579). They are identity,
1009 // immutable for the life of the registry entry: the answer the
1010 // registry borrow used to give, without the core holding the handle.
1011 if let Some(backend_conn) = self.backends.get(&token)
1012 && let Position::Client(_, backend, _) = backend_conn.position()
1013 {
1014 let stream = &mut context.streams[stream_id];
1015 stream.context.backend_id = Some(Rc::clone(&backend.backend_id));
1016 stream.context.backend_address = Some(backend.address);
1017 stream.metrics.backend_id = Some(Rc::clone(&backend.backend_id));
1018 stream.metrics.backend_start();
1019 stream.metrics.backend_connected();
1020 }
1021 context.link_stream(stream_id, token);
1022 return Ok(ConnectPlan::Attached);
1023 }
1024
1025 // New-backend path: no reusable connection was found (no live H2
1026 // multiplex slot for this cluster, no H1 keep-alive socket), so a
1027 // fresh TCP dial and full backend handshake have to follow.
1028 //
1029 // Pool miss, counted here rather than after the dial so the count
1030 // includes attempts that fail at backend selection
1031 // (`BackendError::NoBackendForCluster`, etc.) — every miss is a slot
1032 // we did not save. Pair with `backend.pool.hit` above. The dial
1033 // itself may still fail, in which case `backend.pool.size` is never
1034 // bumped but the miss is already counted.
1035 incr!(names::backend::POOL_MISS);
1036
1037 // Everything past this point was the embedder's work all along: the
1038 // selection, the `connect(2)`, `set_nodelay`, the slab session, the
1039 // epoll registration and their rollback. It now reads that way.
1040 // `Mux::ready_inner` performs it in the same order and calls back
1041 // into `Router::commit_dialed`.
1042 //
1043 // The new connection's `Position::Client` shares the routed id: the
1044 // plan carries a handle on it and `Mux::attach_dialed` moves it there.
1045 Ok(ConnectPlan::Dial {
1046 cluster_id: cluster_id.clone(),
1047 h2,
1048 frontend_should_stick,
1049 })
1050 }
1051
1052 /// Take ownership of a backend connection the embedder dialled, built and
1053 /// registered, and attach `stream_id` to it.
1054 ///
1055 /// The reply half of [`ConnectPlan::Dial`]. By the time this runs the
1056 /// embedder has already proved the connection usable — `start_stream`
1057 /// returned true and the epoll registration succeeded — so this performs
1058 /// the two steps that are the router's own: taking the connection into
1059 /// the backend map under its token, and linking the stream to it.
1060 ///
1061 /// Deliberately infallible. Every way the dial could fail is decided
1062 /// before the caller reaches here, which is what keeps the CWE-400
1063 /// rollback discipline in one place instead of split across the seam.
1064 pub(super) fn commit_dialed<L: ListenerHandler + L7ListenerHandler>(
1065 &mut self,
1066 stream_id: GlobalStreamId,
1067 context: &mut Context<L>,
1068 token: Token,
1069 connection: Connection<SessionTcpStream>,
1070 ) {
1071 // No timer arming here: the connection arms its own connect-timeout
1072 // DEADLINE at construction and the `Mux` adapter reflects it onto the
1073 // wheel when `ready()` reschedules, which is the same pass this runs
1074 // in. Until that reschedule no wheel entry exists — which is what the
1075 // caller's rollback paths want, since they drop the connection
1076 // without ever reaching here.
1077 self.backends.insert(token, connection);
1078 context.link_stream(stream_id, token);
1079 }
1080
1081 fn route_from_request<L: ListenerHandler + L7ListenerHandler>(
1082 &mut self,
1083 context: &mut HttpContext,
1084 front: &mut super::GenericHttpStream,
1085 listener: &Rc<RefCell<L>>,
1086 view: &RoutingView<'_>,
1087 ) -> Result<ClusterId, RetrieveClusterError> {
1088 let (host, uri, method) = match context.extract_route() {
1089 Ok(tuple) => tuple,
1090 Err(cluster_error) => {
1091 // we are past kawa parsing if it succeeded this can't fail
1092 // if the request was malformed it was caught by kawa and we sent a 400
1093 error!(
1094 "{} Malformed request in connect (should be caught at parsing) {:?}: {}",
1095 log_module_context!(context),
1096 context,
1097 cluster_error
1098 );
1099 return Err(cluster_error);
1100 }
1101 };
1102 // ── TLS cert SAN ↔ HTTP :authority binding ────────────────────────
1103 // Reject any request whose `:authority` is not covered by a SAN of
1104 // the certificate Sōzu actually served at the TLS handshake, with
1105 // RFC 6125 §6.4.3 wildcard handling. Without this binding, an
1106 // attacker holding a valid certificate for tenant A could open TLS
1107 // with SNI=A then send an H2 stream with `:authority=tenantB.…` and
1108 // reach tenant B's backend, crossing the TLS trust boundary
1109 // (CWE-346 / CWE-444). The H2 spec explicitly allows browsers to
1110 // coalesce streams onto a connection whenever the server is
1111 // authoritative for the new origin (RFC 7540 §9.1.1 / RFC 9113
1112 // §9.1.1), which "authoritative" means "covered by a SAN of the
1113 // served cert"; rejecting coalesced streams as 421 caused the
1114 // user-visible bug this predicate fixes (RFC 9110 §15.5.20).
1115 //
1116 // Plaintext listeners bypass the check (SNI is always `None`).
1117 // Connections where SNI was sent but no cert matched (rustls served
1118 // the default cert) carry `Some(empty)` SAN snapshot, so every
1119 // authority is rejected — Sōzu is not authoritative for any name.
1120 // Connections with no SNI fall back to the legacy exact-SNI match
1121 // predicate (`authority_matches_sni`) for parity with pre-fix
1122 // behaviour on the pathological "no SNI" case.
1123 // Operators may opt out per-listener via
1124 // `HttpsListenerConfig::strict_sni_binding = false`.
1125 if let Some(sni) = context
1126 .tls_server_name
1127 .as_deref()
1128 .filter(|_| context.strict_sni_binding)
1129 {
1130 let matched: Option<&str> = match context.tls_cert_names.as_deref() {
1131 Some(cert_names) => authority_matched_cert_name(host, cert_names),
1132 None => {
1133 if authority_matches_sni(host, sni) {
1134 Some(sni)
1135 } else {
1136 None
1137 }
1138 }
1139 };
1140 match matched {
1141 Some(matched_name) => {
1142 // Real coalescing = matched SAN differs from the SNI's
1143 // value after the matcher's port-strip + ASCII case
1144 // folding. Same-name requests are the common
1145 // non-coalesced path; do not pollute the counter or
1146 // logs with them. The ALPN=`h2` gate is a defensive
1147 // guard, not load-bearing under current invariants —
1148 // every request reaching `route_from_request` on an
1149 // HTTPS listener with `tls_cert_names` populated has
1150 // already gone through the H2 mux (ALPN=h2 by
1151 // construction). Kept explicit so a future routing
1152 // refactor that funnels H1 keep-alive through the
1153 // same predicate doesn't silently double-count
1154 // sequential `Host:` reuse as "coalescing".
1155 if !authority_matches_sni(host, sni) && context.tls_alpn == Some("h2") {
1156 incr!(names::h2::COALESCING_ACCEPTED);
1157 log_coalescing_accepted(context, host, sni, matched_name);
1158 }
1159 }
1160 None => {
1161 incr!(names::http::SNI_AUTHORITY_MISMATCH);
1162 log_sni_authority_mismatch(
1163 context,
1164 host,
1165 sni,
1166 context.tls_cert_names.as_deref().map(Vec::as_slice),
1167 );
1168 return Err(RetrieveClusterError::SniAuthorityMismatch {
1169 sni: sni.to_owned(),
1170 authority: host.to_owned(),
1171 });
1172 }
1173 }
1174 }
1175
1176 let route_result =
1177 listener
1178 .borrow()
1179 .frontend_from_request(host, uri, method, &FrontRequestHeaders(front));
1180
1181 let route = match route_result {
1182 Ok(route) => route,
1183 Err(frontend_error) => {
1184 trace!("{} {}", log_module_context!(context), frontend_error);
1185 return Err(RetrieveClusterError::RetrieveFrontend(frontend_error));
1186 }
1187 };
1188
1189 // ── Resolve the routing decision ──────────────────────────────────
1190 // Snapshot the policy fields we need before consuming `route`, then
1191 // map each policy outcome to either an early-error variant (which
1192 // the caller turns into a default answer) or a cluster_id (which
1193 // proceeds to backend connect).
1194 let RouteResult {
1195 cluster_id,
1196 redirect,
1197 redirect_scheme,
1198 redirect_template,
1199 rewritten_host,
1200 rewritten_path,
1201 rewritten_port,
1202 headers_request,
1203 headers_response,
1204 required_auth: frontend_required_auth,
1205 tags,
1206 } = route;
1207
1208 // The matched frontend rule is the only correct owner of the
1209 // access-log tags, so stash them here — before every early return
1210 // below, so a redirect, a 401 and a backend-connect failure log
1211 // them too. The listener's `BTreeMap<String, CachedTags>` cannot
1212 // do this job: it is written under the frontend rule's hostname
1213 // and read under the request's authority, two spellings that only
1214 // coincide for an exact literal frontend (sozu#1379).
1215 //
1216 // No `..` in the destructure above, deliberately: that wildcard is
1217 // exactly how `tags` was dropped on the floor for as long as the
1218 // bug lived. A future `RouteResult` field now has to be routed
1219 // here explicitly or the build breaks.
1220 context.tags = tags;
1221
1222 // ── 0. Explicit `RedirectPolicy::SILENT_CLOSE` ────────────────────
1223 // nginx `return 444` parity (sozu-proxy/sozu#767): the request is
1224 // refused without writing a single response byte. Resolved before
1225 // every other policy, and before the cluster lookup it does not need,
1226 // so it holds whether or not a cluster is bound; the caller resets the
1227 // H2 stream or closes the H1 connection.
1228 if matches!(redirect, RedirectPolicy::SilentClose) {
1229 trace!("{} RouteResult::silent_close", log_module_context!(context));
1230 return Err(RetrieveClusterError::SilentClose);
1231 }
1232
1233 // ── HSTS (RFC 6797) snapshot hoist for HTTPS ──────────────────────
1234 // The response snapshot is built in two passes so HSTS reaches
1235 // every HTTPS response code (RFC 6797 §8.1 — including
1236 // proxy-generated 3xx / 401 / 5xx default answers) WITHOUT
1237 // changing the pre-PR scope of operator-defined `Append`
1238 // response headers (which only apply on the regular forward
1239 // path).
1240 //
1241 // Pass 1 (here, before any early return): for HTTPS only, copy
1242 // ONLY the HSTS-class typed edits (`SetIfAbsent | Set`). These
1243 // need to land on default answers — `set_default_answer_with_retry_after`
1244 // bypasses the post-forward copy below.
1245 //
1246 // Pass 2 (post-forward, end of function): copy EVERY edit
1247 // (including operator `Append` headers). Runs only on the
1248 // regular forward path because the early returns short-circuit
1249 // before reaching it.
1250 //
1251 // Plain-HTTP listeners are skipped here per RFC 6797 §7.2 (no
1252 // STS over plaintext) — defense in depth on top of the
1253 // TOML-time `ConfigError::HstsOnPlainHttp` and the worker IPC
1254 // `ProxyError::HstsOnPlainHttp` rejects.
1255 if matches!(context.protocol, crate::Protocol::HTTPS) {
1256 snapshot_response_edits(&mut context.headers_response, &headers_response, |e| {
1257 matches!(e.mode, HeaderEditMode::SetIfAbsent | HeaderEditMode::Set)
1258 });
1259 }
1260
1261 // Look up cluster-side policy knobs once. The values we need are:
1262 // - `https_redirect` (legacy) and `https_redirect_port` for the 301 location URL
1263 // - `authorized_hashes` and `www_authenticate` for the 401 path
1264 let (legacy_https_redirect, https_redirect_port, authorized_hashes, www_authenticate) =
1265 match cluster_id.as_deref() {
1266 Some(id) => view
1267 .cluster(id)
1268 .map(|c| {
1269 (
1270 c.https_redirect,
1271 c.https_redirect_port,
1272 c.authorized_hashes.clone(),
1273 c.www_authenticate.clone(),
1274 )
1275 })
1276 .unwrap_or((false, None, Vec::new(), None)),
1277 None => (false, None, Vec::new(), None),
1278 };
1279
1280 // ── 1. Explicit redirect policies (PERMANENT / FOUND / PERMANENT_REDIRECT) ──
1281 // Resolved BEFORE the clusterless-deny branch so a frontend that
1282 // declares `redirect = permanent | found | permanent_redirect`
1283 // emits the matching 3xx even when no cluster is bound. This is
1284 // the canonical "moved" shape from the original proposal in
1285 // #1161 and is the only way to express "this hostname has moved"
1286 // without standing up a dummy cluster. The block does not read
1287 // `cluster_id`; per-cluster values (`https_redirect_port`,
1288 // `www_authenticate`, …) default to safe sentinels at the cluster
1289 // lookup above when `cluster_id` is `None`, so the reorder is
1290 // data-flow-safe.
1291 //
1292 // Status code mapping (closes #1009):
1293 // Permanent → 301 (RFC 9110 §15.4.2)
1294 // Found → 302 (RFC 9110 §15.4.3) — UA may rewrite POST→GET
1295 // PermanentRedirect → 308 (RFC 9110 §15.4.9) — method MUST be preserved
1296 let redirect_status = match redirect {
1297 RedirectPolicy::Permanent => Some(301u16),
1298 RedirectPolicy::Found => Some(302u16),
1299 RedirectPolicy::PermanentRedirect => Some(308u16),
1300 // Forward / Unauthorized are handled by other branches
1301 // below; keeping them named here forces an exhaustive
1302 // match so a future RedirectPolicy variant doesn't
1303 // silently fall through to `None`.
1304 RedirectPolicy::Forward
1305 | RedirectPolicy::Unauthorized
1306 | RedirectPolicy::SilentClose => None,
1307 };
1308 if let Some(status_code) = redirect_status {
1309 let scheme = resolve_redirect_scheme(redirect_scheme, context);
1310 let port = rewritten_port.map(|p| p as u32).or(https_redirect_port);
1311 // Feed the rewritten host AND path into the `Location` URL
1312 // when the frontend's RewriteParts populated them. Without
1313 // this, a `redirect = permanent` frontend with
1314 // `rewrite_host = "new.example.com"` would serve clients
1315 // back to the original `Host:` header, defeating the
1316 // documented `old → new` shape.
1317 // The host_override path also keeps `:port` stripping
1318 // intact: `build_redirect_location` removes any `:port` on
1319 // the override before reapplying `port_suffix`.
1320 context.redirect_location = Some(build_redirect_location(
1321 scheme,
1322 context,
1323 port,
1324 rewritten_host.as_deref(),
1325 rewritten_path.as_deref(),
1326 ));
1327 // Stash the frontend's `redirect_template` (when set) so the
1328 // 3xx default-answer path can render it via
1329 // `HttpAnswers::render_inline_redirect` instead of the
1330 // listener / cluster default. Without this stash the field
1331 // flows into `RouteResult` only to be dropped by the
1332 // wildcard destructure below, so the operator-supplied
1333 // template has no observable effect on the rendered
1334 // redirect.
1335 context.frontend_redirect_template = redirect_template;
1336 // Stash the resolved status so the answer engine picks the
1337 // matching default template (`http.301.redirection` /
1338 // `http.302.redirection` / `http.308.redirection`).
1339 context.redirect_status = Some(status_code);
1340 return Err(RetrieveClusterError::HttpsRedirect);
1341 }
1342
1343 // ── 2. Explicit `RedirectPolicy::UNAUTHORIZED` or clusterless deny ─
1344 // Reached when the frontend either explicitly asks for 401 or has
1345 // no backing cluster and no `Permanent` redirect to honour. The
1346 // `Forward + cluster_id == None` combination collapses here so
1347 // legacy clusterless frontends still emit 401 by default.
1348 if matches!(redirect, RedirectPolicy::Unauthorized) || cluster_id.is_none() {
1349 context.www_authenticate = www_authenticate.clone();
1350 trace!("{} RouteResult::deny", log_module_context!(context));
1351 return Err(RetrieveClusterError::UnauthorizedRoute);
1352 }
1353
1354 let Some(cluster_id) = cluster_id else {
1355 // Guarded by the clusterless-deny branch immediately above;
1356 // the `is_none()` arm has already returned `UnauthorizedRoute`
1357 // by the time control reaches here.
1358 unreachable!("cluster_id was checked Some above")
1359 };
1360
1361 // ── 3. Legacy `cluster.https_redirect` (HTTP-only listeners) ───────
1362 // The caller (`Router::plan_connect`) emits the actual 301 only on
1363 // `ListenerType::Http`; gate the URL stash on the same predicate
1364 // so an HTTPS listener never carries a stale `redirect_location`
1365 // into a downstream default-answer path.
1366 if legacy_https_redirect && view.is_http_listener() {
1367 let port = https_redirect_port;
1368 context.redirect_location =
1369 Some(build_redirect_location("https", context, port, None, None));
1370 }
1371
1372 // ── 4. Basic auth check (only when `required_auth` was set) ────────
1373 // The check iterates the full hash list in constant time (see
1374 // `crate::protocol::mux::auth::check_basic`) so the time spent
1375 // does not leak which hash matched, or whether any did at all.
1376 // On failure, stash the cluster's `www_authenticate` realm so the
1377 // 401 default-answer can render the matching `WWW-Authenticate`
1378 // header. An empty realm causes the template engine to elide the
1379 // header entirely (`or_elide_header = true`).
1380 if frontend_required_auth
1381 && !crate::protocol::mux::auth::check_basic(front, &authorized_hashes)
1382 {
1383 context.www_authenticate = www_authenticate.clone();
1384 trace!(
1385 "{} basic-auth check failed; emitting 401",
1386 log_module_context!(context)
1387 );
1388 return Err(RetrieveClusterError::UnauthorizedRoute);
1389 }
1390
1391 // ── 5. Request-side mutations on the front kawa ────────────────────
1392 // From here on the route is a Forward — apply the frontend's
1393 // rewrite + header policy to the request kawa so the backend
1394 // wire carries the operator-configured shape.
1395 apply_request_rewrites_and_headers(
1396 front,
1397 context,
1398 rewritten_host.as_deref(),
1399 rewritten_path.as_deref(),
1400 &headers_request,
1401 );
1402
1403 // Pass 2 of the response-snapshot copy (see the HSTS hoist
1404 // above). Runs unconditionally on the regular forward path
1405 // (the early returns above bypass this site, which keeps the
1406 // default-answer scope as HSTS-only). Copies EVERY edit so
1407 // operator-defined `Append` response headers reach
1408 // backend-served responses on both HTTP and HTTPS listeners,
1409 // preserving their pre-PR scope.
1410 snapshot_response_edits(&mut context.headers_response, &headers_response, |_| true);
1411
1412 Ok(cluster_id)
1413 }
1414
1415 /// Pick the backend for an already-routed request and stamp its identity
1416 /// onto the request's [`HttpContext`].
1417 ///
1418 /// Takes no listener handle: every listener-derived value this needs —
1419 /// `sticky_name` above all — is already on the `HttpContext` that
1420 /// `Context::create_stream` filled when the request arrived, and reading
1421 /// the listener again here would be exactly the mid-request reload leak
1422 /// `LIFECYCLE.md` §2.5 rules out.
1423 ///
1424 /// Takes no proxy handle either: the backend set arrives as `selector`, a
1425 /// capability borrowed for this one call (see [`BackendSelector`]).
1426 ///
1427 /// Returns the chosen backend, reserved but not dialled: the caller
1428 /// connects to [`BackendId::address`] (#1684).
1429 pub fn backend_from_request(
1430 &mut self,
1431 cluster_id: &str,
1432 frontend_should_stick: bool,
1433 context: &mut HttpContext,
1434 selector: &mut dyn BackendSelector,
1435 ) -> Result<BackendId, BackendConnectionError> {
1436 // Spelled out rather than folded into the selector's signature: a
1437 // cookie the client sent to a frontend that does not stick is
1438 // ignored, exactly as the `(false, Some(_))` arm of the match this
1439 // replaced ignored it.
1440 let affinity = if frontend_should_stick {
1441 Affinity::Sticky(context.sticky_session_found.as_deref())
1442 } else {
1443 Affinity::Unpinned
1444 };
1445 let selected = selector
1446 .select(cluster_id, affinity, context.affinity_key)
1447 .map_err(|backend_error| {
1448 trace!("{} {}", log_module_context!(context), backend_error);
1449 BackendConnectionError::Backend(backend_error)
1450 })?;
1451 debug_assert_eq!(
1452 selected.sticky_session.is_some(),
1453 frontend_should_stick,
1454 "a selector resolves a sticky cookie exactly when the frontend sticks"
1455 );
1456
1457 // `context.sticky_name` is the name `Context::create_stream` captured
1458 // when this request arrived, and it stays that name to the end of the
1459 // request. Re-reading the listener here used to hand an in-flight
1460 // request a cookie name an operator installed after it started — the
1461 // request would then have been matched on the old name and answered
1462 // with a `Set-Cookie` under the new one. A reload applies from the
1463 // next request (`LIFECYCLE.md` §2.5).
1464 if let Some(sticky_session) = selected.sticky_session {
1465 context.sticky_session = Some(sticky_session);
1466 }
1467
1468 context.backend_id = Some(Rc::clone(&selected.backend.backend_id));
1469 context.backend_address = Some(selected.backend.address);
1470
1471 Ok(selected.backend)
1472 }
1473}
1474
1475/// The worker's backend set, as the one capability
1476/// [`Router::backend_from_request`] needs from it: pick a backend and reserve
1477/// a connection on it.
1478///
1479/// Question 12 of [#1340](https://github.com/sozu-proxy/sozu/issues/1340)
1480/// asked for a borrowed view of backend load state, "the same shape"
1481/// `RoutingView` gave cluster configuration. That shape cannot select.
1482/// `RoutingView` is an immutable borrow of immutable data, and selection
1483/// mutates: the round-robin cursor, the fail-open latch, `PeakEWMA` under
1484/// `LoadMetric::ConnectionTime`, the Maglev table and the cluster
1485/// availability latch. A snapshot of the load counters cannot be taken
1486/// without allocating either — each backend sits behind its own `RefCell`, so
1487/// lending the core N of them at once needs a container of N `Ref`s. The shape
1488/// that fits is the UDP core's `BackendSource` (`lib/src/protocol/udp/mod.rs`),
1489/// a `&mut dyn` the core calls and the embedder implements.
1490///
1491/// # Selection reserves, a failed dial releases
1492///
1493/// The selector returns a backend and no socket: the embedder dials
1494/// [`BackendId::address`] itself, which is what lets a selector exist without
1495/// an OS socket type (#1684). What keeps the load balancer honest is that
1496/// selecting **reserves**: the embedder's selector raises the chosen
1497/// backend's `active_connections` under the same borrow as the choice
1498/// (`BackendMap::reserve_backend`), so the second stream linking in the same
1499/// pass weighs the first one's connection before it has been dialled, exactly
1500/// as it weighed the connection `Backend::try_connect` used to count at the
1501/// dial. A `connect(2)` that fails releases the reservation through the
1502/// ledger (`BackendChange::DialFailed`), and LIFECYCLE §9 invariant 14's
1503/// drain before each selection applies it before anything reads the counters
1504/// again. A dial that connects but is abandoned before its connection is
1505/// registered — no buffer for an H2 client, a refused stream start, a failed
1506/// `register_socket` — releases it through `BackendChange::ConnectionClosed`,
1507/// recording no failure (#1713). A dial that succeeds keeps the reservation
1508/// as its connection's count, and the connection's close releases it.
1509///
1510/// # Why a trait and not `&mut BackendMap`
1511///
1512/// A bare `&mut BackendMap` would be smaller, and it would still leave
1513/// [`Router::backend_from_request`] handing back an `Rc<RefCell<Backend>>`,
1514/// because turning that handle into the opaque [`BackendId`] takes the
1515/// session's `BackendRegistry` (`lib/src/protocol/mux/mod.rs`) as well. The
1516/// embedder's implementation holds both, so no registry handle reaches this
1517/// file at all. It is also what lets a simulator stand in for the backend set:
1518/// such a selector mints its ids with [`BackendId::new`] and needs no socket.
1519///
1520/// `Router` has no lifetime parameter, so it cannot keep the `&mut` it is
1521/// lent; the `Rc<RefCell<dyn L7Proxy>>` this replaced was `'static` and
1522/// `Clone`, and nothing stopped it being stored.
1523pub trait BackendSelector {
1524 /// Select a backend of `cluster_id` and reserve a connection on it.
1525 ///
1526 /// Under [`Affinity::Sticky`] the result carries the cookie value to
1527 /// answer with, read from the chosen backend under the same borrow as the
1528 /// selection. It is resolved here rather than carried on [`BackendId`]
1529 /// because `Backend::sticky_id` is not immutable: `BackendList::add_backend`
1530 /// (`lib/src/backends.rs`) rewrites it in place on the live registry entry
1531 /// when an existing backend is re-added, and `BackendId` carries only what
1532 /// cannot change for the life of an entry.
1533 ///
1534 /// `key` is the client affinity key `affinity_key` in this module derived
1535 /// for the request, `None` unless the cluster selects with `HRW` or
1536 /// `MAGLEV`. It decides where the load balancer sends a request the sticky
1537 /// cookie did not pin, and nothing else: a cookie naming a live backend
1538 /// still wins.
1539 fn select(
1540 &mut self,
1541 cluster_id: &str,
1542 affinity: Affinity<'_>,
1543 key: Option<u64>,
1544 ) -> Result<SelectedBackend, BackendError>;
1545}
1546
1547/// Whether selection for `cluster` reads a client affinity key: its policy
1548/// is `HRW` or `MAGLEV`, or it shards (`shard_percent`), which ranks each
1549/// client's shard by the key under any policy (sozu-proxy/sozu#524). Every
1550/// other cluster derives no key and pays nothing.
1551pub(crate) fn cluster_reads_affinity_key(cluster: &Cluster) -> bool {
1552 cluster.shard_percent.is_some()
1553 || matches!(
1554 cluster.load_balancing(),
1555 LoadBalancingAlgorithms::Hrw | LoadBalancingAlgorithms::Maglev
1556 )
1557}
1558
1559/// The client affinity key of a request routed to `cluster`, for the `HRW`
1560/// and `MAGLEV` policies to pin the client with and for shuffle sharding to
1561/// rank its shard by; `None` for every other cluster, which would ignore it
1562/// ([`cluster_reads_affinity_key`]).
1563///
1564/// The key is the hash of the cluster's `affinity_header` value (first
1565/// occurrence, name matched case-insensitively) or `affinity_cookie` value
1566/// (name matched case-sensitively, RFC 6265), and, when the request does not
1567/// carry that header or cookie, or carries it empty, of the client's source
1568/// IP — `session_address`, the PROXY-protocol source when the listener
1569/// expects one. With no source address either, there is no key, and the
1570/// policies fall back to round-robin as they always did.
1571///
1572/// A cookie named like the listener's sticky-session cookie is read from the
1573/// value `HttpContext::on_request_headers` captured, since that cookie is
1574/// elided from the jar before routing. Values are hashed raw, as sent: a
1575/// quoted cookie value and its unquoted spelling are two different keys.
1576///
1577/// It reads the header blocks and cookie jar where they lie in the request
1578/// buffer and hashes the bytes in place: no copy, no allocation. The hash is
1579/// the seeded FNV of `crate::load_balancing`, so every worker and every
1580/// restart derives the same key for the same client.
1581///
1582/// A header or cookie value is chosen by the client, so a client can choose
1583/// its backend under this option — exactly as it can with a sticky-session
1584/// cookie. It spreads clients; it does not isolate them from one another.
1585pub(super) fn affinity_key(
1586 cluster: &Cluster,
1587 front: &GenericHttpStream,
1588 session_address: Option<SocketAddr>,
1589 sticky: StickyCookie<'_>,
1590) -> Option<u64> {
1591 if !cluster_reads_affinity_key(cluster) {
1592 return None;
1593 }
1594 // `validate_affinity_key` (`command/src/config.rs`) refuses a cluster
1595 // naming both on every `AddCluster` the master accepts; the header is
1596 // read first should a worker ever receive both.
1597 let buf = front.storage.buffer();
1598 let value = if let Some(name) = cluster.affinity_header.as_deref() {
1599 front.blocks.iter().find_map(|block| match block {
1600 Block::Header(header)
1601 if !header.is_elided()
1602 && compare_no_case(header.key.data(buf), name.as_bytes()) =>
1603 {
1604 Some(header.val.data(buf))
1605 }
1606 _ => None,
1607 })
1608 } else if let Some(name) = cluster.affinity_cookie.as_deref() {
1609 if name == sticky.name {
1610 // `HttpContext::on_request_headers` has already elided this
1611 // cookie from the jar (Sōzu's own session cookie never reaches a
1612 // backend) and captured its value, so the key reads the capture.
1613 sticky.value.map(str::as_bytes)
1614 } else {
1615 front.detached.jar.iter().find_map(|cookie| {
1616 (!cookie.is_elided() && cookie.key.data(buf) == name.as_bytes())
1617 .then(|| cookie.val.data(buf))
1618 })
1619 }
1620 } else {
1621 None
1622 };
1623 // Pre: a request value is read only for a cluster that names one.
1624 debug_assert!(
1625 value.is_none() || cluster.affinity_header.is_some() || cluster.affinity_cookie.is_some(),
1626 "a header or cookie value keys only a cluster that configured its name"
1627 );
1628 let key = match value.filter(|value| !value.is_empty()) {
1629 Some(value) => Some(affinity_key_from_value(value)),
1630 None => session_address.map(|address| affinity_key_from_ip(address.ip())),
1631 };
1632 // Post: under a keyed policy the only request left without a key is one
1633 // that carries no usable value AND has no source address.
1634 debug_assert!(
1635 key.is_some() || session_address.is_none(),
1636 "a request with a source address must always be keyed"
1637 );
1638 key
1639}
1640
1641/// The request headers of a front stream, as a router lookup reads them to
1642/// evaluate a frontend's header predicates (sozu-proxy/sozu#1253).
1643///
1644/// Same idiom as [`affinity_key`]: the header blocks are read where they
1645/// lie in the request buffer, an elided block is skipped, names compare
1646/// case-insensitively, and the first occurrence answers, raw as sent. No
1647/// copy, no allocation — and nothing at all for a lookup that meets no
1648/// predicate, which never calls it. Pseudo-headers and cookies are not
1649/// header blocks, which is why `validate_header_predicate`
1650/// (`command/src/config.rs`) refuses `Host` and `Cookie`.
1651struct FrontRequestHeaders<'a>(&'a GenericHttpStream);
1652
1653impl RequestHeaders for FrontRequestHeaders<'_> {
1654 fn header(&self, name: &[u8]) -> Option<&[u8]> {
1655 let buf = self.0.storage.buffer();
1656 self.0.blocks.iter().find_map(|block| match block {
1657 Block::Header(header)
1658 if !header.is_elided() && compare_no_case(header.key.data(buf), name) =>
1659 {
1660 Some(header.val.data(buf))
1661 }
1662 _ => None,
1663 })
1664 }
1665}
1666
1667/// The listener's sticky-session cookie as the request arrived with it: its
1668/// name, and the value `HttpContext::on_request_headers` captured before
1669/// eliding it from the cookie jar. [`affinity_key`] reads it when a cluster's
1670/// `affinity_cookie` names that same cookie.
1671#[derive(Clone, Copy, Debug)]
1672pub(super) struct StickyCookie<'a> {
1673 pub(super) name: &'a str,
1674 pub(super) value: Option<&'a str>,
1675}
1676
1677/// Whether a request is pinned to a backend by its sticky-session cookie.
1678#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1679pub enum Affinity<'a> {
1680 /// The frontend does not stick: any cookie the client sent is ignored.
1681 Unpinned,
1682 /// The frontend sticks. `Some` is the cookie the request carried, tried
1683 /// first; `None`, or a cookie naming no live backend, falls back to the
1684 /// cluster's load balancer. Either way the answer carries a cookie.
1685 Sticky(Option<&'a str>),
1686}
1687
1688/// What [`BackendSelector::select`] answers: the chosen backend, whose
1689/// [`BackendId::address`] the caller dials, and — only under
1690/// [`Affinity::Sticky`] — the cookie value that pins the client to it.
1691#[derive(Debug)]
1692pub struct SelectedBackend {
1693 pub backend: BackendId,
1694 pub sticky_session: Option<String>,
1695}
1696
1697/// Apply the frontend's request-side rewrite + header policy to the
1698/// request kawa. Mutations land before backend connect so the backend
1699/// wire carries the rewritten shape:
1700///
1701/// 1. If `rewritten_host` is set, replace the request-line authority
1702/// with the rewritten value, drop any existing `Host` request header
1703/// block, and inject `X-Forwarded-Host` carrying the pre-rewrite
1704/// authority. The X-Forwarded-Host injection ONLY fires
1705/// when `rewritten_host` is set — without a rewrite there is no host
1706/// swap to disclose, and HAProxy's `option forwardfor` style
1707/// headers (`X-Forwarded-For`, `X-Forwarded-Proto`) still flow from
1708/// the kawa parser. The pre-rewrite authority is read from
1709/// `context.authority`, which no rewrite touches, and recorded in
1710/// `context.original_authority` only here, so a request without a
1711/// host rewrite copies no authority. `X-Forwarded-Host` belongs to the
1712/// `X-Forwarded-*` family, so it is injected (and a client one
1713/// replaced) only when the listener's `forwarded_headers` mode emits
1714/// that family (`both`, `x_forwarded`); `rfc7239` and `none` convey no
1715/// pre-rewrite authority, and under `none` a client `X-Forwarded-Host`
1716/// passes through as sent.
1717/// Host rule: the authority is the only carrier of the backend `Host`.
1718/// The H1 converter (`kawa::protocol::h1::converter`) writes
1719/// `Host: <authority>` right after the request line and the H2
1720/// converter (`H2BlockConverter`, `lib/src/protocol/mux/converter.rs`)
1721/// writes it as `:authority` and filters every `host` block, so a
1722/// `Host` header block is never added here: on an H1 backend it would
1723/// be a second `Host` line (sozu-proxy/sozu#1908).
1724/// 2. If `rewritten_path` is set, replace both the abstract path
1725/// (consumed by H2 `:path`) and the request-line URI (consumed by
1726/// the H1 converter) so cardinality H1↔H1, H1↔H2, H2↔H1, H2↔H2 all
1727/// propagate the rewritten target.
1728/// 3. A `headers_request` set-edit named `Host` replaces the authority
1729/// (the last one wins) unless `rewritten_host` is set, which takes
1730/// precedence; it adds no header block, for the reason above. A `Host`
1731/// delete-edit drops header blocks only: the authority itself always
1732/// reaches the backend.
1733/// 4. For every other `headers_request` edit:
1734/// - empty `val` → remove every existing header with the matching
1735/// name from `kawa.blocks` (HAProxy `del-header` parity);
1736/// - non-empty `val` → append the header before the `end_header`
1737/// flag block. Set/replace semantics: callers that want to replace
1738/// a header pass two edits (one delete with empty val, one set
1739/// with the new value).
1740fn apply_request_rewrites_and_headers(
1741 kawa: &mut super::GenericHttpStream,
1742 context: &mut HttpContext,
1743 rewritten_host: Option<&str>,
1744 rewritten_path: Option<&str>,
1745 headers_request: &[HeaderEdit],
1746) {
1747 use kawa::{Block, Pair, Store};
1748
1749 if rewritten_host.is_none() && rewritten_path.is_none() && headers_request.is_empty() {
1750 return;
1751 }
1752
1753 // `context.authority` is the authority the client sent: the rewrites
1754 // below mutate the kawa status line and headers, never the context. It
1755 // is copied only when the host is rewritten, for the X-Forwarded-Host
1756 // injection, and the copy then moves into `context.original_authority`.
1757 let original_authority: Option<String> = if rewritten_host.is_some() {
1758 context.authority.clone()
1759 } else {
1760 None
1761 };
1762
1763 // ── status-line authority / path rewrites ─────────────────────────
1764 // The kawa request status line carries both `path` and `uri` —
1765 // `path` is the abstract path (consumed by the H2 converter to
1766 // emit `:path`) while `uri` is the request-line URI (consumed by
1767 // the H1 converter at `kawa::protocol::h1::converter`). Both must
1768 // be mutated so an H1 frontend forwarding to an H1 backend AND an
1769 // H2 frontend forwarding to an H1 backend (or vice versa) see the
1770 // rewritten target on the wire.
1771 // An operator `Host` set-edit names the backend authority; the last
1772 // one wins, and `rewrite_host` wins over all of them.
1773 let operator_host: Option<&[u8]> = headers_request
1774 .iter()
1775 .rev()
1776 .find(|edit| !edit.val.is_empty() && edit.key.eq_ignore_ascii_case(b"host"))
1777 .map(|edit| &edit.val[..]);
1778 let new_authority: Option<&[u8]> = rewritten_host.map(str::as_bytes).or(operator_host);
1779
1780 if (new_authority.is_some() || rewritten_path.is_some())
1781 && let kawa::StatusLine::Request {
1782 authority,
1783 path,
1784 uri,
1785 ..
1786 } = &mut kawa.detached.status_line
1787 {
1788 if let Some(new_authority) = new_authority {
1789 *authority = Store::from_slice(new_authority);
1790 }
1791 if let Some(new_path) = rewritten_path {
1792 *path = Store::from_string(new_path.to_owned());
1793 *uri = Store::from_string(new_path.to_owned());
1794 }
1795 }
1796
1797 // ── single-pass split: deletes vs. sets ───────────────────────────
1798 // Walk `headers_request` once and separate each edit into either the
1799 // delete list (empty val) or the insert list (non-empty val). Two
1800 // passes was wasteful when an operator stacks many `--header` flags;
1801 // one pass keeps the allocation profile flat.
1802 let host_lower = b"host";
1803 let xfh_lower = b"x-forwarded-host";
1804 let rewriting_host = rewritten_host.is_some();
1805 // X-Forwarded-Host discloses the rewrite only in a mode that emits the
1806 // X-Forwarded-* family; `rfc7239` already elided a client one in
1807 // `HttpContext::on_request_headers`, and `none` leaves it as sent.
1808 let synthesises_xfh = rewriting_host && emits_x_forwarded(context.forwarded_headers);
1809 let mut keys_to_drop: Vec<Vec<u8>> = Vec::with_capacity(headers_request.len() + 2);
1810 let mut to_insert: Vec<Block> = Vec::with_capacity(headers_request.len() + 2);
1811 // Track whether any operator-supplied edit names Host or
1812 // X-Forwarded-Host so the existing header blocks of that name are
1813 // dropped. A `Host` set-edit already moved into the authority above
1814 // and is never inserted as a block: the converters emit the authority
1815 // as the one `Host`, so a block would land a second, different `Host`
1816 // line on an H1 backend — a request-smuggling primitive on backends
1817 // that pick the last one (CWE-444 cousin, sozu-proxy/sozu#1908).
1818 let mut operator_overrides_host = false;
1819 let mut operator_overrides_xfh = false;
1820 for edit in headers_request {
1821 let key_is_host = edit.key.eq_ignore_ascii_case(host_lower);
1822 let key_is_xfh = edit.key.eq_ignore_ascii_case(xfh_lower);
1823 operator_overrides_host |= key_is_host;
1824 operator_overrides_xfh |= key_is_xfh;
1825 if edit.val.is_empty() {
1826 keys_to_drop.push(edit.key.iter().map(u8::to_ascii_lowercase).collect());
1827 } else if !key_is_host {
1828 to_insert.push(Block::Header(Pair {
1829 key: Store::from_slice(&edit.key),
1830 val: Store::from_slice(&edit.val),
1831 }));
1832 }
1833 }
1834 if rewriting_host || operator_overrides_host {
1835 keys_to_drop.push(host_lower.to_vec());
1836 }
1837 if synthesises_xfh || operator_overrides_xfh {
1838 keys_to_drop.push(xfh_lower.to_vec());
1839 }
1840
1841 // ── delete pass on existing blocks ────────────────────────────────
1842 let buf_ptr = kawa.storage.buffer();
1843 if !keys_to_drop.is_empty() {
1844 // Read `key.data(buf_ptr)` only on non-elided headers — kawa's
1845 // earlier passes (HPACK decoder, H1 header parser) tag suppressed
1846 // headers with `Store::Empty` rather than removing them, and
1847 // calling `.data()` on `Store::Empty` panics in
1848 // `kawa-0.6.8/src/storage/repr.rs`. Pinning the guard explicitly
1849 // until kawa changes its policy.
1850 let buf = buf_ptr;
1851 kawa.blocks.retain(|block| {
1852 if let Block::Header(Pair { key, val: _ }) = block {
1853 if matches!(key, Store::Empty) {
1854 return true;
1855 }
1856 let key_bytes = key.data(buf);
1857 // Both `keys_to_drop` and `key_lower` are pre-lowercased,
1858 // so a byte-equality compare is sufficient — a second
1859 // ASCII-fold pass via `compare_no_case` would just burn
1860 // cycles re-folding bytes that are already canonical.
1861 let key_lower: Vec<u8> = key_bytes.iter().map(u8::to_ascii_lowercase).collect();
1862 !keys_to_drop
1863 .iter()
1864 .any(|k| k.as_slice() == key_lower.as_slice())
1865 } else {
1866 true
1867 }
1868 });
1869 }
1870
1871 // ── insertion before the end-of-headers flag ──────────────────────
1872 // Every header we add (X-Forwarded-Host, operator-supplied set/append
1873 // edits) must land before `Block::Flags { end_header: true }` so the
1874 // converter emits them as part of the request header block. A
1875 // synthetic X-Forwarded-Host is prepended (it describes the rewrite,
1876 // not an operator policy).
1877 let end_header_idx = super::shared::end_of_headers_index(kawa);
1878
1879 if rewriting_host {
1880 let mut synth: Vec<Block> = Vec::with_capacity(1);
1881 if synthesises_xfh && let Some(orig) = original_authority.as_deref() {
1882 synth.push(Block::Header(Pair {
1883 key: Store::Static(b"X-Forwarded-Host"),
1884 val: Store::from_string(orig.to_owned()),
1885 }));
1886 }
1887 context.original_authority = original_authority;
1888 synth.append(&mut to_insert);
1889 to_insert = synth;
1890 }
1891 if !to_insert.is_empty() {
1892 let insert_at = end_header_idx.unwrap_or(kawa.blocks.len());
1893 for (offset, block) in to_insert.into_iter().enumerate() {
1894 kawa.blocks.insert(insert_at + offset, block);
1895 }
1896 }
1897}
1898
1899/// Copy a per-frontend response-edit slice into the per-stream
1900/// `HttpContext.headers_response` snapshot, applying `filter` to each
1901/// edit. The snapshot is cleared before the copy so a second pass on
1902/// the same context (the HSTS hoist + post-forward pattern in
1903/// `route_from_request`) overrides any earlier partial copy.
1904fn snapshot_response_edits<F>(target: &mut Vec<HeaderEditSnapshot>, src: &[HeaderEdit], filter: F)
1905where
1906 F: Fn(&HeaderEdit) -> bool,
1907{
1908 target.clear();
1909 for edit in src.iter().filter(|e| filter(e)) {
1910 target.push(HeaderEditSnapshot {
1911 key: edit.key.to_vec(),
1912 val: edit.val.to_vec(),
1913 mode: edit.mode,
1914 });
1915 }
1916}
1917
1918/// Resolve the protocol scheme to use when emitting a redirect's `Location`
1919/// header. Maps the proto enum onto `"http"` / `"https"`, with `USE_SAME`
1920/// preserving the request's scheme (HTTPS for TLS listeners, HTTP otherwise).
1921fn resolve_redirect_scheme(scheme: RedirectScheme, context: &HttpContext) -> &'static str {
1922 match scheme {
1923 RedirectScheme::UseHttps => "https",
1924 RedirectScheme::UseHttp => "http",
1925 RedirectScheme::UseSame => {
1926 if context.tls_server_name.is_some() {
1927 "https"
1928 } else {
1929 "http"
1930 }
1931 }
1932 }
1933}
1934
1935/// Build the `Location` URL for a redirect response. Defaults the port
1936/// suffix only when the operator provided one or when scheme defaults
1937/// would mismatch (port 80 on https / 443 on http stays implicit).
1938///
1939/// `host_override` and `path_override` carry the frontend's
1940/// `RewriteParts::run` output for `RedirectPolicy::PERMANENT` flows so
1941/// the 301 `Location` reflects `rewrite_host` / `rewrite_path` instead
1942/// of the original `:authority` / `:path`. The legacy
1943/// `cluster.https_redirect` path passes `None` for both — it has no
1944/// per-frontend rewrite knobs.
1945fn build_redirect_location(
1946 scheme: &str,
1947 context: &HttpContext,
1948 port: Option<u32>,
1949 host_override: Option<&str>,
1950 path_override: Option<&str>,
1951) -> String {
1952 let authority = host_override
1953 .or(context.authority.as_deref())
1954 .unwrap_or_default();
1955 let path = path_override.or(context.path.as_deref()).unwrap_or("/");
1956 // Strip an existing `:port` from the authority — operators typically
1957 // configure `https_redirect_port` precisely because the listener's
1958 // port differs from the redirect target. Bracketed IPv6 literals
1959 // like `[::1]` survive intact: `rsplit_once(':')` only triggers when
1960 // the suffix after the final `:` is entirely ASCII digits.
1961 let host_only = match authority.rsplit_once(':') {
1962 Some((host, port_part))
1963 if !port_part.is_empty() && port_part.bytes().all(|b| b.is_ascii_digit()) =>
1964 {
1965 host
1966 }
1967 _ => authority,
1968 };
1969 let port_suffix = match port {
1970 Some(80) if scheme == "http" => String::new(),
1971 Some(443) if scheme == "https" => String::new(),
1972 Some(p) => format!(":{p}"),
1973 None => String::new(),
1974 };
1975 format!("{scheme}://{host_only}{port_suffix}{path}")
1976}
1977
1978/// Exact-match test between an HTTP `:authority` / `Host` value and a TLS SNI.
1979///
1980/// Matching rules:
1981/// * The authority is stripped of its optional `:port` suffix. RFC 6066 §3
1982/// forbids a port in the SNI extension, so the SNI is compared against
1983/// the host component only.
1984/// * The comparison is case-insensitive (RFC 9110 §4.2.3 — hosts are
1985/// case-insensitive). The SNI is assumed to be already lowercased by
1986/// the caller (see `https.rs::upgrade_handshake`); only the authority
1987/// side needs on-the-fly `to_ascii_lowercase`.
1988/// * No wildcard logic: if the operator serves a wildcard certificate,
1989/// the SNI negotiated by the client is still the specific name that
1990/// client sent, and the request `:authority` must equal that specific
1991/// name exactly. This is the tightest possible TLS trust boundary.
1992///
1993/// The `:port` suffix is only stripped when the suffix is non-empty and
1994/// entirely ASCII digits. This keeps bracketed IPv6 literals like `[::1]`
1995/// intact: `rsplit_once(':')` would otherwise mis-split them.
1996pub(crate) fn authority_matches_sni(authority: &str, sni_lowercased: &str) -> bool {
1997 let host = strip_authority_port(authority);
1998 if host.len() != sni_lowercased.len() {
1999 return false;
2000 }
2001 host.as_bytes()
2002 .iter()
2003 .zip(sni_lowercased.as_bytes())
2004 .all(|(a, b)| a.to_ascii_lowercase() == *b)
2005}
2006
2007/// Strip the optional `:port` suffix from an authority value. Bracketed
2008/// IPv6 literals (`[::1]`, `[::1]:8443`) keep their inner colons intact:
2009/// the suffix is only stripped when the tail after the last `:` is
2010/// non-empty and entirely ASCII digits.
2011fn strip_authority_port(authority: &str) -> &str {
2012 match authority.rsplit_once(':') {
2013 Some((h, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => h,
2014 _ => authority,
2015 }
2016}
2017
2018/// RFC 6125 §6.4.3 wildcard-aware match of `:authority` against a SAN set
2019/// snapshot taken at TLS handshake.
2020///
2021/// Returns the matched SAN entry on success so the caller can log it.
2022///
2023/// Matching rules:
2024/// * Port suffix on the authority is stripped (same logic as
2025/// [`authority_matches_sni`], IPv6-bracket safe).
2026/// * Compare is ASCII case-insensitive (`:authority` is ASCII per
2027/// RFC 9113 §8.3.1; SAN entries are stored pre-lowercased by
2028/// `https.rs::upgrade_handshake`).
2029/// * `*.suffix` matches exactly one DNS label at the leftmost position
2030/// and only when that label is non-empty: it does NOT match the apex,
2031/// does NOT cross dots, and embedded wildcards (`foo.*.example.com`,
2032/// `*foo.example.com`) are forbidden.
2033/// * Empty `names` ⇒ `None` (default-cert path — Sōzu is not
2034/// authoritative for any name).
2035pub(crate) fn authority_matched_cert_name<'a>(
2036 authority: &str,
2037 names: &'a [String],
2038) -> Option<&'a str> {
2039 let mut host = strip_authority_port(authority);
2040 // RFC 1034 §3.1 absolute-form: `example.com.` and `example.com` name
2041 // the same host. The SAN snapshot already strips trailing dots at
2042 // `https.rs::upgrade_handshake`, and the SNI side strips them at the
2043 // same site; strip on the authority side so a client emitting
2044 // absolute-form `:authority` (or H1 `Host`) does not get a false 421.
2045 // Only one trailing dot is removed because RFC 1034 forbids multiple
2046 // trailing dots on a domain literal.
2047 if let Some(trimmed) = host.strip_suffix('.') {
2048 host = trimmed;
2049 }
2050 if host.is_empty() {
2051 return None;
2052 }
2053 for entry in names {
2054 if let Some(suffix) = entry.strip_prefix("*.") {
2055 // RFC 6125 §6.4.3: the wildcard label is the *entire* left-most
2056 // label. Embedded wildcards (`f*.example.com`, `*f.example.com`)
2057 // are rejected because we reach this branch only when the entry
2058 // starts with the exact two bytes `*.`. We still must reject
2059 // wildcards anywhere else in the entry by requiring no further
2060 // `*` in `suffix`.
2061 if suffix.contains('*') {
2062 continue;
2063 }
2064 // Authority has the form `<left-most-label>.<rest>`; the
2065 // wildcard substitutes for exactly that left-most label, which
2066 // must be non-empty and contain no dot.
2067 let Some((leftmost, rest)) = host.split_once('.') else {
2068 continue;
2069 };
2070 if leftmost.is_empty() {
2071 continue;
2072 }
2073 if rest.eq_ignore_ascii_case(suffix) {
2074 return Some(entry);
2075 }
2076 continue;
2077 }
2078 if entry.contains('*') {
2079 // Internal wildcards (`foo.*.example.com`) are not RFC 6125-
2080 // valid. Skip rather than mis-match.
2081 continue;
2082 }
2083 if host.eq_ignore_ascii_case(entry) {
2084 return Some(entry);
2085 }
2086 }
2087 None
2088}
2089
2090#[cfg(test)]
2091mod tests {
2092 use std::{cell::RefCell, collections::BTreeMap, rc::Rc, time::Duration};
2093
2094 use sozu_command::{
2095 config::ListenerBuilder,
2096 proto::command::{
2097 ForwardedHeaders, PathRule, RedirectPolicy, RequestHttpFrontend, RulePosition,
2098 SocketAddress,
2099 },
2100 };
2101
2102 use super::{Router, RoutingView, authority_matches_sni};
2103 use crate::{
2104 L7Proxy,
2105 http::HttpProxy,
2106 protocol::{
2107 http::{editor::HttpContext, parser::Method},
2108 mux::{buffer_source::PoolBufferSource, stream::Stream},
2109 },
2110 };
2111
2112 /// A frontend declared as `*.example.com` and carrying `--tags` must put
2113 /// those tags on the access-log line of a request whose authority is
2114 /// `foo.example.com` (sozu#1379).
2115 ///
2116 /// The bug this pins: the tag cache was WRITTEN under the frontend
2117 /// RULE's hostname (`listener.set_tags(front.hostname, …)` in
2118 /// `lib/src/http.rs` / `lib/src/https.rs`) and READ back under the
2119 /// REQUEST's authority (`listener.get_tags(hostname)` in
2120 /// `lib/src/protocol/mux/stream.rs`), against a `BTreeMap<String,
2121 /// CachedTags>` that does exact-key lookup only. `*.example.com` is a
2122 /// key `foo.example.com` can never produce, so every access-log line of
2123 /// every wildcard, regex, ported or differently-cased frontend lost its
2124 /// tags silently. The fix resolves the tags through the router, which
2125 /// already knows which frontend rule matched.
2126 ///
2127 /// The test drives the production add path (`HttpProxy::add_http_frontend`),
2128 /// the production routing path (`Router::route_from_request`) and the
2129 /// production emit path (`Stream::generate_access_log`), and asserts on
2130 /// the rendered access-log line rather than on any intermediate field,
2131 /// so it stays valid whatever the resolution mechanism becomes.
2132 ///
2133 /// To SEE THIS RED: in `Router::route_from_request`
2134 /// (`lib/src/protocol/mux/router.rs`), replace the
2135 /// `context.tags = tags;` stash that follows the `RouteResult`
2136 /// destructure with `let _ = tags;`. The access log then falls back to
2137 /// the authority-keyed listener map, which holds only the
2138 /// `*.example.com` key, and the line is emitted with an empty `[]` tag
2139 /// field. Dropping `|| front.tags.is_some()` from `has_policy` in
2140 /// `Router::add_http_front_with_hsts_origin`
2141 /// (`lib/src/router/mod.rs`) reddens it the same way, one step
2142 /// earlier: the frontend is then stored as a tagless
2143 /// `Route::ClusterId` and there is nothing left for the stash to
2144 /// carry.
2145 #[test]
2146 fn a_wildcard_frontends_tags_reach_the_access_log_of_a_concrete_authority() {
2147 const TAG: &str = "owner=team-wildcard-1379";
2148
2149 let output = crate::capture_test_logs(|| {
2150 let port = crate::testing::provide_port();
2151 let address = SocketAddress::new_v4(127, 0, 0, 1, port);
2152 let config = ListenerBuilder::new_http(address)
2153 .to_http(None)
2154 .expect("test http listener config must build");
2155 let parts = crate::testing::prebuild_server(10, 16_384, false)
2156 .expect("test server parts must build");
2157 let pool = parts.pool.clone();
2158 let mut proxy =
2159 HttpProxy::new(parts.registry, parts.sessions, parts.pool, parts.backends);
2160 let token = mio::Token(0);
2161 proxy
2162 .add_listener(config, token)
2163 .expect("test listener must register");
2164 proxy
2165 .add_http_frontend(RequestHttpFrontend {
2166 cluster_id: Some("cluster-1379".to_owned()),
2167 address,
2168 hostname: "*.example.com".to_owned(),
2169 path: PathRule::prefix("/".to_owned()),
2170 position: RulePosition::Tree.into(),
2171 tags: BTreeMap::from([("owner".to_owned(), "team-wildcard-1379".to_owned())]),
2172 ..Default::default()
2173 })
2174 .expect("the wildcard frontend must register");
2175 let listener = proxy
2176 .get_listener(&token)
2177 .expect("the registered listener must be reachable");
2178 let proxy: Rc<RefCell<dyn L7Proxy>> = Rc::new(RefCell::new(proxy));
2179
2180 let mut context = HttpContext::new(
2181 rusty_ulid::Ulid::generate(),
2182 rusty_ulid::Ulid::generate(),
2183 crate::Protocol::HTTP,
2184 "127.0.0.1:80"
2185 .parse()
2186 .expect("test public address must parse"),
2187 Some(
2188 "127.0.0.1:12345"
2189 .parse()
2190 .expect("test session address must parse"),
2191 ),
2192 "SOZUBALANCEID".to_owned(),
2193 "Sozu-Id".to_owned(),
2194 false,
2195 false,
2196 );
2197 context.authority = Some("foo.example.com".to_owned());
2198 context.path = Some("/".to_owned());
2199 context.method = Some(Method::Get);
2200
2201 let mut stream = Stream::new(
2202 &mut PoolBufferSource::new(Rc::downgrade(&pool)),
2203 context,
2204 crate::protocol::mux::test_support::test_answers(),
2205 65_535,
2206 )
2207 .expect("test stream must check out its buffers");
2208 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
2209 let (front, stream_context) = {
2210 let split = &mut stream;
2211 (&mut split.front, &mut split.context)
2212 };
2213 let proxy_ref = proxy.borrow();
2214 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
2215 router
2216 .route_from_request(stream_context, front, &listener, &view)
2217 .expect("the wildcard frontend must route foo.example.com");
2218 drop(proxy_ref);
2219
2220 // This test asserts on the access log, not on metrics.
2221 let _ = stream.generate_access_log(false, None, listener, None, None);
2222 });
2223
2224 assert!(
2225 output.contains(TAG),
2226 "the access log of a request routed by `*.example.com` must carry that frontend's tags, got: {output}"
2227 );
2228 }
2229
2230 /// `HttpContext::original_authority` records the authority the client
2231 /// sent only when the frontend rewrites the host, the one case where it
2232 /// is sent on as `X-Forwarded-Host`; a request without a host rewrite
2233 /// leaves it `None` instead of paying a copy of the authority (#1589).
2234 ///
2235 /// To SEE THIS RED: stamp `context.original_authority` with a copy of
2236 /// `host` in `Router::route_from_request` before the frontend lookup,
2237 /// as it was before #1589. The plain request then carries
2238 /// `Some("plain.example.com")`.
2239 #[test]
2240 fn only_a_host_rewrite_records_the_original_authority() {
2241 let port = crate::testing::provide_port();
2242 let address = SocketAddress::new_v4(127, 0, 0, 1, port);
2243 let config = ListenerBuilder::new_http(address)
2244 .to_http(None)
2245 .expect("test http listener config must build");
2246 let parts = crate::testing::prebuild_server(10, 16_384, false)
2247 .expect("test server parts must build");
2248 let pool = parts.pool.clone();
2249 let mut proxy = HttpProxy::new(parts.registry, parts.sessions, parts.pool, parts.backends);
2250 let token = mio::Token(0);
2251 proxy
2252 .add_listener(config, token)
2253 .expect("test listener must register");
2254 for (hostname, rewrite_host) in [
2255 ("rewrite.example.com", Some("internal.example.com")),
2256 ("plain.example.com", None),
2257 ] {
2258 proxy
2259 .add_http_frontend(RequestHttpFrontend {
2260 cluster_id: Some("cluster-1589".to_owned()),
2261 address,
2262 hostname: hostname.to_owned(),
2263 path: PathRule::prefix("/".to_owned()),
2264 position: RulePosition::Tree.into(),
2265 rewrite_host: rewrite_host.map(str::to_owned),
2266 ..Default::default()
2267 })
2268 .unwrap_or_else(|error| panic!("{hostname} must register: {error}"));
2269 }
2270 let listener = proxy
2271 .get_listener(&token)
2272 .expect("the registered listener must be reachable");
2273 let proxy: Rc<RefCell<dyn L7Proxy>> = Rc::new(RefCell::new(proxy));
2274 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
2275
2276 for (authority, expected) in [
2277 ("rewrite.example.com", Some("rewrite.example.com")),
2278 ("plain.example.com", None),
2279 ] {
2280 let mut context = HttpContext::new(
2281 rusty_ulid::Ulid::generate(),
2282 rusty_ulid::Ulid::generate(),
2283 crate::Protocol::HTTP,
2284 "127.0.0.1:80"
2285 .parse()
2286 .expect("test public address must parse"),
2287 None,
2288 "SOZUBALANCEID".to_owned(),
2289 "Sozu-Id".to_owned(),
2290 false,
2291 false,
2292 );
2293 context.authority = Some(authority.to_owned());
2294 context.path = Some("/".to_owned());
2295 context.method = Some(Method::Get);
2296 let mut stream = Stream::new(
2297 &mut PoolBufferSource::new(Rc::downgrade(&pool)),
2298 context,
2299 crate::protocol::mux::test_support::test_answers(),
2300 65_535,
2301 )
2302 .expect("test stream must check out its buffers");
2303 let split = &mut stream;
2304 let proxy_ref = proxy.borrow();
2305 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
2306 router
2307 .route_from_request(&mut split.context, &mut split.front, &listener, &view)
2308 .unwrap_or_else(|error| panic!("{authority} must route: {error}"));
2309
2310 assert_eq!(
2311 split.context.original_authority.as_deref(),
2312 expected,
2313 "{authority}: original_authority is recorded exactly when the host is rewritten",
2314 );
2315 let forwarded_host = split.front.blocks.iter().find_map(|block| match block {
2316 kawa::Block::Header(pair)
2317 if pair.key.data(split.front.storage.buffer()) == b"X-Forwarded-Host" =>
2318 {
2319 Some(pair.val.data(split.front.storage.buffer()).to_vec())
2320 }
2321 _ => None,
2322 });
2323 assert_eq!(
2324 forwarded_host.as_deref(),
2325 expected.map(str::as_bytes),
2326 "{authority}: X-Forwarded-Host carries the authority the client sent, on a rewrite only",
2327 );
2328 }
2329 }
2330
2331 /// A frontend whose policy is `RedirectPolicy::SilentClose` refuses the
2332 /// request with `RetrieveClusterError::SilentClose` whether or not it is
2333 /// bound to a cluster, before any redirect or 401 decision, so the caller
2334 /// writes no response at all (sozu-proxy/sozu#767).
2335 ///
2336 /// To SEE THIS RED: delete the `RedirectPolicy::SilentClose` early return
2337 /// that opens the policy steps of `Router::route_from_request`
2338 /// (`lib/src/protocol/mux/router.rs`). The bound frontend then forwards
2339 /// (`Ok`) and the clusterless one answers `UnauthorizedRoute`.
2340 #[test]
2341 fn a_silent_close_frontend_refuses_without_an_answer_bound_or_clusterless() {
2342 let port = crate::testing::provide_port();
2343 let address = SocketAddress::new_v4(127, 0, 0, 1, port);
2344 let config = ListenerBuilder::new_http(address)
2345 .to_http(None)
2346 .expect("test http listener config must build");
2347 let parts = crate::testing::prebuild_server(10, 16_384, false)
2348 .expect("test server parts must build");
2349 let pool = parts.pool.clone();
2350 let mut proxy = HttpProxy::new(parts.registry, parts.sessions, parts.pool, parts.backends);
2351 let token = mio::Token(0);
2352 proxy
2353 .add_listener(config, token)
2354 .expect("test listener must register");
2355 for (hostname, cluster_id) in [
2356 ("bound.example.com", Some("cluster-767")),
2357 ("clusterless.example.com", None),
2358 ] {
2359 proxy
2360 .add_http_frontend(RequestHttpFrontend {
2361 cluster_id: cluster_id.map(str::to_owned),
2362 address,
2363 hostname: hostname.to_owned(),
2364 path: PathRule::prefix("/".to_owned()),
2365 position: RulePosition::Tree.into(),
2366 redirect: Some(RedirectPolicy::SilentClose as i32),
2367 ..Default::default()
2368 })
2369 .unwrap_or_else(|error| panic!("{hostname} must register: {error}"));
2370 }
2371 let listener = proxy
2372 .get_listener(&token)
2373 .expect("the registered listener must be reachable");
2374 let proxy: Rc<RefCell<dyn L7Proxy>> = Rc::new(RefCell::new(proxy));
2375 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
2376
2377 for authority in ["bound.example.com", "clusterless.example.com"] {
2378 let mut context = HttpContext::new(
2379 rusty_ulid::Ulid::generate(),
2380 rusty_ulid::Ulid::generate(),
2381 crate::Protocol::HTTP,
2382 "127.0.0.1:80"
2383 .parse()
2384 .expect("test public address must parse"),
2385 None,
2386 "SOZUBALANCEID".to_owned(),
2387 "Sozu-Id".to_owned(),
2388 false,
2389 false,
2390 );
2391 context.authority = Some(authority.to_owned());
2392 context.path = Some("/".to_owned());
2393 context.method = Some(Method::Get);
2394 let mut stream = Stream::new(
2395 &mut PoolBufferSource::new(Rc::downgrade(&pool)),
2396 context,
2397 crate::protocol::mux::test_support::test_answers(),
2398 65_535,
2399 )
2400 .expect("test stream must check out its buffers");
2401 let split = &mut stream;
2402 let proxy_ref = proxy.borrow();
2403 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
2404 let result =
2405 router.route_from_request(&mut split.context, &mut split.front, &listener, &view);
2406
2407 assert!(
2408 matches!(result, Err(crate::RetrieveClusterError::SilentClose)),
2409 "{authority}: a silent-close frontend must refuse with SilentClose, got {result:?}",
2410 );
2411 assert_eq!(
2412 split.context.redirect_status, None,
2413 "{authority}: a silent close must not stage a redirect answer",
2414 );
2415 }
2416 }
2417
2418 /// On a host rewrite, `X-Forwarded-Host` follows the listener's
2419 /// `forwarded_headers` mode: `both` and `x_forwarded` replace a client
2420 /// one with the authority the client sent, `rfc7239` and `none` inject
2421 /// none, and `none` leaves a client one as sent. (`rfc7239` elided the
2422 /// client one earlier, in `HttpContext::on_request_headers`.)
2423 ///
2424 /// To SEE THIS RED: in `apply_request_rewrites_and_headers`, set
2425 /// `synthesises_xfh` to `rewriting_host` alone. `rfc7239` and `none` then
2426 /// carry `X-Forwarded-Host: rewrite.example.com`.
2427 #[test]
2428 fn a_host_rewrite_discloses_x_forwarded_host_only_in_x_forwarded_modes() {
2429 let port = crate::testing::provide_port();
2430 let address = SocketAddress::new_v4(127, 0, 0, 1, port);
2431 let config = ListenerBuilder::new_http(address)
2432 .to_http(None)
2433 .expect("test http listener config must build");
2434 let parts = crate::testing::prebuild_server(10, 16_384, false)
2435 .expect("test server parts must build");
2436 let pool = parts.pool.clone();
2437 let mut proxy = HttpProxy::new(parts.registry, parts.sessions, parts.pool, parts.backends);
2438 let token = mio::Token(0);
2439 proxy
2440 .add_listener(config, token)
2441 .expect("test listener must register");
2442 proxy
2443 .add_http_frontend(RequestHttpFrontend {
2444 cluster_id: Some("cluster-322".to_owned()),
2445 address,
2446 hostname: "rewrite.example.com".to_owned(),
2447 path: PathRule::prefix("/".to_owned()),
2448 position: RulePosition::Tree.into(),
2449 rewrite_host: Some("internal.example.com".to_owned()),
2450 ..Default::default()
2451 })
2452 .expect("the rewriting frontend must register");
2453 let listener = proxy
2454 .get_listener(&token)
2455 .expect("the registered listener must be reachable");
2456 let proxy: Rc<RefCell<dyn L7Proxy>> = Rc::new(RefCell::new(proxy));
2457 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
2458
2459 for (mode, client_xfh, expected) in [
2460 (
2461 ForwardedHeaders::Both,
2462 true,
2463 vec![b"rewrite.example.com".to_vec()],
2464 ),
2465 (
2466 ForwardedHeaders::XForwarded,
2467 true,
2468 vec![b"rewrite.example.com".to_vec()],
2469 ),
2470 (ForwardedHeaders::Rfc7239, false, vec![]),
2471 (
2472 ForwardedHeaders::None,
2473 true,
2474 vec![b"client.example".to_vec()],
2475 ),
2476 ] {
2477 let mut context = HttpContext::new(
2478 rusty_ulid::Ulid::generate(),
2479 rusty_ulid::Ulid::generate(),
2480 crate::Protocol::HTTP,
2481 "127.0.0.1:80"
2482 .parse()
2483 .expect("test public address must parse"),
2484 None,
2485 "SOZUBALANCEID".to_owned(),
2486 "Sozu-Id".to_owned(),
2487 false,
2488 false,
2489 );
2490 context.forwarded_headers = mode;
2491 context.authority = Some("rewrite.example.com".to_owned());
2492 context.path = Some("/".to_owned());
2493 context.method = Some(Method::Get);
2494 let mut stream = Stream::new(
2495 &mut PoolBufferSource::new(Rc::downgrade(&pool)),
2496 context,
2497 crate::protocol::mux::test_support::test_answers(),
2498 65_535,
2499 )
2500 .expect("test stream must check out its buffers");
2501 if client_xfh {
2502 stream.front.push_block(kawa::Block::Header(kawa::Pair {
2503 key: kawa::Store::Static(b"X-Forwarded-Host"),
2504 val: kawa::Store::Static(b"client.example"),
2505 }));
2506 }
2507 let split = &mut stream;
2508 let proxy_ref = proxy.borrow();
2509 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
2510 router
2511 .route_from_request(&mut split.context, &mut split.front, &listener, &view)
2512 .unwrap_or_else(|error| panic!("{mode:?} must route: {error}"));
2513
2514 let buffer = split.front.storage.buffer();
2515 let forwarded_hosts: Vec<Vec<u8>> = split
2516 .front
2517 .blocks
2518 .iter()
2519 .filter_map(|block| match block {
2520 kawa::Block::Header(pair)
2521 if !pair.is_elided()
2522 && pair
2523 .key
2524 .data(buffer)
2525 .eq_ignore_ascii_case(b"X-Forwarded-Host") =>
2526 {
2527 Some(pair.val.data(buffer).to_vec())
2528 }
2529 _ => None,
2530 })
2531 .collect();
2532 assert_eq!(
2533 forwarded_hosts, expected,
2534 "{mode:?}: X-Forwarded-Host after a host rewrite"
2535 );
2536 }
2537 }
2538
2539 // Asserts on `debug!` output: gate on the same condition that compiles it in.
2540 #[cfg(any(debug_assertions, feature = "logs-debug", feature = "logs-trace"))]
2541 #[test]
2542 fn routing_runtime_logs_bound_method_authority_sni_and_certificate_names() {
2543 use super::{log_coalescing_accepted, log_sni_authority_mismatch};
2544
2545 const METHOD_SECRET: &str = "MUX_ROUTE_METHOD_SECRET_SENTINEL";
2546 const AUTHORITY_SECRET: &str = "MUX_ROUTE_AUTHORITY_SECRET_SENTINEL";
2547 const SNI_SECRET: &str = "MUX_ROUTE_SNI_SECRET_SENTINEL";
2548 const SAN_SECRET: &str = "MUX_ROUTE_SAN_SECRET_SENTINEL";
2549
2550 let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
2551 let method = long_value(METHOD_SECRET);
2552 let authority = long_value(AUTHORITY_SECRET);
2553 let sni = long_value(SNI_SECRET);
2554 let san = long_value(SAN_SECRET);
2555 let method_len = method.len();
2556 let authority_len = authority.len();
2557 let sni_len = sni.len();
2558 let san_len = san.len();
2559
2560 let output = crate::capture_test_logs_at_level("debug", move || {
2561 let mut context = HttpContext::new(
2562 rusty_ulid::Ulid::generate(),
2563 rusty_ulid::Ulid::generate(),
2564 crate::Protocol::HTTPS,
2565 "127.0.0.1:443"
2566 .parse()
2567 .expect("test public address must parse"),
2568 Some(
2569 "127.0.0.1:12345"
2570 .parse()
2571 .expect("test session address must parse"),
2572 ),
2573 String::new(),
2574 String::new(),
2575 false,
2576 false,
2577 );
2578 context.method = Some(crate::protocol::http::parser::Method::Custom(method));
2579 context.authority = Some(authority.clone());
2580 context.tls_cert_names = Some(std::sync::Arc::new(vec![san.clone()]));
2581
2582 log_coalescing_accepted(&context, &authority, &sni, &san);
2583 log_sni_authority_mismatch(
2584 &context,
2585 &authority,
2586 &sni,
2587 context.tls_cert_names.as_deref().map(Vec::as_slice),
2588 );
2589 });
2590
2591 for secret in [METHOD_SECRET, AUTHORITY_SECRET, SNI_SECRET, SAN_SECRET] {
2592 assert!(
2593 !output.contains(secret),
2594 "mux routing runtime log leaked {secret}: {output}"
2595 );
2596 }
2597 for metadata in [
2598 format!("bytes={method_len}"),
2599 format!("authority_bytes=Some({authority_len})"),
2600 format!("authority_bytes={authority_len}"),
2601 format!("sni_bytes={sni_len}"),
2602 format!("matched_san_bytes={san_len}"),
2603 "certificate_sans_count=1".to_owned(),
2604 format!("certificate_sans_bytes={san_len}"),
2605 ] {
2606 assert!(
2607 output.contains(&metadata),
2608 "mux routing runtime log omitted {metadata}: {output}"
2609 );
2610 }
2611 assert!(
2612 output.len() <= 2048,
2613 "mux routing runtime capture is not bounded: {} bytes",
2614 output.len()
2615 );
2616 }
2617
2618 #[test]
2619 fn match_exact() {
2620 assert!(authority_matches_sni("example.com", "example.com"));
2621 }
2622
2623 #[test]
2624 fn match_different_case() {
2625 assert!(authority_matches_sni("Example.COM", "example.com"));
2626 }
2627
2628 #[test]
2629 fn match_authority_with_port() {
2630 assert!(authority_matches_sni("example.com:8443", "example.com"));
2631 }
2632
2633 #[test]
2634 fn reject_different_host() {
2635 assert!(!authority_matches_sni(
2636 "tenant-b.example.com",
2637 "tenant-a.example.com"
2638 ));
2639 }
2640
2641 #[test]
2642 fn reject_substring_attack() {
2643 // Length check guards against an authority that is a prefix or
2644 // suffix of the SNI (or vice versa).
2645 assert!(!authority_matches_sni("example.co", "example.com"));
2646 assert!(!authority_matches_sni("example.commons", "example.com"));
2647 }
2648
2649 #[test]
2650 fn reject_wildcard_not_expanded() {
2651 // Wildcard cert selection happens at the cert-resolver layer; the SNI
2652 // we see here is the concrete name the client sent. Do not silently
2653 // accept `*.example.com` as matching `foo.example.com`.
2654 assert!(!authority_matches_sni("foo.example.com", "*.example.com"));
2655 }
2656
2657 #[test]
2658 fn ipv6_bracketed_literal_with_port() {
2659 // `[::1]:8443` must still match the SNI `[::1]`; only the trailing
2660 // `:8443` is a port (all digits → stripped).
2661 assert!(authority_matches_sni("[::1]:8443", "[::1]"));
2662 }
2663
2664 #[test]
2665 fn ipv6_bracketed_without_port() {
2666 // The `:` characters inside the brackets must not be mistaken for a
2667 // port separator: the tail after the last `:` is `1]`, not all
2668 // digits, so it is NOT stripped and the whole string compares.
2669 assert!(authority_matches_sni("[::1]", "[::1]"));
2670 }
2671}
2672
2673#[cfg(test)]
2674mod authority_matched_cert_name_tests {
2675 use super::authority_matched_cert_name;
2676
2677 #[test]
2678 fn cert_name_match_exact_single_san() {
2679 let names = vec!["example.com".to_owned()];
2680 assert_eq!(
2681 authority_matched_cert_name("example.com", &names),
2682 Some("example.com"),
2683 );
2684 }
2685
2686 #[test]
2687 fn cert_name_match_wildcard_left_most() {
2688 let names = vec!["*.cleverapps.io".to_owned()];
2689 assert_eq!(
2690 authority_matched_cert_name("staging-3.cleverapps.io", &names),
2691 Some("*.cleverapps.io"),
2692 );
2693 }
2694
2695 #[test]
2696 fn cert_name_reject_wildcard_apex() {
2697 // RFC 6125 §6.4.3: `*.example.com` does NOT cover the apex
2698 // `example.com` — the wildcard label must consume exactly one
2699 // non-empty label.
2700 let names = vec!["*.example.com".to_owned()];
2701 assert_eq!(authority_matched_cert_name("example.com", &names), None);
2702 }
2703
2704 #[test]
2705 fn cert_name_reject_wildcard_two_labels() {
2706 // `*.example.com` cannot cross dots: `a.b.example.com` has two
2707 // labels before `example.com` and must be rejected.
2708 let names = vec!["*.example.com".to_owned()];
2709 assert_eq!(authority_matched_cert_name("a.b.example.com", &names), None,);
2710 }
2711
2712 #[test]
2713 fn cert_name_reject_wildcard_not_left_most() {
2714 // Embedded wildcards (`foo.*.example.com`) are not RFC 6125-valid
2715 // and must be skipped, not mis-matched.
2716 let names = vec!["foo.*.example.com".to_owned()];
2717 assert_eq!(
2718 authority_matched_cert_name("foo.bar.example.com", &names),
2719 None,
2720 );
2721 }
2722
2723 #[test]
2724 fn cert_name_match_case_insensitive() {
2725 // ASCII case folding only — `:authority` is ASCII per RFC 9113
2726 // §8.3.1 and the snapshot is pre-lowercased at handshake.
2727 let names = vec!["EXAMPLE.com".to_owned()];
2728 assert!(authority_matched_cert_name("Example.COM", &names).is_some());
2729 }
2730
2731 #[test]
2732 fn cert_name_match_with_port() {
2733 // The port suffix on `:authority` must be stripped before the
2734 // SAN compare.
2735 let names = vec!["example.com".to_owned()];
2736 assert!(authority_matched_cert_name("example.com:8443", &names).is_some());
2737 }
2738
2739 #[test]
2740 fn cert_name_match_absolute_form_trailing_dot() {
2741 // RFC 1034 §3.1: an absolute-form domain literal carries one
2742 // trailing dot (`example.com.`) and resolves to the same host as
2743 // the relative form. The SAN snapshot stores the relative form
2744 // (https.rs strips the trailing dot at handshake), so the matcher
2745 // must strip it on the authority side too — otherwise a client
2746 // emitting an absolute-form `:authority` gets a false 421.
2747 let names = vec!["example.com".to_owned()];
2748 assert!(authority_matched_cert_name("example.com.", &names).is_some());
2749 // And with both port and trailing dot.
2750 assert!(authority_matched_cert_name("example.com.:8443", &names).is_some());
2751 // The wildcard branch must also accept the absolute form.
2752 let wildcard = vec!["*.example.com".to_owned()];
2753 assert!(authority_matched_cert_name("foo.example.com.", &wildcard).is_some());
2754 }
2755
2756 #[test]
2757 fn cert_name_match_idn_a_label() {
2758 // IDNA A-labels (xn--…) are ASCII and compare byte-for-byte once
2759 // the snapshot is lowercased.
2760 let names = vec!["xn--bcher-kva.example.com".to_owned()];
2761 assert!(authority_matched_cert_name("xn--bcher-kva.example.com", &names).is_some());
2762 }
2763
2764 #[test]
2765 fn cert_name_reject_empty_names() {
2766 // Empty snapshot = default cert served = Sōzu is not
2767 // authoritative for any name; every authority must miss.
2768 assert_eq!(authority_matched_cert_name("example.com", &[]), None);
2769 }
2770
2771 #[test]
2772 fn cert_name_match_multi_san_one_hit() {
2773 let names = vec!["foo.com".to_owned(), "*.example.org".to_owned()];
2774 assert_eq!(
2775 authority_matched_cert_name("bar.example.org", &names),
2776 Some("*.example.org"),
2777 );
2778 }
2779
2780 #[test]
2781 fn cert_name_reject_substring_attack() {
2782 // `*.example.com` must not match `example.commons` — the suffix
2783 // after the first label is `commons`, not `example.com`.
2784 let names = vec!["*.example.com".to_owned()];
2785 assert_eq!(authority_matched_cert_name("example.commons", &names), None,);
2786 }
2787
2788 #[test]
2789 fn cert_name_ipv6_bracketed_literal_with_port() {
2790 // The `:` characters inside the brackets must not be mistaken for
2791 // a port separator: only the trailing `:8443` is stripped, and
2792 // `[::1]` compares equal to `[::1]`.
2793 let names = vec!["[::1]".to_owned()];
2794 assert!(authority_matched_cert_name("[::1]:8443", &names).is_some());
2795 }
2796}
2797
2798/// Backend-selection order: `Router::plan_connect` must resolve a tie the same way
2799/// on every process, for the identical set of backend connections and the
2800/// identical request (issue #1338).
2801///
2802/// **Why these assert a SPECIFIC token rather than "the same one twice".**
2803/// `RandomState` is seeded per `HashMap`, not per iteration: two walks of one
2804/// live `HashMap` agree, so a test that runs the selection twice inside one
2805/// process and compares passes against a `HashMap` by construction. What does
2806/// NOT agree is two SEPARATELY CONSTRUCTED maps — `RandomState::default()`
2807/// bumps a thread-local key on every instantiation — so each round below
2808/// builds a fresh [`Router`], and therefore a fresh hasher, and asserts the
2809/// token the total order on `Token` picks.
2810///
2811/// That makes the red statistical rather than structural, and the bound is
2812/// stated so it can be checked rather than trusted: with `n` staged backends
2813/// and `r` rounds a `HashMap` survives with probability about `n.pow(-r)`,
2814/// which is `4^-24` here — about one run in 2.8e14. A `BTreeMap` survives
2815/// with probability 1. Measured on the pre-image, with the three production
2816/// lines reverted and these guards untouched: three consecutive runs failed
2817/// all three at round 0 or round 1. The least-loaded guard was handed
2818/// `Token(23)` and `Token(13)` where the total order gives `Token(7)`, and
2819/// the last-wins fallback `Token(7)` where it gives `Token(41)`.
2820///
2821/// **To SEE THESE RED:** in this module, put `Router::backends` back to
2822/// `HashMap<Token, Connection<SessionTcpStream>>`, `Router::new`'s initialiser
2823/// back to `HashMap::new()` and the import back to `collections::HashMap`
2824/// (the `BackendConnections` type then has no user left; delete it).
2825/// Reverting the file wholesale deletes these tests instead of reddening them.
2826#[cfg(test)]
2827mod backend_selection_order_tests {
2828 use std::{cell::RefCell, collections::HashMap, net::SocketAddr, rc::Rc, time::Duration};
2829
2830 use mio::Token;
2831 use rusty_ulid::Ulid;
2832 use sozu_command::{
2833 config::ListenerBuilder,
2834 proto::command::{
2835 Cluster, ListenerType, PathRule, RequestHttpFrontend, RulePosition, SocketAddress,
2836 },
2837 };
2838
2839 use super::Router;
2840 use crate::{
2841 BackendConnectionError, L7Proxy,
2842 backends::Backend,
2843 http::{HttpListener, HttpProxy},
2844 pool::Pool,
2845 protocol::{
2846 http::parser::Method,
2847 mux::{
2848 BackendId, BackendRegistry, BackendStatus, Connection, Context, Position,
2849 StreamState,
2850 buffer_source::PoolBufferSource,
2851 h2::H2ConnectionConfig,
2852 h2_flood_detector::H2FloodConfig,
2853 router::{ConnectPlan, ConnectStep, IpGateVerdict, RoutingView},
2854 },
2855 },
2856 socket::SessionTcpStream,
2857 };
2858
2859 /// Unwrap a step these fixtures can only produce one way.
2860 ///
2861 /// Every `Context` here is built with `session_address: None`, so the
2862 /// per-(cluster, source-IP) gate is skipped and `Router::plan_connect`
2863 /// must decide outright. A `CheckIpLimit` would mean the gate fired
2864 /// without a source IP, which is its own bug.
2865 fn decided(step: ConnectStep) -> ConnectPlan {
2866 match step {
2867 ConnectStep::Decided(plan) => plan,
2868 ConnectStep::CheckIpLimit(_) => panic!(
2869 "a stream with no session address must not reach the \
2870 per-(cluster, source-IP) gate"
2871 ),
2872 }
2873 }
2874
2875 /// Staged deliberately out of order and non-contiguous, so a green run
2876 /// cannot be an artefact of insertion order or of a dense key space.
2877 const STAGED_TOKENS: [usize; 4] = [23, 7, 41, 13];
2878 const LOWEST_TOKEN: usize = 7;
2879 const HIGHEST_TOKEN: usize = 41;
2880 /// See the module-level note for the `n.pow(-r)` bound this feeds.
2881 const ROUNDS: usize = 24;
2882
2883 const H2_CLUSTER: &str = "cluster-1338-h2";
2884 const H1_CLUSTER: &str = "cluster-1338-h1";
2885 const H2_AUTHORITY: &str = "h2.backend-order.example.com";
2886 const H1_AUTHORITY: &str = "h1.backend-order.example.com";
2887
2888 /// Which arm of `Router::plan_connect`'s scan the staged backends land in.
2889 enum Staged {
2890 /// `Position::Client(_, _, BackendStatus::Connected)` on an H2
2891 /// connection — the least-loaded arm, strict `<`, first-at-minimum.
2892 ConnectedH2,
2893 /// `BackendStatus::Connecting` on an H2 connection — the fallback
2894 /// arm, assigned with no `break` and no "already chosen" guard.
2895 ConnectingH2,
2896 /// `BackendStatus::KeepAlive` on an H1 connection — assigned with a
2897 /// `break`, so first-seen.
2898 KeepAliveH1,
2899 }
2900
2901 impl Staged {
2902 fn cluster(&self) -> &'static str {
2903 match self {
2904 Staged::ConnectedH2 | Staged::ConnectingH2 => H2_CLUSTER,
2905 Staged::KeepAliveH1 => H1_CLUSTER,
2906 }
2907 }
2908
2909 fn authority(&self) -> &'static str {
2910 match self {
2911 Staged::ConnectedH2 | Staged::ConnectingH2 => H2_AUTHORITY,
2912 Staged::KeepAliveH1 => H1_AUTHORITY,
2913 }
2914 }
2915 }
2916
2917 /// The proxy, listener, clusters, frontends and buffer pool
2918 /// `Router::plan_connect` needs. Built once and shared by every round:
2919 /// the order under test belongs to `Router::backends`, which each round
2920 /// rebuilds from scratch.
2921 struct RoutingFixture {
2922 proxy: Rc<RefCell<dyn L7Proxy>>,
2923 /// The map `proxy` was built over, which a `Mux` receives at
2924 /// construction.
2925 backends: Rc<RefCell<crate::backends::BackendMap>>,
2926 listener: Rc<RefCell<HttpListener>>,
2927 pool: Rc<RefCell<Pool>>,
2928 }
2929
2930 fn routing_fixture() -> RoutingFixture {
2931 let port = crate::testing::provide_port();
2932 let address = SocketAddress::new_v4(127, 0, 0, 1, port);
2933 let config = ListenerBuilder::new_http(address)
2934 .to_http(None)
2935 .expect("test http listener config must build");
2936 let parts =
2937 crate::testing::prebuild_server(32, 65_536, false).expect("test server must build");
2938 let pool = parts.pool.clone();
2939 let backends = Rc::clone(&parts.backends);
2940 let mut proxy = HttpProxy::new(parts.registry, parts.sessions, parts.pool, parts.backends);
2941 let listener_token = Token(0);
2942 proxy
2943 .add_listener(config, listener_token)
2944 .expect("test listener must register");
2945 for (cluster_id, authority, http2) in [
2946 (H2_CLUSTER, H2_AUTHORITY, Some(true)),
2947 (H1_CLUSTER, H1_AUTHORITY, None),
2948 ] {
2949 proxy
2950 .add_cluster(Cluster {
2951 cluster_id: cluster_id.to_owned(),
2952 http2,
2953 ..Default::default()
2954 })
2955 .expect("the test cluster must register");
2956 proxy
2957 .add_http_frontend(RequestHttpFrontend {
2958 cluster_id: Some(cluster_id.to_owned()),
2959 address,
2960 hostname: authority.to_owned(),
2961 path: PathRule::prefix("/".to_owned()),
2962 position: RulePosition::Tree.into(),
2963 ..Default::default()
2964 })
2965 .expect("the test frontend must register");
2966 }
2967 let listener = proxy
2968 .get_listener(&listener_token)
2969 .expect("the registered listener must be reachable");
2970 RoutingFixture {
2971 proxy: Rc::new(RefCell::new(proxy)),
2972 backends,
2973 listener,
2974 pool,
2975 }
2976 }
2977
2978 /// One backend connection in the requested arm, plus the accepted peer the
2979 /// caller must keep alive for the length of the round.
2980 fn staged_backend(
2981 pool: &Rc<RefCell<Pool>>,
2982 staged: &Staged,
2983 backend_registry: &mut BackendRegistry,
2984 ) -> (Connection<SessionTcpStream>, std::net::TcpStream) {
2985 let (socket, peer) = super::super::test_support::connected_socket();
2986 let backend_address: SocketAddr =
2987 "127.0.0.1:2".parse().expect("backend address must parse");
2988 let backend = Rc::new(RefCell::new(Backend::new(
2989 "test-backend",
2990 backend_address,
2991 None,
2992 None,
2993 None,
2994 )));
2995 // Through the embedder's table, exactly as a real dial does: the
2996 // handle stops here and the connection carries the opaque id.
2997 let backend = backend_registry.id_for(&backend);
2998 let session_ulid = Ulid::generate();
2999 let socket = SessionTcpStream::new(socket, session_ulid, Some(backend_address));
3000 let mut connection = match staged {
3001 Staged::ConnectedH2 | Staged::ConnectingH2 => Connection::new_h2_client(
3002 session_ulid,
3003 socket,
3004 staged.cluster().into(),
3005 backend,
3006 &mut PoolBufferSource::new(Rc::downgrade(pool)),
3007 Duration::from_secs(30),
3008 H2FloodConfig::default(),
3009 H2ConnectionConfig::default(),
3010 Duration::from_secs(30),
3011 None,
3012 )
3013 .expect("the test pool must hand out a buffer for the H2 backend"),
3014 Staged::KeepAliveH1 => Connection::new_h1_client(
3015 session_ulid,
3016 socket,
3017 staged.cluster().into(),
3018 backend,
3019 Duration::from_secs(30),
3020 ),
3021 };
3022 // `new_h*_client` opens in `Connecting`; the other two arms are
3023 // reached by moving the status the way a completed dial does.
3024 if let Position::Client(_, _, status) = connection.position_mut() {
3025 match staged {
3026 Staged::ConnectedH2 => *status = BackendStatus::Connected,
3027 Staged::ConnectingH2 => {}
3028 Staged::KeepAliveH1 => *status = BackendStatus::KeepAlive,
3029 }
3030 }
3031 (connection, peer)
3032 }
3033
3034 /// Stage [`STAGED_TOKENS`] into a FRESH [`Router`] — a fresh `RandomState`
3035 /// in the pre-image — drive one `Router::plan_connect`, and report the token it
3036 /// attached the stream to.
3037 fn selected_backend_token(fixture: &RoutingFixture, staged: &Staged) -> Token {
3038 let pool = &fixture.pool;
3039 let mut context = Context::new(
3040 Ulid::generate(),
3041 Rc::downgrade(pool),
3042 fixture.listener.clone(),
3043 // No session address: the per-(cluster, source-IP) gate is
3044 // skipped, so this never reaches the proxy's session manager.
3045 None,
3046 "127.0.0.1:80"
3047 .parse()
3048 .expect("test public address must parse"),
3049 );
3050 let stream_id = context
3051 .create_stream(Ulid::generate(), 65_535)
3052 .expect("the test pool must hand out a stream");
3053 {
3054 let stream = &mut context.streams[stream_id];
3055 stream.state = StreamState::Link;
3056 stream.context.authority = Some(staged.authority().to_owned());
3057 stream.context.path = Some("/".to_owned());
3058 stream.context.method = Some(Method::Get);
3059 }
3060
3061 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3062 let mut backend_registry = BackendRegistry::default();
3063 let mut peers = Vec::with_capacity(STAGED_TOKENS.len());
3064 for token in STAGED_TOKENS {
3065 let (connection, peer) = staged_backend(pool, staged, &mut backend_registry);
3066 peers.push(peer);
3067 router.backends.insert(Token(token), connection);
3068 }
3069
3070 // Every staged backend is reusable, so the plan must be `Attached`:
3071 // the router completed the attach itself and asked the embedder for
3072 // nothing. A `Dial` here would mean the reuse scan missed, which is
3073 // the failure this harness exists to catch.
3074 let proxy_ref = fixture.proxy.borrow();
3075 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
3076 let plan = router
3077 .plan_connect(stream_id, &mut context, &view)
3078 .map(decided)
3079 .expect("the router must reuse one of the staged backends");
3080 assert!(
3081 matches!(plan, ConnectPlan::Attached),
3082 "the staged backends are all reusable, so no dial may be requested: got {plan:?}"
3083 );
3084
3085 match context.streams[stream_id].state {
3086 StreamState::Linked(token) => token,
3087 other => panic!("connect must link the stream to a backend, got {other:?}"),
3088 }
3089 }
3090
3091 /// Drive one `Router::plan_connect` against a single staged connection of
3092 /// the `staged` arm, whose backend is retired when `retired` is set, and
3093 /// return the plan.
3094 fn plan_against_one_staged(
3095 fixture: &RoutingFixture,
3096 staged: &Staged,
3097 retired: bool,
3098 ) -> (ConnectPlan, StreamState) {
3099 let mut context = Context::new(
3100 Ulid::generate(),
3101 Rc::downgrade(&fixture.pool),
3102 fixture.listener.clone(),
3103 None,
3104 "127.0.0.1:80"
3105 .parse()
3106 .expect("test public address must parse"),
3107 );
3108 let stream_id = context
3109 .create_stream(Ulid::generate(), 65_535)
3110 .expect("the test pool must hand out a stream");
3111 {
3112 let stream = &mut context.streams[stream_id];
3113 stream.state = StreamState::Link;
3114 stream.context.authority = Some(staged.authority().to_owned());
3115 stream.context.path = Some("/".to_owned());
3116 stream.context.method = Some(Method::Get);
3117 }
3118 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3119 let mut backend_registry = BackendRegistry::default();
3120 let (connection, _peer) = staged_backend(&fixture.pool, staged, &mut backend_registry);
3121 if retired {
3122 // What `BackendList::remove_backend` and `BackendMap::remove_cluster`
3123 // do to the handle the session's registry still holds.
3124 let Position::Client(_, backend, _) = connection.position() else {
3125 unreachable!("a staged backend is a client connection")
3126 };
3127 backend_registry
3128 .handle(backend)
3129 .expect("the staged backend was interned")
3130 .borrow_mut()
3131 .set_closing();
3132 }
3133 router.backends.insert(Token(LOWEST_TOKEN), connection);
3134 let is_retired = |backend: &BackendId| backend_registry.is_retired(backend);
3135 let proxy_ref = fixture.proxy.borrow();
3136 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind())
3137 .with_backend_retired(&is_retired);
3138 let plan = router
3139 .plan_connect(stream_id, &mut context, &view)
3140 .map(decided)
3141 .expect("routing must resolve the staged cluster");
3142 (plan, context.streams[stream_id].state)
3143 }
3144
3145 /// The pool-reuse scan skips a connection whose backend has left the
3146 /// configuration, in each of its three reusable arms: H2 `Connected`, H2
3147 /// `Connecting` and H1 `KeepAlive`. The same connection with a live
3148 /// backend is reused, so a `Dial` here is the skip and not a scan that
3149 /// would have missed anyway.
3150 #[test]
3151 fn a_retired_backend_connection_is_never_reused() {
3152 let fixture = routing_fixture();
3153 for (staged, arm) in [
3154 (Staged::ConnectedH2, "H2 Connected"),
3155 (Staged::ConnectingH2, "H2 Connecting"),
3156 (Staged::KeepAliveH1, "H1 KeepAlive"),
3157 ] {
3158 let (plan, state) = plan_against_one_staged(&fixture, &staged, false);
3159 assert!(
3160 matches!(plan, ConnectPlan::Attached),
3161 "premise, {arm}: a connection to a live backend is reused, got {plan:?}"
3162 );
3163 assert_eq!(state, StreamState::Linked(Token(LOWEST_TOKEN)));
3164
3165 let (plan, state) = plan_against_one_staged(&fixture, &staged, true);
3166 assert!(
3167 matches!(&plan, ConnectPlan::Dial { cluster_id, .. } if &**cluster_id == staged.cluster()),
3168 "{arm}: a connection to a retired backend must not be reused, got {plan:?}"
3169 );
3170 assert_eq!(
3171 state,
3172 StreamState::Link,
3173 "{arm}: the stream must not be linked to the retired connection"
3174 );
3175 }
3176 }
3177
3178 /// `Mux::ready_inner` closes a retired connection only once it is idle:
3179 /// an H1 keep-alive, or a connected H2 connection with no stream. A dial
3180 /// still in progress is left to its own connect path.
3181 #[test]
3182 fn idle_pooled_backend_names_only_streamless_pooled_connections() {
3183 let fixture = routing_fixture();
3184 for (staged, arm, idle) in [
3185 (Staged::ConnectedH2, "H2 Connected", true),
3186 (Staged::ConnectingH2, "H2 Connecting", false),
3187 (Staged::KeepAliveH1, "H1 KeepAlive", true),
3188 ] {
3189 let mut backend_registry = BackendRegistry::default();
3190 let (connection, _peer) = staged_backend(&fixture.pool, &staged, &mut backend_registry);
3191 assert_eq!(
3192 connection.idle_pooled_backend().is_some(),
3193 idle,
3194 "{arm}: idle_pooled_backend must answer {idle}"
3195 );
3196 }
3197 }
3198
3199 /// An admitted stream must have been **counted** before the decision
3200 /// resumes — the ordering the gate-as-a-step split has to preserve.
3201 ///
3202 /// `SessionManager::track_cluster_ip` mutates
3203 /// `connections_per_cluster_ip`, which is keyed on `(cluster, ip)`
3204 /// **across every session on the worker** and read again by
3205 /// `lib/src/tcp.rs`'s own gate. So an `IpGateVerdict::Admitted` is an
3206 /// assertion that the slot has already been taken. Resume as `Admitted`
3207 /// without having tracked and the stream goes through uncounted, and the
3208 /// next session from the same IP finds a slot that should be gone.
3209 ///
3210 /// The test drives the real production sequence — `Router::plan_connect`
3211 /// → `super::consult_ip_gate` → `Router::plan_connect_resume` — for two
3212 /// DIFFERENT frontend tokens from the SAME source IP, against a limit of
3213 /// one. Two tokens, not one: the gate is deliberately idempotent within a
3214 /// token (an H2 session multiplexing many streams to one cluster holds a
3215 /// single slot), so a second stream on the same token could never trip
3216 /// the limit and would prove nothing.
3217 ///
3218 /// To SEE THIS RED: delete the `track_cluster_ip` call from
3219 /// `super::consult_ip_gate`, leaving it to return
3220 /// `IpGateVerdict::Admitted` without counting. The second token is then
3221 /// admitted too and the `AtLimit` assertion below fires.
3222 #[test]
3223 fn an_admitted_stream_is_counted_before_the_decision_resumes() {
3224 let fixture = routing_fixture();
3225 let sessions = fixture.proxy.borrow().sessions();
3226 let source: SocketAddr = "203.0.113.7:51000"
3227 .parse()
3228 .expect("test source address must parse");
3229
3230 // One connection per (cluster, ip).
3231 let mut clusters = HashMap::new();
3232 clusters.insert(
3233 H2_CLUSTER.into(),
3234 Cluster {
3235 cluster_id: H2_CLUSTER.to_owned(),
3236 max_connections_per_ip: Some(1),
3237 ..Default::default()
3238 },
3239 );
3240 let view = RoutingView::new(&clusters, ListenerType::Http);
3241
3242 // Drive one stream all the way through the three-step sequence and
3243 // report what the gate said and what the decision became.
3244 let run = |token: Token| {
3245 let mut context = Context::new(
3246 Ulid::generate(),
3247 Rc::downgrade(&fixture.pool),
3248 fixture.listener.clone(),
3249 Some(source),
3250 "127.0.0.1:80"
3251 .parse()
3252 .expect("test public address must parse"),
3253 );
3254 let stream_id = context
3255 .create_stream(Ulid::generate(), 65_535)
3256 .expect("the test pool must hand out a stream");
3257 {
3258 let stream = &mut context.streams[stream_id];
3259 stream.state = StreamState::Link;
3260 stream.context.authority = Some(H2_AUTHORITY.to_owned());
3261 stream.context.path = Some("/".to_owned());
3262 stream.context.method = Some(Method::Get);
3263 }
3264 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3265 let step = router
3266 .plan_connect(stream_id, &mut context, &view)
3267 .expect("routing must resolve the staged cluster");
3268 let ConnectStep::CheckIpLimit(resume) = step else {
3269 panic!("a stream WITH a session address must reach the per-IP gate")
3270 };
3271 let verdict = super::super::consult_ip_gate(
3272 &sessions,
3273 token,
3274 context
3275 .http_context(stream_id)
3276 .cluster_id
3277 .as_ref()
3278 .expect("plan_connect stamps the routed cluster"),
3279 &resume,
3280 );
3281 let admitted = matches!(verdict, IpGateVerdict::Admitted);
3282 let outcome =
3283 router.plan_connect_resume(stream_id, &mut context, &view, resume, verdict);
3284 let retry_after = context.streams[stream_id].context.retry_after_seconds;
3285 (admitted, outcome, retry_after)
3286 };
3287
3288 let (admitted, outcome, _) = run(Token(1));
3289 assert!(
3290 admitted,
3291 "premise: the first token must be under the limit, or the second proves nothing"
3292 );
3293 assert!(
3294 matches!(outcome, Ok(ConnectPlan::Dial { .. })),
3295 "an admitted stream must go on to request a dial, got {outcome:?}"
3296 );
3297
3298 let (admitted, outcome, retry_after) = run(Token(2));
3299 assert!(
3300 !admitted,
3301 "a SECOND token from the same source IP must find the slot already counted — \
3302 an Admitted verdict here means the first stream resumed without being tracked"
3303 );
3304 assert!(
3305 matches!(
3306 outcome,
3307 Err(BackendConnectionError::TooManyConnectionsPerIp { .. })
3308 ),
3309 "the refused stream must surface TooManyConnectionsPerIp, got {outcome:?}"
3310 );
3311 assert_eq!(
3312 retry_after, None,
3313 "with no cluster or global override the resolved Retry-After is 0, which is \
3314 stashed as None so the 429 mapping elides the header rather than sending 0"
3315 );
3316 }
3317
3318 /// A source holding a whole subnet defeats the per-(cluster, source-IP)
3319 /// cap outright: it takes a fresh address for every connection, so the
3320 /// per-address counter never reaches 2. The per-(cluster,
3321 /// source-SUBNET) cap is the answer, and this pins that it fires on
3322 /// DISTINCT addresses drawn from one masked network
3323 /// (sozu-proxy/sozu#1270).
3324 ///
3325 /// Three tokens, three different `203.0.113.x` addresses — every one a
3326 /// distinct `/32`, all inside `203.0.113.0/24`. The per-IP cap is set
3327 /// to 10, comfortably above the 1 connection each address holds, so it
3328 /// cannot be what refuses the fourth: only the subnet counter can.
3329 /// That is the whole point of the two caps being independent.
3330 ///
3331 /// To SEE THIS RED: delete the `|| self.cluster_subnet_at_limit(...)`
3332 /// disjunct from `SessionManager::cluster_connection_at_limit` in
3333 /// `lib/src/server.rs`, leaving every other piece of the feature —
3334 /// config keys, proto fields, masking, tracking — in place. The fourth
3335 /// address is then admitted and the `AtLimit` assertion below fires.
3336 #[test]
3337 fn a_subnet_cap_refuses_a_fresh_address_from_an_already_counted_subnet() {
3338 let fixture = routing_fixture();
3339 let sessions = fixture.proxy.borrow().sessions();
3340 {
3341 let mut manager = sessions.borrow_mut();
3342 // /24, so 203.0.113.* all mask to one key.
3343 manager.subnet_ipv4_prefix = 24;
3344 manager.max_connections_per_subnet = 3;
3345 // Deliberately far above what any single address will hold.
3346 manager.max_connections_per_ip = 10;
3347 }
3348
3349 let mut clusters = HashMap::new();
3350 clusters.insert(
3351 H2_CLUSTER.into(),
3352 Cluster {
3353 cluster_id: H2_CLUSTER.to_owned(),
3354 ..Default::default()
3355 },
3356 );
3357 let view = RoutingView::new(&clusters, ListenerType::Http);
3358
3359 // Drive one stream, from its own source address on its own token,
3360 // through the real three-step production sequence.
3361 let run = |token: Token, source: SocketAddr| {
3362 let mut context = Context::new(
3363 Ulid::generate(),
3364 Rc::downgrade(&fixture.pool),
3365 fixture.listener.clone(),
3366 Some(source),
3367 "127.0.0.1:80"
3368 .parse()
3369 .expect("test public address must parse"),
3370 );
3371 let stream_id = context
3372 .create_stream(Ulid::generate(), 65_535)
3373 .expect("the test pool must hand out a stream");
3374 {
3375 let stream = &mut context.streams[stream_id];
3376 stream.state = StreamState::Link;
3377 stream.context.authority = Some(H2_AUTHORITY.to_owned());
3378 stream.context.path = Some("/".to_owned());
3379 stream.context.method = Some(Method::Get);
3380 }
3381 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3382 let step = router
3383 .plan_connect(stream_id, &mut context, &view)
3384 .expect("routing must resolve the staged cluster");
3385 let ConnectStep::CheckIpLimit(resume) = step else {
3386 panic!("a stream WITH a session address must reach the connection gate")
3387 };
3388 let verdict = super::super::consult_ip_gate(
3389 &sessions,
3390 token,
3391 context
3392 .http_context(stream_id)
3393 .cluster_id
3394 .as_ref()
3395 .expect("plan_connect stamps the routed cluster"),
3396 &resume,
3397 );
3398 let admitted = matches!(verdict, IpGateVerdict::Admitted);
3399 let outcome =
3400 router.plan_connect_resume(stream_id, &mut context, &view, resume, verdict);
3401 (admitted, outcome)
3402 };
3403
3404 let address = |host: u8| -> SocketAddr {
3405 format!("203.0.113.{host}:51000")
3406 .parse()
3407 .expect("test source address must parse")
3408 };
3409
3410 // Premise: three DISTINCT addresses inside one /24 fill the cap.
3411 for (index, host) in [1u8, 2, 3].into_iter().enumerate() {
3412 let (admitted, outcome) = run(Token(index + 1), address(host));
3413 assert!(
3414 admitted,
3415 "premise: 203.0.113.{host} is the {} of 3 allowed in the subnet and must be \
3416 admitted, or the refusal below proves nothing",
3417 index + 1
3418 );
3419 assert!(
3420 matches!(outcome, Ok(ConnectPlan::Dial { .. })),
3421 "an admitted stream must go on to request a dial, got {outcome:?}"
3422 );
3423 }
3424
3425 // Premise: no single address is anywhere near the per-IP cap, so
3426 // the per-IP counter cannot be what refuses the next one.
3427 {
3428 let manager = sessions.borrow();
3429 for host in [1u8, 2, 3] {
3430 let ip = address(host).ip();
3431 assert!(
3432 !manager.cluster_ip_at_limit(Token(99), H2_CLUSTER, &ip, None),
3433 "premise: 203.0.113.{host} holds 1 of 10 per-IP slots and must be under \
3434 its own cap — otherwise the refusal below is the per-IP cap, not the subnet"
3435 );
3436 }
3437 }
3438
3439 // A FOURTH, never-seen address in the same /24 is refused.
3440 let (admitted, outcome) = run(Token(4), address(4));
3441 assert!(
3442 !admitted,
3443 "a fresh address from an already-counted /24 must be refused — an Admitted \
3444 verdict here is the #1270 bypass: a new address per connection defeats the cap"
3445 );
3446 assert!(
3447 matches!(
3448 outcome,
3449 Err(BackendConnectionError::TooManyConnectionsPerIp { .. })
3450 ),
3451 "the refused stream must surface the too-many-connections error, got {outcome:?}"
3452 );
3453
3454 // Negative space: the same fourth address in a DIFFERENT /24 is
3455 // admitted, so the refusal above is the subnet key and not a
3456 // blanket cap on new addresses.
3457 let (admitted, _) = run(
3458 Token(5),
3459 "198.51.100.4:51000"
3460 .parse()
3461 .expect("test source address must parse"),
3462 );
3463 assert!(
3464 admitted,
3465 "an address in an UNCOUNTED subnet must still be admitted — otherwise the cap \
3466 is refusing on something other than the subnet key"
3467 );
3468 }
3469
3470 /// The default must change nothing. With `max_connections_per_subnet`
3471 /// left at its `0` default, the admission path must behave exactly as
3472 /// it did before this feature existed — and, because the subnet
3473 /// tracking is skipped outright rather than merely ignored, it must
3474 /// also leave no bookkeeping behind.
3475 ///
3476 /// The same four addresses in one /24 that the test above refuses are
3477 /// all admitted here, and the per-IP cap still fires on its own terms
3478 /// when a single address exceeds it.
3479 #[test]
3480 fn an_unset_subnet_cap_leaves_the_admission_path_untouched() {
3481 let fixture = routing_fixture();
3482 let sessions = fixture.proxy.borrow().sessions();
3483 {
3484 let mut manager = sessions.borrow_mut();
3485 // A /24 prefix is configured but the cap is OFF: the prefix
3486 // alone must not gate anything.
3487 manager.subnet_ipv4_prefix = 24;
3488 manager.max_connections_per_subnet = 0;
3489 manager.max_connections_per_ip = 1;
3490 }
3491
3492 let mut clusters = HashMap::new();
3493 clusters.insert(
3494 H2_CLUSTER.into(),
3495 Cluster {
3496 cluster_id: H2_CLUSTER.to_owned(),
3497 ..Default::default()
3498 },
3499 );
3500 let view = RoutingView::new(&clusters, ListenerType::Http);
3501
3502 let run = |token: Token, source: SocketAddr| {
3503 let mut context = Context::new(
3504 Ulid::generate(),
3505 Rc::downgrade(&fixture.pool),
3506 fixture.listener.clone(),
3507 Some(source),
3508 "127.0.0.1:80"
3509 .parse()
3510 .expect("test public address must parse"),
3511 );
3512 let stream_id = context
3513 .create_stream(Ulid::generate(), 65_535)
3514 .expect("the test pool must hand out a stream");
3515 {
3516 let stream = &mut context.streams[stream_id];
3517 stream.state = StreamState::Link;
3518 stream.context.authority = Some(H2_AUTHORITY.to_owned());
3519 stream.context.path = Some("/".to_owned());
3520 stream.context.method = Some(Method::Get);
3521 }
3522 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3523 let step = router
3524 .plan_connect(stream_id, &mut context, &view)
3525 .expect("routing must resolve the staged cluster");
3526 let ConnectStep::CheckIpLimit(resume) = step else {
3527 panic!("a stream WITH a session address must reach the connection gate")
3528 };
3529 let verdict = super::super::consult_ip_gate(
3530 &sessions,
3531 token,
3532 context
3533 .http_context(stream_id)
3534 .cluster_id
3535 .as_ref()
3536 .expect("plan_connect stamps the routed cluster"),
3537 &resume,
3538 );
3539 matches!(verdict, IpGateVerdict::Admitted)
3540 };
3541
3542 // Four distinct addresses in one /24 — the exact traffic the test
3543 // above refuses — are all admitted while the cap is off.
3544 for (index, host) in [1u8, 2, 3, 4].into_iter().enumerate() {
3545 let source: SocketAddr = format!("203.0.113.{host}:51000")
3546 .parse()
3547 .expect("test source address must parse");
3548 assert!(
3549 run(Token(index + 1), source),
3550 "with max_connections_per_subnet = 0, 203.0.113.{host} must be admitted — \
3551 the default must not gate traffic the previous release allowed"
3552 );
3553 }
3554
3555 // The subnet accounting must be genuinely untouched, not merely
3556 // consulted-and-ignored: a disabled limiter allocates nothing.
3557 assert!(
3558 sessions.borrow().subnet_tracking_is_empty(),
3559 "a disabled subnet limiter must leave both subnet maps empty — a populated one \
3560 means every deployment that never enables this feature pays for it anyway"
3561 );
3562
3563 // And the pre-existing per-IP cap still fires on its own terms:
3564 // a SECOND token from an address already counted once is refused
3565 // at a per-IP limit of 1.
3566 let repeat: SocketAddr = "203.0.113.1:51000"
3567 .parse()
3568 .expect("test source address must parse");
3569 assert!(
3570 !run(Token(90), repeat),
3571 "the per-IP cap must still refuse a second connection from the same address — \
3572 this feature must not have relaxed the limiter that already existed"
3573 );
3574 }
3575
3576 /// The routing decision reads cluster configuration from the view it was
3577 /// handed, not from the `L7Proxy` handle it still carries.
3578 ///
3579 /// Question 6 of [#1340](https://github.com/sozu-proxy/sozu/issues/1340)
3580 /// puts routing inside the core, and the only thing that makes that worth
3581 /// anything is that the data routing reads is supplied rather than
3582 /// fetched — otherwise a simulator still cannot drive the decision. This
3583 /// hands `Router::plan_connect` a view that DISAGREES with the proxy
3584 /// about the one cluster knob the plan surfaces, and asserts the plan
3585 /// follows the view.
3586 ///
3587 /// The fixture registers `H2_CLUSTER` with `http2: Some(true)`. The view
3588 /// below says `Some(false)` for the same cluster id. A `Dial { h2: true }`
3589 /// would mean the decision went back to `L7Proxy::clusters` behind the
3590 /// view's back.
3591 ///
3592 /// To SEE THIS RED: restore either cluster read to the handle — in
3593 /// `Router::plan_connect` write `proxy.borrow().clusters().get(&cluster_id)`
3594 /// in place of `view.cluster(&cluster_id)` — and the assertion below
3595 /// reports `h2: true`.
3596 #[test]
3597 fn the_routing_decision_reads_cluster_config_from_the_view() {
3598 let fixture = routing_fixture();
3599 let pool = &fixture.pool;
3600 let mut context = Context::new(
3601 Ulid::generate(),
3602 Rc::downgrade(pool),
3603 fixture.listener.clone(),
3604 // No session address: the per-(cluster, source-IP) gate — the one
3605 // read still taken through the handle — is skipped, so this test
3606 // isolates the reads that moved.
3607 None,
3608 "127.0.0.1:80"
3609 .parse()
3610 .expect("test public address must parse"),
3611 );
3612 let stream_id = context
3613 .create_stream(Ulid::generate(), 65_535)
3614 .expect("the test pool must hand out a stream");
3615 {
3616 let stream = &mut context.streams[stream_id];
3617 stream.state = StreamState::Link;
3618 stream.context.authority = Some(H2_AUTHORITY.to_owned());
3619 stream.context.path = Some("/".to_owned());
3620 stream.context.method = Some(Method::Get);
3621 }
3622
3623 // A cluster map that is NOT the proxy's, disagreeing on `http2`.
3624 let mut clusters = HashMap::new();
3625 clusters.insert(
3626 H2_CLUSTER.into(),
3627 Cluster {
3628 cluster_id: H2_CLUSTER.to_owned(),
3629 http2: Some(false),
3630 ..Default::default()
3631 },
3632 );
3633 assert_eq!(
3634 fixture
3635 .proxy
3636 .borrow()
3637 .clusters()
3638 .get(H2_CLUSTER)
3639 .and_then(|c| c.http2),
3640 Some(true),
3641 "premise: the proxy and the view must disagree, or this proves nothing"
3642 );
3643 let view = RoutingView::new(&clusters, ListenerType::Http);
3644
3645 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3646 let plan = router
3647 .plan_connect(stream_id, &mut context, &view)
3648 .map(decided)
3649 .expect("routing must resolve the staged cluster");
3650
3651 match plan {
3652 ConnectPlan::Dial {
3653 ref cluster_id, h2, ..
3654 } => {
3655 assert_eq!(
3656 &**cluster_id, H2_CLUSTER,
3657 "premise: routing resolved a cluster"
3658 );
3659 assert!(
3660 !h2,
3661 "the plan must carry the view's `http2`, not the proxy's — \
3662 `h2: true` means the decision read `L7Proxy::clusters` behind the view"
3663 );
3664 }
3665 ConnectPlan::Attached => {
3666 panic!("premise: an empty router has nothing to reuse, so it must ask for a dial")
3667 }
3668 }
3669 }
3670
3671 /// A plan is a decision, not a deed: `ConnectPlan::Dial` must leave the
3672 /// router and the stream exactly as it found them.
3673 ///
3674 /// This is the property Question 6 of
3675 /// [#1340](https://github.com/sozu-proxy/sozu/issues/1340) is actually
3676 /// about — the core decides and the embedder performs — and it is the one
3677 /// a later edit is most likely to erode, by "just" doing one small effect
3678 /// inline because the data is right there. Asserting on the return value
3679 /// alone would not catch that: a `Dial` that had already dialled is still
3680 /// a `Dial`. So this asserts on the state the router and the stream are
3681 /// left in.
3682 ///
3683 /// To SEE THIS RED: perform any effect in `Router::plan_connect`'s
3684 /// new-backend path before it returns — inserting into `self.backends`,
3685 /// or calling `context.link_stream(stream_id, token)` — and the
3686 /// corresponding assertion below fires.
3687 ///
3688 /// SCOPE, because the name would otherwise over-read.
3689 /// `Router::plan_connect` is NOT pure yet. It still performs one registry
3690 /// mutation: `SessionManager::track_cluster_ip`, reached through
3691 /// `L7Proxy::sessions` in the per-(cluster, source-IP) limit gate. That is
3692 /// the mutation sitting *between* cluster resolution and backend
3693 /// selection that #1340 recorded as Question 6's first wrinkle, and it is
3694 /// deliberately unchanged here — it moves when the borrowed view lands,
3695 /// as either a second returned step or part of that view.
3696 ///
3697 /// This `Context` is therefore built with `session_address: None`, which
3698 /// skips the gate entirely, and the assertions below cover the router's
3699 /// backend map, the stream's link state, the backend-stream reverse index
3700 /// and the accounting ledger — NOT the session manager. Read the
3701 /// assertion list rather than the test name.
3702 #[test]
3703 fn a_dial_plan_performs_no_effect_of_its_own() {
3704 let fixture = routing_fixture();
3705 let pool = &fixture.pool;
3706 let mut context = Context::new(
3707 Ulid::generate(),
3708 Rc::downgrade(pool),
3709 fixture.listener.clone(),
3710 // No session address, so the per-(cluster, source-IP) gate is
3711 // skipped and this never reaches the proxy's session manager.
3712 None,
3713 "127.0.0.1:80"
3714 .parse()
3715 .expect("test public address must parse"),
3716 );
3717 let stream_id = context
3718 .create_stream(Ulid::generate(), 65_535)
3719 .expect("the test pool must hand out a stream");
3720 {
3721 let stream = &mut context.streams[stream_id];
3722 stream.state = StreamState::Link;
3723 stream.context.authority = Some(H2_AUTHORITY.to_owned());
3724 stream.context.path = Some("/".to_owned());
3725 stream.context.method = Some(Method::Get);
3726 }
3727
3728 // An EMPTY router: nothing to reuse, so routing must fall through to
3729 // the new-backend path and ask the embedder for a dial.
3730 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3731 let proxy_ref = fixture.proxy.borrow();
3732 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
3733 let plan = router
3734 .plan_connect(stream_id, &mut context, &view)
3735 .map(decided)
3736 .expect("routing must resolve the staged cluster");
3737
3738 match &plan {
3739 ConnectPlan::Dial { cluster_id, .. } => assert_eq!(
3740 &**cluster_id, H2_CLUSTER,
3741 "the dial request must name the cluster routing resolved"
3742 ),
3743 ConnectPlan::Attached => {
3744 panic!("premise: an empty router has nothing to reuse, so it must ask for a dial")
3745 }
3746 }
3747
3748 assert!(
3749 router.backends.is_empty(),
3750 "a plan must not have taken a backend connection into the router: {:?}",
3751 router.backends.keys().collect::<Vec<_>>()
3752 );
3753 assert_eq!(
3754 context.streams[stream_id].state,
3755 StreamState::Link,
3756 "a plan must not have linked the stream — the embedder has not dialled yet"
3757 );
3758 assert!(
3759 context.backend_streams.is_empty(),
3760 "a plan must not have touched the backend-stream reverse index"
3761 );
3762 assert!(
3763 context.backend_deltas.is_empty(),
3764 "a plan must not have charged any backend accounting: {:?}",
3765 context.backend_deltas
3766 );
3767 }
3768
3769 /// The leak with the most user-visible consequence: the H2 least-loaded
3770 /// arm compares stream counts with a strict `<`, so every staged backend
3771 /// sitting at the same count leaves the choice entirely to iteration
3772 /// order — hash order decided which machine served the request.
3773 #[test]
3774 fn an_h2_stream_count_tie_always_picks_the_lowest_backend_token() {
3775 let fixture = routing_fixture();
3776 for round in 0..ROUNDS {
3777 let chosen = selected_backend_token(&fixture, &Staged::ConnectedH2);
3778 assert_eq!(
3779 chosen,
3780 Token(LOWEST_TOKEN),
3781 "round {round}: a stream-count tie must resolve to the lowest token, \
3782 not to whatever the map iterated first"
3783 );
3784 }
3785 }
3786
3787 /// The same scan's fallback arm, and it does NOT bias the same way:
3788 /// it assigns with neither a `break` nor an "already chosen" guard, so
3789 /// the LAST matching connecting backend wins. The total order pins that
3790 /// to the highest token — asserting the lowest here would be red against
3791 /// both containers.
3792 #[test]
3793 fn an_all_connecting_h2_fallback_always_picks_the_highest_backend_token() {
3794 let fixture = routing_fixture();
3795 for round in 0..ROUNDS {
3796 let chosen = selected_backend_token(&fixture, &Staged::ConnectingH2);
3797 assert_eq!(
3798 chosen,
3799 Token(HIGHEST_TOKEN),
3800 "round {round}: the connecting-backend fallback assigns last-wins, \
3801 so the total order must land on the highest token"
3802 );
3803 }
3804 }
3805
3806 /// The H1 keep-alive arm carries the same first-seen bias as the H2
3807 /// least-loaded one — `reuse_token = Some(*token); break;` — and it is
3808 /// live wherever an H2 frontend fans several concurrent streams onto an
3809 /// H1 cluster: each stream dials its own socket, every socket parks
3810 /// `KeepAlive` for the same cluster, and the next request picks among
3811 /// them by map order.
3812 #[test]
3813 fn an_h1_keep_alive_reuse_always_picks_the_lowest_backend_token() {
3814 let fixture = routing_fixture();
3815 for round in 0..ROUNDS {
3816 let chosen = selected_backend_token(&fixture, &Staged::KeepAliveH1);
3817 assert_eq!(
3818 chosen,
3819 Token(LOWEST_TOKEN),
3820 "round {round}: the first matching keep-alive socket wins, so the total \
3821 order must land on the lowest token"
3822 );
3823 }
3824 }
3825
3826 /// #1579: the routing decision for a request that reuses a pooled
3827 /// backend connection allocates nothing of its own, in steady state.
3828 ///
3829 /// The reuse branch of `Router::decide_after_gate` stamps the stream's
3830 /// `HttpContext` and `SessionMetrics` with the id the connection's
3831 /// `BackendId` carries. That id is an `Rc<str>`, so the stamp is a
3832 /// reference-count increment; a `String` copy there costs one heap
3833 /// allocation per field and per request.
3834 ///
3835 /// Measured against a control that performs the same mux bookkeeping the
3836 /// branch triggers — `Connection::start_stream` then
3837 /// `Context::link_stream` on the same keep-alive socket — and nothing
3838 /// else. Each request is released the way production releases it
3839 /// (`Context::unlink_stream`, then the ledger drained through
3840 /// `BackendRegistry::apply_all` as a Mux pass drains it), so the control
3841 /// carries exactly the bookkeeping's costs and the difference is what the
3842 /// decision adds.
3843 ///
3844 /// Both are also held at zero outright (#1583): the reverse-index entry
3845 /// keeps its emptied `Vec` for the next `link_stream`, and the ledger is
3846 /// drained in place, so the bookkeeping itself allocates nothing either.
3847 ///
3848 /// TO SEE THIS RED: stamp either field with
3849 /// `Some(backend.backend_id.to_string().into())` instead of the `Rc`
3850 /// clone; the difference is then two allocations per request per field
3851 /// (the `String`, then the `Rc<str>` built from it). For the absolute
3852 /// half, make `remove_backend_stream` drop the emptied entry again, or
3853 /// make `BackendRegistry::apply_all` iterate `mem::take(deltas)`: each
3854 /// costs one allocation per request in both measurements.
3855 #[test]
3856 fn a_request_on_a_reused_backend_connection_allocates_nothing() {
3857 use std::hint::black_box;
3858
3859 use crate::test_allocations::allocations;
3860
3861 const REQUESTS: usize = 64;
3862 let backend_token = Token(LOWEST_TOKEN);
3863
3864 let fixture = routing_fixture();
3865 let mut context = Context::new(
3866 Ulid::generate(),
3867 Rc::downgrade(&fixture.pool),
3868 fixture.listener.clone(),
3869 None,
3870 "127.0.0.1:80"
3871 .parse()
3872 .expect("test public address must parse"),
3873 );
3874 let stream_id = context
3875 .create_stream(Ulid::generate(), 65_535)
3876 .expect("the test pool must hand out a stream");
3877 context.streams[stream_id].context.method = Some(Method::Get);
3878 // Where `Router::plan_connect` leaves the routed cluster for the
3879 // decision to read.
3880 context.streams[stream_id].context.cluster_id = Some(H1_CLUSTER.into());
3881
3882 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3883 let mut backend_registry = BackendRegistry::default();
3884 let (connection, _peer) =
3885 staged_backend(&fixture.pool, &Staged::KeepAliveH1, &mut backend_registry);
3886 router.backends.insert(backend_token, connection);
3887
3888 // Released as `ConnectionH1::end_stream` releases a terminated
3889 // response on a keep-alive backend, then the ledger drained as the
3890 // next Mux pass drains it.
3891 let backend_registry = &backend_registry;
3892 let release = |router: &mut Router, context: &mut Context<HttpListener>| {
3893 assert_eq!(
3894 context.streams[stream_id].state,
3895 StreamState::Linked(backend_token)
3896 );
3897 context.unlink_stream(stream_id);
3898 backend_registry.apply_all(&mut context.backend_deltas, &fixture.backends);
3899 context.streams[stream_id].forget_upstream_replay();
3900 context.streams[stream_id].state = StreamState::Link;
3901 let Some(Connection::H1(h1)) = router.backends.get_mut(&backend_token) else {
3902 unreachable!("the staged backend is an H1 connection")
3903 };
3904 h1.stream = None;
3905 if let Position::Client(_, _, status) = &mut h1.position {
3906 *status = BackendStatus::KeepAlive;
3907 }
3908 };
3909 // The worker's retired-backend check rides the decision, so it is
3910 // measured with it.
3911 let clusters = HashMap::new();
3912 let retired = |backend: &super::BackendId| backend_registry.is_retired(backend);
3913 let view = RoutingView::new(&clusters, ListenerType::Http).with_backend_retired(&retired);
3914 // One request through the routing decision.
3915 let decision = |router: &mut Router, context: &mut Context<HttpListener>| -> usize {
3916 let before = allocations();
3917 let plan =
3918 router.decide_after_gate(stream_id, black_box(&mut *context), &view, false, false);
3919 let allocated = allocations() - before;
3920 assert!(
3921 matches!(plan, Ok(ConnectPlan::Attached)),
3922 "the keep-alive socket must be reused, got {plan:?}"
3923 );
3924 release(router, context);
3925 allocated
3926 };
3927 // The same socket attached by hand: only the mux bookkeeping.
3928 let control = |router: &mut Router, context: &mut Context<HttpListener>| -> usize {
3929 let before = allocations();
3930 let started = router
3931 .backends
3932 .get_mut(&backend_token)
3933 .expect("the staged backend is registered")
3934 .start_stream(stream_id, black_box(&mut *context));
3935 context.link_stream(stream_id, backend_token);
3936 let allocated = allocations() - before;
3937 assert!(started, "the keep-alive socket must accept the stream");
3938 release(router, context);
3939 allocated
3940 };
3941
3942 // Warm-up: metric keys and tables are sized by the first request.
3943 decision(&mut router, &mut context);
3944 control(&mut router, &mut context);
3945 let (mut decided, mut controlled) = (0, 0);
3946 for _ in 0..REQUESTS {
3947 decided += decision(&mut router, &mut context);
3948 controlled += control(&mut router, &mut context);
3949 }
3950
3951 decision(&mut router, &mut context);
3952 let stream = &context.streams[stream_id];
3953 assert_eq!(stream.context.backend_id.as_deref(), Some("test-backend"));
3954 assert_eq!(stream.metrics.backend_id.as_deref(), Some("test-backend"));
3955 assert_eq!(
3956 decided.saturating_sub(controlled),
3957 0,
3958 "{REQUESTS} requests on a reused keep-alive backend made {decided} \
3959 heap allocations in the routing decision against {controlled} for \
3960 the mux bookkeeping alone, expected no difference"
3961 );
3962 assert_eq!(
3963 (decided, controlled),
3964 (0, 0),
3965 "{REQUESTS} requests on a reused keep-alive backend must allocate \
3966 nothing in the decision nor in the mux bookkeeping it triggers"
3967 );
3968 }
3969
3970 /// #1583: the whole reuse branch copies no cluster id, gated or not.
3971 ///
3972 /// `ClusterId` is a reference-counted `Arc<str>` in sozu-command-lib:
3973 /// `route_from_request` hands `Router::plan_connect` a handle on the
3974 /// route table's own id, `plan_connect` moves it into the stream's
3975 /// `HttpContext`, and every later reader borrows it from there or bumps
3976 /// its count — the per-(cluster, source-IP) gate's `SessionManager`
3977 /// bookkeeping included, whose maps are keyed by the same handle. With
3978 /// the reverse-index entry and the delta ledger keeping their capacity,
3979 /// the branch allocates nothing at all past the debug build's history.
3980 ///
3981 /// Measured end to end — `plan_connect`, then on the gated path
3982 /// `consult_ip_gate` with its `SessionManager::track_cluster_connection`
3983 /// and `plan_connect_resume`, then the production release — against a
3984 /// control made of the one cost this branch does not own and which runs
3985 /// inside `plan_connect`: the debug-build `DebugEvent::Str` history push,
3986 /// absent from a release build, where the control is zero. The rest of
3987 /// `route_from_request` is measured, not controlled: it copies no
3988 /// authority and the route lookup records its trie segments on the stack
3989 /// (#1589). Both paths run, because production traffic always carries a
3990 /// source address and so always takes the gated one, while the fixtures
3991 /// above never do.
3992 ///
3993 /// TO SEE THIS RED: stamp `stream_context.cluster_id` in `plan_connect`
3994 /// with a copy (`Some(ClusterId::from(&*cluster_id))`) instead of moving
3995 /// the routed handle in: one allocation per request, on both paths. Hand
3996 /// `SessionManager::track_cluster_connection` such a copy in
3997 /// `consult_ip_gate` and the gated path alone pays one more. The same
3998 /// count comes back if `route_from_request` stamps
3999 /// `HttpContext::original_authority` with a copy of the authority on a
4000 /// request without a host rewrite, or if `Router::lookup` hands the trie
4001 /// a `Vec::with_capacity(16)` again.
4002 #[test]
4003 fn a_routed_request_on_a_reused_backend_connection_copies_no_cluster_id() {
4004 use std::hint::black_box;
4005
4006 use crate::test_allocations::allocations;
4007
4008 const REQUESTS: usize = 64;
4009 let backend_token = Token(LOWEST_TOKEN);
4010 let fixture = routing_fixture();
4011 let sessions = fixture.proxy.borrow().sessions();
4012
4013 for gated in [false, true] {
4014 let mut context = Context::new(
4015 Ulid::generate(),
4016 Rc::downgrade(&fixture.pool),
4017 fixture.listener.clone(),
4018 gated.then(|| {
4019 "127.0.0.1:4242"
4020 .parse()
4021 .expect("test session address must parse")
4022 }),
4023 "127.0.0.1:80"
4024 .parse()
4025 .expect("test public address must parse"),
4026 );
4027 let stream_id = context
4028 .create_stream(Ulid::generate(), 65_535)
4029 .expect("the test pool must hand out a stream");
4030 {
4031 let stream = &mut context.streams[stream_id];
4032 stream.state = StreamState::Link;
4033 stream.context.authority = Some(H1_AUTHORITY.to_owned());
4034 stream.context.path = Some("/".to_owned());
4035 stream.context.method = Some(Method::Get);
4036 }
4037 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
4038 let mut backend_registry = BackendRegistry::default();
4039 let (connection, _peer) =
4040 staged_backend(&fixture.pool, &Staged::KeepAliveH1, &mut backend_registry);
4041 router.backends.insert(backend_token, connection);
4042 let proxy_ref = fixture.proxy.borrow();
4043 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
4044
4045 // One request through the whole branch, the gate's bookkeeping
4046 // included, then released as production releases it.
4047 let branch = |router: &mut Router, context: &mut Context<HttpListener>| -> usize {
4048 let before = allocations();
4049 let step = router
4050 .plan_connect(stream_id, black_box(&mut *context), &view)
4051 .expect("the reused request must be planned");
4052 let mut allocated = allocations() - before;
4053 let plan = match step {
4054 ConnectStep::Decided(plan) => {
4055 assert!(!gated, "a stream with a source address must be gated");
4056 plan
4057 }
4058 ConnectStep::CheckIpLimit(resume) => {
4059 assert!(gated, "a stream without a source address must not be gated");
4060 let before = allocations();
4061 let verdict = super::super::consult_ip_gate(
4062 &sessions,
4063 Token(0),
4064 context
4065 .http_context(stream_id)
4066 .cluster_id
4067 .as_ref()
4068 .expect("plan_connect stamps the routed cluster"),
4069 &resume,
4070 );
4071 let plan = router.plan_connect_resume(
4072 stream_id,
4073 &mut *context,
4074 &view,
4075 resume,
4076 verdict,
4077 );
4078 allocated += allocations() - before;
4079 plan.expect("the gate is unlimited, so the stream is admitted")
4080 }
4081 };
4082 assert!(
4083 matches!(plan, ConnectPlan::Attached),
4084 "the keep-alive socket must be reused, got {plan:?}"
4085 );
4086 let before = allocations();
4087 context.unlink_stream(stream_id);
4088 backend_registry.apply_all(&mut context.backend_deltas, &fixture.backends);
4089 allocated += allocations() - before;
4090 let stream = &mut context.streams[stream_id];
4091 stream.forget_upstream_replay();
4092 stream.state = StreamState::Link;
4093 stream.attempts = 0;
4094 let Some(Connection::H1(h1)) = router.backends.get_mut(&backend_token) else {
4095 unreachable!("the staged backend is an H1 connection")
4096 };
4097 h1.stream = None;
4098 if let Position::Client(_, _, status) = &mut h1.position {
4099 *status = BackendStatus::KeepAlive;
4100 }
4101 allocated
4102 };
4103 // The one cost `plan_connect` carries that is not this branch's:
4104 // the debug history push. Routing hands over a handle on the
4105 // route table's cluster id, so nothing else may allocate.
4106 let control = |context: &mut Context<HttpListener>| -> usize {
4107 let before = allocations();
4108 #[cfg(debug_assertions)]
4109 {
4110 use crate::protocol::mux::DebugEvent;
4111
4112 let route = context.streams[stream_id].context.get_route();
4113 context.debug.push(DebugEvent::Str(route));
4114 }
4115 #[cfg(not(debug_assertions))]
4116 let _ = &context;
4117 allocations() - before
4118 };
4119
4120 // Warm-up: metric keys, the reverse index and the ledger are
4121 // sized by the first request.
4122 branch(&mut router, &mut context);
4123 control(&mut context);
4124 let (mut branched, mut controlled) = (0, 0);
4125 for _ in 0..REQUESTS {
4126 branched += branch(&mut router, &mut context);
4127 controlled += control(&mut context);
4128 }
4129
4130 assert_eq!(
4131 context.http_context(stream_id).cluster_id.as_deref(),
4132 Some(H1_CLUSTER)
4133 );
4134 assert_eq!(
4135 branched, controlled,
4136 "gated={gated}: {REQUESTS} requests on a reused keep-alive backend \
4137 made {branched} heap allocations through the reuse branch against \
4138 {controlled} for the debug history, expected no difference"
4139 );
4140 }
4141 }
4142
4143 /// #1583: a dial copies no cluster id: the new connection shares the
4144 /// routed one.
4145 ///
4146 /// `ClusterId` is a reference-counted `Arc<str>` in sozu-command-lib, so
4147 /// the route table, the stream's `HttpContext::cluster_id`, the
4148 /// `ConnectPlan::Dial` and the new connection's `Position::Client` all
4149 /// point at the one allocation the route table made when the frontend was
4150 /// added. `Mux::attach_dialed` moves the plan's handle into the connection.
4151 ///
4152 /// TO SEE THIS RED: build the connection in `Mux::attach_dialed` from
4153 /// `ClusterId::from(&*cluster_id)`, or stamp the plan in
4154 /// `Router::decide_after_gate` with one; the connection then owns a
4155 /// second copy at another address.
4156 #[test]
4157 fn a_dial_moves_the_planned_cluster_id_into_the_backend_connection() {
4158 use crate::{Protocol, ProxySession, protocol::mux::Mux, server::ListenSession};
4159
4160 let fixture = routing_fixture();
4161 let upstream =
4162 std::net::TcpListener::bind("127.0.0.1:0").expect("a loopback listener must bind");
4163 fixture.backends.borrow_mut().add_backend(
4164 H1_CLUSTER,
4165 Backend::new(
4166 "dial-backend",
4167 upstream
4168 .local_addr()
4169 .expect("a bound listener has an address"),
4170 None,
4171 None,
4172 None,
4173 ),
4174 );
4175 let mut context = Context::new(
4176 Ulid::generate(),
4177 Rc::downgrade(&fixture.pool),
4178 fixture.listener.clone(),
4179 None,
4180 "127.0.0.1:80"
4181 .parse()
4182 .expect("test public address must parse"),
4183 );
4184 let stream_id = context
4185 .create_stream(Ulid::generate(), 65_535)
4186 .expect("the test pool must hand out a stream");
4187 {
4188 let stream = &mut context.streams[stream_id];
4189 stream.state = StreamState::Link;
4190 stream.context.authority = Some(H1_AUTHORITY.to_owned());
4191 stream.context.path = Some("/".to_owned());
4192 stream.context.method = Some(Method::Get);
4193 }
4194 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
4195 let mut backend_registry = BackendRegistry::default();
4196
4197 let plan = {
4198 let proxy_ref = fixture.proxy.borrow();
4199 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
4200 router
4201 .plan_connect(stream_id, &mut context, &view)
4202 .map(decided)
4203 .expect("an empty router must ask for a dial")
4204 };
4205 let ConnectPlan::Dial {
4206 cluster_id,
4207 h2,
4208 frontend_should_stick,
4209 } = plan
4210 else {
4211 panic!("premise: an empty router has nothing to reuse, so it must ask for a dial")
4212 };
4213 let planned = cluster_id.as_ptr();
4214
4215 let session: Rc<RefCell<dyn ProxySession>> = Rc::new(RefCell::new(ListenSession {
4216 protocol: Protocol::HTTPListen,
4217 }));
4218 Mux::<mio::net::TcpStream, HttpListener>::dial_backend(
4219 &mut router,
4220 &mut backend_registry,
4221 &fixture.backends,
4222 stream_id,
4223 &mut context,
4224 &session,
4225 &fixture.proxy,
4226 cluster_id,
4227 h2,
4228 frontend_should_stick,
4229 )
4230 .expect("the loopback backend must dial");
4231
4232 let token = match context.streams[stream_id].state {
4233 StreamState::Linked(token) => token,
4234 other => panic!("the dial must link the stream, got {other:?}"),
4235 };
4236 let Some(Position::Client(owned, _, _)) =
4237 router.backends.get(&token).map(Connection::position)
4238 else {
4239 panic!("the dialled connection must be a client")
4240 };
4241 assert_eq!(&**owned, H1_CLUSTER);
4242 assert_eq!(
4243 context.http_context(stream_id).cluster_id.as_deref(),
4244 Some(H1_CLUSTER)
4245 );
4246 assert!(
4247 std::ptr::eq(owned.as_ptr(), planned),
4248 "the connection must own the id the plan carried, not a copy of it"
4249 );
4250 let routed = context
4251 .http_context(stream_id)
4252 .cluster_id
4253 .as_ref()
4254 .expect("plan_connect stamps the routed cluster");
4255 assert!(
4256 std::ptr::eq(owned.as_ptr(), routed.as_ptr()),
4257 "the connection must share the stream's routed id, not a copy of it"
4258 );
4259 }
4260
4261 /// Dial one fresh stream of `H1_CLUSTER` through `Mux::dial_backend`
4262 /// against a single backend at `address`, then drain the ledger as the
4263 /// next pass would. Returns the dial's result and the backend.
4264 fn dial_one(
4265 address: std::net::SocketAddr,
4266 ) -> (Result<(), BackendConnectionError>, Rc<RefCell<Backend>>) {
4267 dial_with(address, DialFault::None)
4268 }
4269
4270 /// A local failure `dial_with` stages between the plan and the dial, each
4271 /// reaching one exit of `Mux::attach_dialed`, past a successful `connect(2)`.
4272 enum DialFault {
4273 /// No fault: the dial runs as it does in production.
4274 None,
4275 /// Every buffer of the pool is checked out, so
4276 /// `Connection::new_h2_client` returns `None`. Dials `H2_CLUSTER`.
4277 PoolExhausted,
4278 /// The request already holds output encoded for another connection,
4279 /// so `ConnectionH2::start_stream` refuses it (#1632). Dials
4280 /// `H2_CLUSTER`.
4281 StartRefused,
4282 /// `L7Proxy::register_socket` fails, as it does under fd pressure.
4283 /// Dials `H1_CLUSTER`.
4284 RegisterRefused,
4285 }
4286
4287 /// The fixture's proxy, except that registering a socket fails.
4288 ///
4289 /// Everything `Mux::dial_backend` calls besides `register_socket` goes to
4290 /// the real proxy, so the slab session it adds is removed from it again.
4291 struct RegisterRefuses(Rc<RefCell<dyn L7Proxy>>);
4292
4293 impl L7Proxy for RegisterRefuses {
4294 fn kind(&self) -> ListenerType {
4295 self.0.borrow().kind()
4296 }
4297 fn register_socket(
4298 &self,
4299 _socket: &mut mio::net::TcpStream,
4300 _token: Token,
4301 _interest: mio::Interest,
4302 ) -> Result<(), std::io::Error> {
4303 Err(std::io::Error::from_raw_os_error(24)) // EMFILE
4304 }
4305 fn add_session(&self, session: Rc<RefCell<dyn crate::ProxySession>>) -> Token {
4306 self.0.borrow().add_session(session)
4307 }
4308 fn remove_session(&self, token: Token) -> bool {
4309 self.0.borrow().remove_session(token)
4310 }
4311 fn clusters(&self) -> &HashMap<sozu_command::state::ClusterId, Cluster> {
4312 unreachable!("Mux::dial_backend never reads the cluster table")
4313 }
4314 fn sessions(&self) -> Rc<RefCell<crate::server::SessionManager>> {
4315 self.0.borrow().sessions()
4316 }
4317 }
4318
4319 /// [`dial_one`] with `fault` staged before the dial.
4320 fn dial_with(
4321 address: std::net::SocketAddr,
4322 fault: DialFault,
4323 ) -> (Result<(), BackendConnectionError>, Rc<RefCell<Backend>>) {
4324 use crate::{Protocol, ProxySession, protocol::mux::Mux, server::ListenSession};
4325
4326 let (cluster, authority) = match fault {
4327 DialFault::PoolExhausted | DialFault::StartRefused => (H2_CLUSTER, H2_AUTHORITY),
4328 DialFault::None | DialFault::RegisterRefused => (H1_CLUSTER, H1_AUTHORITY),
4329 };
4330 let fixture = routing_fixture();
4331 fixture.backends.borrow_mut().add_backend(
4332 cluster,
4333 Backend::new("dial-once", address, None, None, None),
4334 );
4335 let backend = fixture
4336 .backends
4337 .borrow_mut()
4338 .backends
4339 .get_mut(cluster)
4340 .and_then(|list| list.find_backend(&address).cloned())
4341 .expect("the backend was just added");
4342 let mut context = Context::new(
4343 Ulid::generate(),
4344 Rc::downgrade(&fixture.pool),
4345 fixture.listener.clone(),
4346 None,
4347 "127.0.0.1:80"
4348 .parse()
4349 .expect("test public address must parse"),
4350 );
4351 let stream_id = context
4352 .create_stream(Ulid::generate(), 65_535)
4353 .expect("the test pool must hand out a stream");
4354 {
4355 let stream = &mut context.streams[stream_id];
4356 stream.state = StreamState::Link;
4357 stream.context.authority = Some(authority.to_owned());
4358 stream.context.path = Some("/".to_owned());
4359 stream.context.method = Some(Method::Get);
4360 }
4361 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
4362 let mut backend_registry = BackendRegistry::default();
4363 let plan = {
4364 let proxy_ref = fixture.proxy.borrow();
4365 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
4366 router
4367 .plan_connect(stream_id, &mut context, &view)
4368 .map(decided)
4369 .expect("an empty router must ask for a dial")
4370 };
4371 let ConnectPlan::Dial {
4372 cluster_id,
4373 h2,
4374 frontend_should_stick,
4375 } = plan
4376 else {
4377 panic!("premise: an empty router has nothing to reuse, so it must ask for a dial")
4378 };
4379 assert_eq!(
4380 h2,
4381 cluster == H2_CLUSTER,
4382 "premise: the plan must dial the protocol the cluster declares"
4383 );
4384 // Held until the dial returns: dropping a checkout gives it back.
4385 let mut exhausted = Vec::new();
4386 let mut proxy = fixture.proxy.clone();
4387 match fault {
4388 DialFault::None => {}
4389 DialFault::PoolExhausted => {
4390 while let Some(buffer) = fixture.pool.borrow_mut().checkout() {
4391 exhausted.push(buffer);
4392 }
4393 }
4394 DialFault::StartRefused => {
4395 context.streams[stream_id]
4396 .front
4397 .out
4398 .push_back(kawa::OutBlock::Store(kawa::Store::Static(b"encoded")));
4399 }
4400 DialFault::RegisterRefused => {
4401 proxy = Rc::new(RefCell::new(RegisterRefuses(fixture.proxy.clone())));
4402 }
4403 }
4404 let session: Rc<RefCell<dyn ProxySession>> = Rc::new(RefCell::new(ListenSession {
4405 protocol: Protocol::HTTPListen,
4406 }));
4407 let dialed = Mux::<mio::net::TcpStream, HttpListener>::dial_backend(
4408 &mut router,
4409 &mut backend_registry,
4410 &fixture.backends,
4411 stream_id,
4412 &mut context,
4413 &session,
4414 &proxy,
4415 cluster_id,
4416 h2,
4417 frontend_should_stick,
4418 );
4419 drop(exhausted);
4420 assert!(
4421 dialed.is_ok() || router.backends.is_empty(),
4422 "a dial that failed must keep no backend connection, got {dialed:?}"
4423 );
4424 backend_registry.apply_all(&mut context.backend_deltas, &fixture.backends);
4425 (dialed, backend)
4426 }
4427
4428 /// #1684: a dial that connects counts its connection once. Selection
4429 /// reserved it, and the dial keeps that reservation as the connection's
4430 /// count rather than taking a second one.
4431 ///
4432 /// TO SEE THIS RED: call `inc_connections` on the dialled backend in
4433 /// `Mux::dial_backend` after the connect succeeds, as
4434 /// `Backend::try_connect` does for the TCP proxy; the backend then holds
4435 /// two connections for one dial.
4436 #[test]
4437 fn a_successful_dial_counts_its_connection_once() {
4438 let upstream =
4439 std::net::TcpListener::bind("127.0.0.1:0").expect("a loopback listener must bind");
4440 let (dialed, backend) = dial_one(
4441 upstream
4442 .local_addr()
4443 .expect("a bound listener has an address"),
4444 );
4445 dialed.expect("the loopback backend must dial");
4446 let backend = backend.borrow();
4447 assert_eq!(
4448 backend.active_connections, 1,
4449 "one dial must hold exactly one connection on its backend"
4450 );
4451 assert_eq!(backend.failures, 0, "a successful dial records no failure");
4452 }
4453
4454 /// #1684: a `connect(2)` that fails synchronously releases the reservation
4455 /// selection took and records the failure, once the ledger is drained.
4456 ///
4457 /// `255.255.255.255` is the limited broadcast address: connecting a TCP
4458 /// socket to it fails before any packet leaves (`ENETUNREACH`, or
4459 /// `EACCES` where a default route exists). The dial is asserted to fail,
4460 /// so a host where it somehow connected fails this test loudly instead of
4461 /// passing it vacuously.
4462 ///
4463 /// TO SEE THIS RED: return the error from `Mux::dial_backend` without
4464 /// pushing the `BackendChange::DialFailed` delta; the reservation then
4465 /// outlives the failed dial.
4466 #[test]
4467 fn a_synchronously_failed_dial_releases_its_reservation() {
4468 let (dialed, backend) = dial_one("255.255.255.255:80".parse().expect("a literal address"));
4469 assert!(
4470 matches!(
4471 dialed,
4472 Err(BackendConnectionError::Backend(
4473 crate::backends::BackendError::ConnectionFailures { failures: 1, .. }
4474 ))
4475 ),
4476 "a connect to the broadcast address must fail synchronously, got {dialed:?}"
4477 );
4478 let backend = backend.borrow();
4479 assert_eq!(
4480 backend.active_connections, 0,
4481 "the failed dial must release the reservation selection took"
4482 );
4483 assert_eq!(
4484 backend.failures, 1,
4485 "the failed dial must count one failure"
4486 );
4487 }
4488
4489 /// #1713: dial one stream with `fault` staged against a listening
4490 /// backend, and assert the dial failed with `expected` and left the
4491 /// backend exactly as it found it: the reservation selection took is
4492 /// released, and no failure is recorded, because the backend did not fail.
4493 fn assert_a_local_failure_releases_the_reservation(
4494 fault: DialFault,
4495 expected: fn(&BackendConnectionError) -> bool,
4496 ) {
4497 use crate::retry::{RetryAction, RetryPolicy};
4498
4499 let upstream =
4500 std::net::TcpListener::bind("127.0.0.1:0").expect("a loopback listener must bind");
4501 let (dialed, backend) = dial_with(
4502 upstream
4503 .local_addr()
4504 .expect("a bound listener has an address"),
4505 fault,
4506 );
4507 assert!(
4508 dialed.as_ref().is_err_and(expected),
4509 "premise: the staged fault must fail the dial at its exit, got {dialed:?}"
4510 );
4511 let backend = backend.borrow();
4512 assert_eq!(
4513 backend.active_connections, 0,
4514 "a dial abandoned after its connect must release the reservation \
4515 selection took"
4516 );
4517 assert_eq!(
4518 backend.failures, 0,
4519 "a local failure must not count against the backend"
4520 );
4521 assert_eq!(
4522 backend.retry_policy.can_try(std::time::Instant::now()),
4523 Some(RetryAction::OKAY),
4524 "a local failure must not arm the backend's backoff"
4525 );
4526 }
4527
4528 /// #1713: `Connection::new_h2_client` returning `None` (buffer pool
4529 /// exhausted) releases the reservation.
4530 ///
4531 /// TO SEE THIS RED: in `Mux::dial_backend`, return the error of
4532 /// `Mux::attach_dialed` without pushing the `BackendChange::ConnectionClosed`
4533 /// delta; the backend then keeps `active_connections == 1`.
4534 #[test]
4535 fn an_h2_client_the_pool_cannot_build_releases_its_reservation() {
4536 assert_a_local_failure_releases_the_reservation(DialFault::PoolExhausted, |error| {
4537 matches!(error, BackendConnectionError::MaxBuffers)
4538 });
4539 }
4540
4541 /// #1713: a refused `Connection::start_stream` releases the reservation.
4542 ///
4543 /// TO SEE THIS RED: as for
4544 /// [`an_h2_client_the_pool_cannot_build_releases_its_reservation`].
4545 #[test]
4546 fn a_refused_stream_start_releases_its_reservation() {
4547 assert_a_local_failure_releases_the_reservation(DialFault::StartRefused, |error| {
4548 matches!(error, BackendConnectionError::MaxSessionsMemory)
4549 });
4550 }
4551
4552 /// #1713: the `register_socket` rollback releases the reservation, not
4553 /// only the gauges, `active_requests` and the slab session.
4554 ///
4555 /// TO SEE THIS RED: as for
4556 /// [`an_h2_client_the_pool_cannot_build_releases_its_reservation`].
4557 #[test]
4558 fn a_socket_the_poller_refuses_releases_its_reservation() {
4559 assert_a_local_failure_releases_the_reservation(DialFault::RegisterRefused, |error| {
4560 matches!(error, BackendConnectionError::MaxSessionsMemory)
4561 });
4562 }
4563
4564 /// #1610: a session's first backend connection is stored without a heap
4565 /// allocation, and the connections still iterate in `Token` order however
4566 /// they were inserted and removed.
4567 ///
4568 /// `Router::backends` was a `BTreeMap`, whose first insertion allocates an
4569 /// 11-slot leaf of ~1.7 KiB connections, ~18.8 KiB, for the one backend a
4570 /// session almost always has.
4571 ///
4572 /// TO SEE THIS RED: in `BackendConnections::insert`, send the `None` arm
4573 /// to `self.rest.insert(token, connection)` instead of filling `first`
4574 /// (and let `remove` and `is_empty` read `rest` alone). The first
4575 /// insertion then makes one allocation.
4576 #[test]
4577 fn the_first_backend_connection_of_a_session_allocates_nothing() {
4578 use crate::test_allocations::allocations;
4579
4580 let fixture = routing_fixture();
4581 let mut backend_registry = BackendRegistry::default();
4582 let mut staged = |router: &mut Router, token: usize| {
4583 let (connection, peer) =
4584 staged_backend(&fixture.pool, &Staged::KeepAliveH1, &mut backend_registry);
4585 let before = allocations();
4586 let replaced = router.backends.insert(Token(token), connection);
4587 let allocated = allocations() - before;
4588 assert!(replaced.is_none(), "Token({token}) was not held yet");
4589 (allocated, peer)
4590 };
4591
4592 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
4593 let (first, _first_peer) = staged(&mut router, LOWEST_TOKEN + 2);
4594 assert_eq!(
4595 first, 0,
4596 "the first backend connection of a session must not allocate"
4597 );
4598
4599 // A lower token takes the inline slot, a higher one goes to the map.
4600 let (_, _lower_peer) = staged(&mut router, LOWEST_TOKEN);
4601 let (_, _higher_peer) = staged(&mut router, LOWEST_TOKEN + 5);
4602 let order = |router: &Router| router.backends.keys().map(|t| t.0).collect::<Vec<_>>();
4603 assert_eq!(
4604 order(&router),
4605 [LOWEST_TOKEN, LOWEST_TOKEN + 2, LOWEST_TOKEN + 5]
4606 );
4607 assert_eq!(router.backends.len(), 3);
4608
4609 // Removing the inline connection promotes the lowest remaining one.
4610 assert!(router.backends.remove(&Token(LOWEST_TOKEN)).is_some());
4611 assert_eq!(order(&router), [LOWEST_TOKEN + 2, LOWEST_TOKEN + 5]);
4612 assert!(router.backends.contains_key(&Token(LOWEST_TOKEN + 2)));
4613 assert!(!router.backends.contains_key(&Token(LOWEST_TOKEN)));
4614 assert!(router.backends.remove(&Token(LOWEST_TOKEN + 5)).is_some());
4615 assert!(router.backends.remove(&Token(LOWEST_TOKEN + 2)).is_some());
4616 assert!(router.backends.is_empty());
4617 }
4618}
4619
4620/// [`Router::backend_from_request`] against the embedder's real selector.
4621///
4622/// Question 12 of [#1340](https://github.com/sozu-proxy/sozu/issues/1340)
4623/// replaced the proxy handle this function took with a backend-set capability,
4624/// now a [`BackendSelector`] (#1684); it had no test before that change. Each
4625/// fixture owns its own `BackendMap` and `BackendRegistry`, so nothing here
4626/// depends on a proxy, a listener or a session — and, since selection no
4627/// longer dials, on any socket.
4628#[cfg(test)]
4629mod backend_selector_tests {
4630 use std::{
4631 cell::RefCell,
4632 net::SocketAddr,
4633 time::{Duration, Instant},
4634 };
4635
4636 use sozu_command::proto::command::{LoadBalancingAlgorithms, LoadMetric};
4637
4638 use super::Router;
4639 use crate::{
4640 backends::{Backend, BackendMap},
4641 protocol::{
4642 http::editor::HttpContext,
4643 mux::{BackendChange, BackendDelta, BackendId, BackendRegistry, RegistrySelector},
4644 },
4645 retry::{RetryAction, RetryPolicy},
4646 };
4647
4648 const CLUSTER: &str = "cluster-1340-q12";
4649 const STICKY_B: &str = "sticky-b";
4650
4651 /// Two backends under `policy` on connections. The addresses are never
4652 /// dialled: selection reserves, and only a caller of the selector connects.
4653 /// `LeastLoaded` is the default because round-robin would alternate
4654 /// whatever the counters said, and a test that passes without reading the
4655 /// load observes nothing.
4656 struct Fixture {
4657 backends: RefCell<BackendMap>,
4658 registry: BackendRegistry,
4659 router: Router,
4660 now: Instant,
4661 }
4662
4663 fn fixture_with(policy: LoadBalancingAlgorithms) -> Fixture {
4664 let now = Instant::now();
4665 let address = |port: u16| SocketAddr::from(([127, 0, 0, 1], port));
4666 let mut backends = BackendMap::with_seed(0x1684);
4667 backends.set_load_balancing_policy_for_cluster(
4668 CLUSTER,
4669 policy,
4670 Some(LoadMetric::Connections),
4671 );
4672 backends.add_backend(
4673 CLUSTER,
4674 Backend::new_at("backend-a", address(9501), None, None, None, now),
4675 );
4676 backends.add_backend(
4677 CLUSTER,
4678 Backend::new_at(
4679 "backend-b",
4680 address(9502),
4681 Some(STICKY_B.to_owned()),
4682 None,
4683 None,
4684 now,
4685 ),
4686 );
4687 Fixture {
4688 backends: RefCell::new(backends),
4689 registry: BackendRegistry::default(),
4690 router: Router::new(Duration::from_secs(10), Duration::from_secs(10)),
4691 now,
4692 }
4693 }
4694
4695 fn fixture() -> Fixture {
4696 fixture_with(LoadBalancingAlgorithms::LeastLoaded)
4697 }
4698
4699 fn request(cookie: Option<&str>) -> HttpContext {
4700 let mut context = HttpContext::new(
4701 rusty_ulid::Ulid::generate(),
4702 rusty_ulid::Ulid::generate(),
4703 crate::Protocol::HTTP,
4704 "127.0.0.1:80"
4705 .parse()
4706 .expect("test public address must parse"),
4707 None,
4708 "SOZUBALANCEID".to_owned(),
4709 "Sozu-Id".to_owned(),
4710 false,
4711 false,
4712 );
4713 context.sticky_session_found = cookie.map(ToOwned::to_owned);
4714 context
4715 }
4716
4717 impl Fixture {
4718 fn select(&mut self, should_stick: bool, context: &mut HttpContext) -> BackendId {
4719 let mut backends = self.backends.borrow_mut();
4720 let mut selector = RegistrySelector {
4721 backends: &mut backends,
4722 registry: &mut self.registry,
4723 now: self.now,
4724 exclude: &[],
4725 };
4726 let backend = self
4727 .router
4728 .backend_from_request(CLUSTER, should_stick, context, &mut selector)
4729 .expect("a cluster with two available backends must select");
4730 assert_eq!(
4731 context.backend_id.as_deref(),
4732 Some(&*backend.backend_id),
4733 "the request must be stamped with the backend it was reserved on"
4734 );
4735 backend
4736 }
4737
4738 fn backend(&self, id: &BackendId) -> std::cell::Ref<'_, Backend> {
4739 self.registry
4740 .handle(id)
4741 .expect("an id this fixture minted must resolve")
4742 .borrow()
4743 }
4744
4745 fn active_connections(&self, backend: &BackendId) -> usize {
4746 self.backend(backend).active_connections
4747 }
4748 }
4749
4750 /// Two streams linking in one pass must spread under `LeastLoaded`, and
4751 /// no dial happens in between.
4752 ///
4753 /// The second selection reads `active_connections`, which only the first
4754 /// selection's reservation can have raised: nothing here connects, and no
4755 /// delta drain runs. So this holds only if selecting reserves under the
4756 /// same borrow as the choice (#1684). A selector that stopped reserving —
4757 /// counting the connection at the dial instead — would send both streams
4758 /// to `backend-a`.
4759 ///
4760 /// TO SEE THIS RED: make `Backend::reserve_connection` return `Ok(())`
4761 /// without calling `inc_connections`.
4762 #[test]
4763 fn a_second_selection_observes_the_first_selections_reservation() {
4764 let mut fixture = fixture();
4765
4766 let first = fixture.select(false, &mut request(None));
4767 let second = fixture.select(false, &mut request(None));
4768
4769 assert_eq!(
4770 &*first.backend_id, "backend-a",
4771 "with both backends idle the first minimum wins"
4772 );
4773 assert_ne!(
4774 first.backend_id,
4775 second.backend_id,
4776 "the second selection landed on the first one's backend: the first \
4777 selection did not reserve its connection \
4778 (first={} active={}, second={} active={})",
4779 first.backend_id,
4780 fixture.active_connections(&first),
4781 second.backend_id,
4782 fixture.active_connections(&second),
4783 );
4784 assert_eq!(
4785 (
4786 fixture.active_connections(&first),
4787 fixture.active_connections(&second)
4788 ),
4789 (1, 1),
4790 "each selection must reserve exactly one connection on the backend it chose"
4791 );
4792 }
4793
4794 /// #1684: power-of-two-choices weighs every reservation too.
4795 ///
4796 /// With two backends the sample is the whole set, so each selection must
4797 /// take the one with fewer reserved connections, and a tie resolves by
4798 /// coin flip. Over any run of selections the two counts therefore never
4799 /// drift more than one apart, and together they count every selection.
4800 ///
4801 /// TO SEE THIS RED: make `Backend::reserve_connection` return `Ok(())`
4802 /// without calling `inc_connections`; nothing is reserved and the total
4803 /// stays at zero.
4804 #[test]
4805 fn power_of_two_weighs_every_reservation() {
4806 const SELECTIONS: usize = 64;
4807 let mut fixture = fixture_with(LoadBalancingAlgorithms::PowerOfTwo);
4808 let first = fixture.select(false, &mut request(None));
4809 let mut ids = vec![first];
4810 for selections in 2..=SELECTIONS {
4811 let before: Vec<usize> = ids
4812 .iter()
4813 .map(|id| fixture.active_connections(id))
4814 .collect();
4815 let picked = fixture.select(false, &mut request(None));
4816 if !ids.iter().any(|id| id.backend_id == picked.backend_id) {
4817 ids.push(picked.clone());
4818 }
4819 let loads: Vec<usize> = ids
4820 .iter()
4821 .map(|id| fixture.active_connections(id))
4822 .collect();
4823 assert_eq!(
4824 loads.iter().sum::<usize>(),
4825 selections,
4826 "{selections} selections must hold {selections} reservations, got {loads:?}"
4827 );
4828 if ids.len() == 2 {
4829 let (low, high) = (loads[0].min(loads[1]), loads[0].max(loads[1]));
4830 assert!(
4831 high - low <= 1,
4832 "power-of-two let the reservations drift apart: {loads:?} (before {before:?})"
4833 );
4834 }
4835 }
4836 assert_eq!(ids.len(), 2, "both backends must be used");
4837 }
4838
4839 /// #1684: a dial whose `connect(2)` fails releases its reservation and
4840 /// records the failure, once the ledger is drained.
4841 ///
4842 /// TO SEE THIS RED: drop `self.dec_connections()` from
4843 /// `Backend::release_failed_dial`; the reservation then outlives the
4844 /// failed dial.
4845 #[test]
4846 fn a_failed_dial_releases_its_reservation_and_records_the_failure() {
4847 let mut fixture = fixture();
4848 let backend = fixture.select(false, &mut request(None));
4849 assert_eq!(
4850 fixture.active_connections(&backend),
4851 1,
4852 "selection reserves"
4853 );
4854
4855 let mut deltas = vec![BackendDelta {
4856 slot: backend.slot(),
4857 change: BackendChange::DialFailed(fixture.now),
4858 }];
4859 fixture.registry.apply_all(&mut deltas, &fixture.backends);
4860
4861 assert!(deltas.is_empty(), "the drain empties the ledger");
4862 let released = fixture.backend(&backend);
4863 assert_eq!(
4864 released.active_connections, 0,
4865 "a failed dial must release the reservation selection took"
4866 );
4867 assert_eq!(released.failures, 1, "a failed dial must count one failure");
4868 assert_eq!(
4869 released.retry_policy.can_try(fixture.now),
4870 Some(RetryAction::WAIT),
4871 "a failed dial must arm the backoff at the instant it failed"
4872 );
4873 }
4874
4875 /// A cookie pins the request only when its frontend sticks.
4876 ///
4877 /// The call this replaced matched on `(frontend_should_stick, cookie)`
4878 /// and sent `(false, Some(_))` to the load balancer; the selector now takes
4879 /// an [`super::Affinity`] built by the caller, so that arm is the one a
4880 /// signature change could flip without a compile error.
4881 #[test]
4882 fn a_cookie_pins_only_a_frontend_that_sticks() {
4883 let mut unpinned = fixture();
4884 let mut context = request(Some(STICKY_B));
4885 let backend = unpinned.select(false, &mut context);
4886 assert_eq!(
4887 &*backend.backend_id, "backend-a",
4888 "a frontend that does not stick must ignore the client's cookie"
4889 );
4890 assert_eq!(
4891 context.sticky_session, None,
4892 "a frontend that does not stick must not answer with a cookie"
4893 );
4894
4895 let mut pinned = fixture();
4896 let mut context = request(Some(STICKY_B));
4897 let backend = pinned.select(true, &mut context);
4898 assert_eq!(
4899 &*backend.backend_id, "backend-b",
4900 "a sticky frontend must follow the cookie to its backend"
4901 );
4902 assert_eq!(context.sticky_session.as_deref(), Some(STICKY_B));
4903 assert_eq!(
4904 pinned.active_connections(&backend),
4905 1,
4906 "a sticky selection reserves like any other"
4907 );
4908
4909 let mut fallback = fixture();
4910 let mut context = request(None);
4911 let backend = fallback.select(true, &mut context);
4912 assert_eq!(
4913 context.sticky_session.as_deref(),
4914 Some(&*backend.backend_id),
4915 "a sticky frontend with no cookie answers with the chosen \
4916 backend's id when it has no sticky id of its own"
4917 );
4918 }
4919
4920 /// [`fixture`] under `HRW`, the policy that reads the affinity key.
4921 fn hrw_fixture() -> Fixture {
4922 fixture_with(LoadBalancingAlgorithms::Hrw)
4923 }
4924
4925 /// A request whose `plan_connect` derived `key`.
4926 fn keyed_request(cookie: Option<&str>, key: u64) -> HttpContext {
4927 let mut context = request(cookie);
4928 context.affinity_key = Some(key);
4929 context
4930 }
4931
4932 /// #524: the key a request carries reaches the load balancer, so `HRW`
4933 /// sends one key to one backend selection after selection.
4934 ///
4935 /// TO SEE THIS RED: pass `None` instead of `context.affinity_key` to
4936 /// `BackendSelector::select` in `Router::backend_from_request`;
4937 /// `HRW` then round-robins and the second selection of each key lands on
4938 /// the other backend.
4939 #[test]
4940 fn a_keyed_request_stays_on_one_backend_under_hrw() {
4941 let mut fixture = hrw_fixture();
4942 for key in 0..16u64 {
4943 let first = fixture.select(false, &mut keyed_request(None, key));
4944 for _ in 0..3 {
4945 let again = fixture.select(false, &mut keyed_request(None, key));
4946 assert_eq!(
4947 again.backend_id, first.backend_id,
4948 "key {key} must stay on one backend under HRW"
4949 );
4950 }
4951 }
4952 }
4953
4954 /// #524: a sticky cookie naming a live backend wins over the key; the key
4955 /// only decides where a request the cookie did not pin lands.
4956 #[test]
4957 fn a_sticky_cookie_wins_over_the_affinity_key() {
4958 let mut fixture = hrw_fixture();
4959 let key = (0..64u64)
4960 .find(|&key| {
4961 &*fixture
4962 .select(false, &mut keyed_request(None, key))
4963 .backend_id
4964 == "backend-a"
4965 })
4966 .expect("HRW sends some of 64 keys to each of two backends");
4967
4968 let mut context = keyed_request(Some(STICKY_B), key);
4969 let backend = fixture.select(true, &mut context);
4970 assert_eq!(
4971 &*backend.backend_id, "backend-b",
4972 "the cookie must pin the request whatever its key says"
4973 );
4974
4975 let mut context = keyed_request(None, key);
4976 let backend = fixture.select(true, &mut context);
4977 assert_eq!(
4978 &*backend.backend_id, "backend-a",
4979 "without a cookie a sticky frontend follows the key"
4980 );
4981 }
4982
4983 /// #1579: stamping a selected backend onto the request allocates nothing.
4984 ///
4985 /// `Router::backend_from_request` runs once per backend dial and writes
4986 /// the chosen backend's id into `HttpContext::backend_id`. The id arrives
4987 /// as the `Rc<str>` of the selected `BackendId`, so the stamp is a
4988 /// reference-count increment. The selector is a stub that always answers
4989 /// one id, so the count covers the router's own work and not the load
4990 /// balancer. It needs no socket: a selector does not dial (#1684).
4991 ///
4992 /// TO SEE THIS RED: stamp `Some(selected.backend.backend_id.to_string().into())`
4993 /// instead of the `Rc` clone; the assertion then reports two allocations
4994 /// per dial.
4995 #[test]
4996 fn stamping_a_dialled_backend_allocates_nothing() {
4997 use std::hint::black_box;
4998
4999 use super::{Affinity, BackendSelector, SelectedBackend};
5000 use crate::{backends::BackendError, test_allocations::allocations};
5001
5002 const DIALS: usize = 64;
5003
5004 /// Always answers `backend`, minted as a simulator would mint it.
5005 struct StubSelector {
5006 backend: BackendId,
5007 }
5008
5009 impl BackendSelector for StubSelector {
5010 fn select(
5011 &mut self,
5012 _cluster_id: &str,
5013 _affinity: Affinity<'_>,
5014 _key: Option<u64>,
5015 ) -> Result<SelectedBackend, BackendError> {
5016 Ok(SelectedBackend {
5017 backend: self.backend.clone(),
5018 sticky_session: None,
5019 })
5020 }
5021 }
5022
5023 let mut selector = StubSelector {
5024 backend: BackendId::new(
5025 0,
5026 std::rc::Rc::from("stamped-backend"),
5027 SocketAddr::from(([127, 0, 0, 1], 9503)),
5028 ),
5029 };
5030 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
5031 let mut context = request(None);
5032
5033 // Warm-up: the first stamp fills the slot the later ones overwrite.
5034 drop(
5035 router
5036 .backend_from_request(CLUSTER, false, &mut context, &mut selector)
5037 .expect("the stub selector always selects"),
5038 );
5039 let mut allocated = 0;
5040 for _ in 0..DIALS {
5041 let before = allocations();
5042 let selected = router.backend_from_request(
5043 black_box(CLUSTER),
5044 false,
5045 black_box(&mut context),
5046 &mut selector,
5047 );
5048 allocated += allocations() - before;
5049 drop(selected.expect("the stub selector always selects"));
5050 }
5051
5052 assert_eq!(context.backend_id.as_deref(), Some("stamped-backend"));
5053 assert_eq!(
5054 allocated, 0,
5055 "{DIALS} dials made {allocated} heap allocations stamping the \
5056 backend onto the request, expected none"
5057 );
5058 }
5059}
5060
5061/// [`affinity_key`]: which request bytes key `HRW` and `MAGLEV`, and that
5062/// deriving the key costs no allocation (#524).
5063#[cfg(test)]
5064mod affinity_key_tests {
5065 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
5066
5067 use sozu_command::proto::command::{Cluster, LoadBalancingAlgorithms};
5068
5069 use super::{StickyCookie, affinity_key};
5070 use crate::{
5071 load_balancing::{affinity_key_from_ip, affinity_key_from_value},
5072 pool::Pool,
5073 protocol::mux::GenericHttpStream,
5074 };
5075
5076 /// A listener sticky cookie the requests below do not carry.
5077 const NO_STICKY: StickyCookie<'static> = StickyCookie {
5078 name: "SOZUBALANCEID",
5079 value: None,
5080 };
5081
5082 const CLIENT: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)), 40_000);
5083
5084 /// A request kawa parsed from `request`, on its own pool buffer.
5085 fn parsed(pool: &mut Pool, request: &[u8]) -> GenericHttpStream {
5086 let mut kawa: GenericHttpStream = kawa::Kawa::new(
5087 kawa::Kind::Request,
5088 kawa::Buffer::new(
5089 pool.checkout()
5090 .expect("the test pool must hand out a buffer"),
5091 ),
5092 );
5093 kawa.storage.space()[..request.len()].copy_from_slice(request);
5094 kawa.storage.fill(request.len());
5095 kawa::h1::parse(&mut kawa, &mut kawa::h1::NoCallbacks);
5096 assert!(kawa.is_main_phase(), "premise: the request must parse");
5097 kawa
5098 }
5099
5100 fn cluster(
5101 policy: LoadBalancingAlgorithms,
5102 header: Option<&str>,
5103 cookie: Option<&str>,
5104 ) -> Cluster {
5105 Cluster {
5106 cluster_id: "affinity".to_owned(),
5107 load_balancing: policy as i32,
5108 affinity_header: header.map(ToOwned::to_owned),
5109 affinity_cookie: cookie.map(ToOwned::to_owned),
5110 ..Default::default()
5111 }
5112 }
5113
5114 const REQUEST: &[u8] = b"GET / HTTP/1.1\r\nHost: example.com\r\nx-tenant: acme\r\n\
5115 Cookie: theme=dark; tenant=globex\r\nX-Empty: \r\n\r\n";
5116
5117 /// Only `HRW`, `MAGLEV` and a sharded cluster read a key; every other
5118 /// cluster gets `None`, pays nothing and selects exactly as before.
5119 ///
5120 /// TO SEE THIS RED: drop the `shard_percent` arm of
5121 /// `cluster_reads_affinity_key`; the sharded round-robin cluster then
5122 /// derives no key and is silently not sharded.
5123 #[test]
5124 fn only_hrw_maglev_and_sharded_clusters_derive_a_key() {
5125 let mut pool = Pool::with_capacity(1, 1, 4096);
5126 let front = parsed(&mut pool, REQUEST);
5127 for policy in [
5128 LoadBalancingAlgorithms::RoundRobin,
5129 LoadBalancingAlgorithms::Random,
5130 LoadBalancingAlgorithms::LeastLoaded,
5131 LoadBalancingAlgorithms::PowerOfTwo,
5132 ] {
5133 assert_eq!(
5134 affinity_key(
5135 &cluster(policy, Some("X-Tenant"), None),
5136 &front,
5137 Some(CLIENT),
5138 NO_STICKY
5139 ),
5140 None,
5141 "{policy:?} does not read a key"
5142 );
5143 }
5144 // #524: a sharded cluster reads the key under any policy, to rank
5145 // the client's shard by.
5146 let sharded_round_robin = Cluster {
5147 shard_percent: Some(25),
5148 ..cluster(LoadBalancingAlgorithms::RoundRobin, None, None)
5149 };
5150 assert_eq!(
5151 affinity_key(&sharded_round_robin, &front, Some(CLIENT), NO_STICKY),
5152 Some(affinity_key_from_ip(CLIENT.ip())),
5153 "a sharded cluster derives a key whatever its policy"
5154 );
5155 for policy in [
5156 LoadBalancingAlgorithms::Hrw,
5157 LoadBalancingAlgorithms::Maglev,
5158 ] {
5159 assert_eq!(
5160 affinity_key(
5161 &cluster(policy, None, None),
5162 &front,
5163 Some(CLIENT),
5164 NO_STICKY
5165 ),
5166 Some(affinity_key_from_ip(CLIENT.ip())),
5167 "{policy:?} keys on the source IP by default"
5168 );
5169 }
5170 }
5171
5172 /// The configured header keys the request, its name matched without
5173 /// regard to case; a header the request lacks, or carries empty, falls
5174 /// back to the source IP; with no source address either there is no key.
5175 #[test]
5176 fn a_configured_header_keys_the_request_and_the_source_ip_is_the_fallback() {
5177 let mut pool = Pool::with_capacity(1, 1, 4096);
5178 let front = parsed(&mut pool, REQUEST);
5179 let hrw = LoadBalancingAlgorithms::Hrw;
5180 assert_eq!(
5181 affinity_key(
5182 &cluster(hrw, Some("X-Tenant"), None),
5183 &front,
5184 Some(CLIENT),
5185 NO_STICKY
5186 ),
5187 Some(affinity_key_from_value(b"acme"))
5188 );
5189 assert_eq!(
5190 affinity_key(
5191 &cluster(hrw, Some("X-Absent"), None),
5192 &front,
5193 Some(CLIENT),
5194 NO_STICKY
5195 ),
5196 Some(affinity_key_from_ip(CLIENT.ip()))
5197 );
5198 assert_eq!(
5199 affinity_key(
5200 &cluster(hrw, Some("X-Empty"), None),
5201 &front,
5202 Some(CLIENT),
5203 NO_STICKY
5204 ),
5205 Some(affinity_key_from_ip(CLIENT.ip())),
5206 "an empty value keys nothing and falls back to the source IP"
5207 );
5208 assert_eq!(
5209 affinity_key(
5210 &cluster(hrw, Some("X-Absent"), None),
5211 &front,
5212 None,
5213 NO_STICKY
5214 ),
5215 None,
5216 "no header and no source address: no key, and the policy round-robins"
5217 );
5218 }
5219
5220 /// The configured cookie keys the request, its name matched exactly
5221 /// (RFC 6265 cookie names are case-sensitive).
5222 #[test]
5223 fn a_configured_cookie_keys_the_request() {
5224 let mut pool = Pool::with_capacity(1, 1, 4096);
5225 let front = parsed(&mut pool, REQUEST);
5226 let maglev = LoadBalancingAlgorithms::Maglev;
5227 assert_eq!(
5228 affinity_key(
5229 &cluster(maglev, None, Some("tenant")),
5230 &front,
5231 Some(CLIENT),
5232 NO_STICKY
5233 ),
5234 Some(affinity_key_from_value(b"globex"))
5235 );
5236 assert_eq!(
5237 affinity_key(
5238 &cluster(maglev, None, Some("Tenant")),
5239 &front,
5240 Some(CLIENT),
5241 NO_STICKY
5242 ),
5243 Some(affinity_key_from_ip(CLIENT.ip())),
5244 "a cookie name differing in case is another cookie"
5245 );
5246 }
5247
5248 /// #524: deriving the key allocates nothing: the header blocks and the
5249 /// cookie jar are read where they lie and hashed in place.
5250 ///
5251 /// TO SEE THIS RED: copy the value out before hashing it in
5252 /// `affinity_key`, e.g. `affinity_key_from_value(&value.to_vec())`; each
5253 /// derivation then makes one allocation.
5254 #[test]
5255 fn deriving_an_affinity_key_allocates_nothing() {
5256 use std::hint::black_box;
5257
5258 use crate::test_allocations::allocations;
5259
5260 const DERIVATIONS: usize = 64;
5261 let mut pool = Pool::with_capacity(1, 1, 4096);
5262 let front = parsed(&mut pool, REQUEST);
5263 let clusters = [
5264 cluster(LoadBalancingAlgorithms::Hrw, None, None),
5265 cluster(LoadBalancingAlgorithms::Hrw, Some("X-Tenant"), None),
5266 cluster(LoadBalancingAlgorithms::Maglev, None, Some("tenant")),
5267 cluster(LoadBalancingAlgorithms::Maglev, Some("X-Absent"), None),
5268 ];
5269 let mut allocated = 0;
5270 for cluster in &clusters {
5271 for _ in 0..DERIVATIONS {
5272 let before = allocations();
5273 let key = affinity_key(
5274 black_box(cluster),
5275 black_box(&front),
5276 Some(CLIENT),
5277 NO_STICKY,
5278 );
5279 allocated += allocations() - before;
5280 assert!(key.is_some());
5281 }
5282 }
5283 assert_eq!(
5284 allocated,
5285 0,
5286 "{} derivations made {allocated} heap allocations, expected none",
5287 DERIVATIONS * clusters.len()
5288 );
5289 }
5290
5291 /// A cluster keying on the listener's own sticky-session cookie reads the
5292 /// value captured before that cookie was elided from the jar; without the
5293 /// capture it would never find the cookie and always fall back to the
5294 /// source IP.
5295 ///
5296 /// TO SEE THIS RED: drop the sticky-name arm of `affinity_key`, so the
5297 /// lookup reads only the jar.
5298 #[test]
5299 fn an_affinity_cookie_named_like_the_sticky_cookie_reads_its_captured_value() {
5300 let mut pool = Pool::with_capacity(1, 1, 4096);
5301 // The sticky cookie is already gone from the jar, as
5302 // `on_request_headers` leaves it.
5303 let front = parsed(&mut pool, REQUEST);
5304 let hrw = cluster(LoadBalancingAlgorithms::Hrw, None, Some("SOZUBALANCEID"));
5305 assert_eq!(
5306 affinity_key(
5307 &hrw,
5308 &front,
5309 Some(CLIENT),
5310 StickyCookie {
5311 name: "SOZUBALANCEID",
5312 value: Some("backend-7"),
5313 },
5314 ),
5315 Some(affinity_key_from_value(b"backend-7"))
5316 );
5317 assert_eq!(
5318 affinity_key(&hrw, &front, Some(CLIENT), NO_STICKY),
5319 Some(affinity_key_from_ip(CLIENT.ip())),
5320 "no sticky cookie on the request: the source IP keys it"
5321 );
5322 }
5323}