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, replace any existing `Host` request
1703/// header (so H1 backends see the same value the H2 `:authority`
1704/// would carry), and inject `X-Forwarded-Host` carrying the
1705/// pre-rewrite authority. The X-Forwarded-Host injection ONLY fires
1706/// when `rewritten_host` is set — without a rewrite there is no host
1707/// swap to disclose, and HAProxy's `option forwardfor` style
1708/// headers (`X-Forwarded-For`, `X-Forwarded-Proto`) still flow from
1709/// the kawa parser. The pre-rewrite authority is read from
1710/// `context.authority`, which no rewrite touches, and recorded in
1711/// `context.original_authority` only here, so a request without a
1712/// host rewrite copies no authority. `X-Forwarded-Host` belongs to the
1713/// `X-Forwarded-*` family, so it is injected (and a client one
1714/// replaced) only when the listener's `forwarded_headers` mode emits
1715/// that family (`both`, `x_forwarded`); `rfc7239` and `none` convey no
1716/// pre-rewrite authority, and under `none` a client `X-Forwarded-Host`
1717/// passes through as sent.
1718/// Dedup rule: the synthetic Host AND any pre-existing Host header
1719/// are dropped in the retain pass below before the rewritten Host is
1720/// appended, so the wire never carries two `Host:` headers.
1721/// 2. If `rewritten_path` is set, replace both the abstract path
1722/// (consumed by H2 `:path`) and the request-line URI (consumed by
1723/// the H1 converter) so cardinality H1↔H1, H1↔H2, H2↔H1, H2↔H2 all
1724/// propagate the rewritten target.
1725/// 3. For every `headers_request` edit:
1726/// - empty `val` → remove every existing header with the matching
1727/// name from `kawa.blocks` (HAProxy `del-header` parity);
1728/// - non-empty `val` → append the header before the `end_header`
1729/// flag block. Set/replace semantics: callers that want to replace
1730/// a header pass two edits (one delete with empty val, one set
1731/// with the new value).
1732fn apply_request_rewrites_and_headers(
1733 kawa: &mut super::GenericHttpStream,
1734 context: &mut HttpContext,
1735 rewritten_host: Option<&str>,
1736 rewritten_path: Option<&str>,
1737 headers_request: &[HeaderEdit],
1738) {
1739 use kawa::{Block, Pair, Store};
1740
1741 if rewritten_host.is_none() && rewritten_path.is_none() && headers_request.is_empty() {
1742 return;
1743 }
1744
1745 // `context.authority` is the authority the client sent: the rewrites
1746 // below mutate the kawa status line and headers, never the context. It
1747 // is copied only when the host is rewritten, for the X-Forwarded-Host
1748 // injection, and the copy then moves into `context.original_authority`.
1749 let original_authority: Option<String> = if rewritten_host.is_some() {
1750 context.authority.clone()
1751 } else {
1752 None
1753 };
1754
1755 // ── status-line authority / path rewrites ─────────────────────────
1756 // The kawa request status line carries both `path` and `uri` —
1757 // `path` is the abstract path (consumed by the H2 converter to
1758 // emit `:path`) while `uri` is the request-line URI (consumed by
1759 // the H1 converter at `kawa::protocol::h1::converter`). Both must
1760 // be mutated so an H1 frontend forwarding to an H1 backend AND an
1761 // H2 frontend forwarding to an H1 backend (or vice versa) see the
1762 // rewritten target on the wire.
1763 if (rewritten_host.is_some() || rewritten_path.is_some())
1764 && let kawa::StatusLine::Request {
1765 authority,
1766 path,
1767 uri,
1768 ..
1769 } = &mut kawa.detached.status_line
1770 {
1771 if let Some(new_host) = rewritten_host {
1772 *authority = Store::from_string(new_host.to_owned());
1773 }
1774 if let Some(new_path) = rewritten_path {
1775 *path = Store::from_string(new_path.to_owned());
1776 *uri = Store::from_string(new_path.to_owned());
1777 }
1778 }
1779
1780 // ── single-pass split: deletes vs. sets ───────────────────────────
1781 // Walk `headers_request` once and separate each edit into either the
1782 // delete list (empty val) or the insert list (non-empty val). Two
1783 // passes was wasteful when an operator stacks many `--header` flags;
1784 // one pass keeps the allocation profile flat.
1785 let host_lower = b"host";
1786 let xfh_lower = b"x-forwarded-host";
1787 let rewriting_host = rewritten_host.is_some();
1788 // X-Forwarded-Host discloses the rewrite only in a mode that emits the
1789 // X-Forwarded-* family; `rfc7239` already elided a client one in
1790 // `HttpContext::on_request_headers`, and `none` leaves it as sent.
1791 let synthesises_xfh = rewriting_host && emits_x_forwarded(context.forwarded_headers);
1792 let mut keys_to_drop: Vec<Vec<u8>> = Vec::with_capacity(headers_request.len() + 2);
1793 let mut to_insert: Vec<Block> = Vec::with_capacity(headers_request.len() + 2);
1794 // Track whether any operator-supplied edit names Host or
1795 // X-Forwarded-Host so we always dedup the existing kawa Host header
1796 // before inserting the operator's value. Without this, an operator
1797 // who sets `--header request=Host=evil` on a frontend WITHOUT
1798 // `--rewrite-host` lands TWO `Host:` headers on the backend wire —
1799 // a request-smuggling primitive on backends that pick last-Host
1800 // (CWE-444 cousin).
1801 let mut operator_overrides_host = false;
1802 let mut operator_overrides_xfh = false;
1803 for edit in headers_request {
1804 let key_is_host = edit.key.eq_ignore_ascii_case(host_lower);
1805 let key_is_xfh = edit.key.eq_ignore_ascii_case(xfh_lower);
1806 operator_overrides_host |= key_is_host;
1807 operator_overrides_xfh |= key_is_xfh;
1808 if edit.val.is_empty() {
1809 keys_to_drop.push(edit.key.iter().map(u8::to_ascii_lowercase).collect());
1810 } else {
1811 to_insert.push(Block::Header(Pair {
1812 key: Store::from_slice(&edit.key),
1813 val: Store::from_slice(&edit.val),
1814 }));
1815 }
1816 }
1817 if rewriting_host || operator_overrides_host {
1818 keys_to_drop.push(host_lower.to_vec());
1819 }
1820 if synthesises_xfh || operator_overrides_xfh {
1821 keys_to_drop.push(xfh_lower.to_vec());
1822 }
1823
1824 // ── delete pass on existing blocks ────────────────────────────────
1825 let buf_ptr = kawa.storage.buffer();
1826 if !keys_to_drop.is_empty() {
1827 // Read `key.data(buf_ptr)` only on non-elided headers — kawa's
1828 // earlier passes (HPACK decoder, H1 header parser) tag suppressed
1829 // headers with `Store::Empty` rather than removing them, and
1830 // calling `.data()` on `Store::Empty` panics in
1831 // `kawa-0.6.8/src/storage/repr.rs`. Pinning the guard explicitly
1832 // until kawa changes its policy.
1833 let buf = buf_ptr;
1834 kawa.blocks.retain(|block| {
1835 if let Block::Header(Pair { key, val: _ }) = block {
1836 if matches!(key, Store::Empty) {
1837 return true;
1838 }
1839 let key_bytes = key.data(buf);
1840 // Both `keys_to_drop` and `key_lower` are pre-lowercased,
1841 // so a byte-equality compare is sufficient — a second
1842 // ASCII-fold pass via `compare_no_case` would just burn
1843 // cycles re-folding bytes that are already canonical.
1844 let key_lower: Vec<u8> = key_bytes.iter().map(u8::to_ascii_lowercase).collect();
1845 !keys_to_drop
1846 .iter()
1847 .any(|k| k.as_slice() == key_lower.as_slice())
1848 } else {
1849 true
1850 }
1851 });
1852 }
1853
1854 // ── insertion before the end-of-headers flag ──────────────────────
1855 // Every header we add (rewritten Host, X-Forwarded-Host,
1856 // operator-supplied set/append edits) must land before
1857 // `Block::Flags { end_header: true }` so the converter emits them
1858 // as part of the request header block. Synthetic Host/X-Forwarded-Host
1859 // are prepended (they describe the rewrite, not an operator policy).
1860 let end_header_idx = super::shared::end_of_headers_index(kawa);
1861
1862 if rewriting_host {
1863 let mut synth: Vec<Block> = Vec::with_capacity(2);
1864 if let Some(new_host) = rewritten_host {
1865 synth.push(Block::Header(Pair {
1866 key: Store::Static(b"Host"),
1867 val: Store::from_string(new_host.to_owned()),
1868 }));
1869 }
1870 if synthesises_xfh && let Some(orig) = original_authority.as_deref() {
1871 synth.push(Block::Header(Pair {
1872 key: Store::Static(b"X-Forwarded-Host"),
1873 val: Store::from_string(orig.to_owned()),
1874 }));
1875 }
1876 context.original_authority = original_authority;
1877 synth.append(&mut to_insert);
1878 to_insert = synth;
1879 }
1880 if !to_insert.is_empty() {
1881 let insert_at = end_header_idx.unwrap_or(kawa.blocks.len());
1882 for (offset, block) in to_insert.into_iter().enumerate() {
1883 kawa.blocks.insert(insert_at + offset, block);
1884 }
1885 }
1886}
1887
1888/// Copy a per-frontend response-edit slice into the per-stream
1889/// `HttpContext.headers_response` snapshot, applying `filter` to each
1890/// edit. The snapshot is cleared before the copy so a second pass on
1891/// the same context (the HSTS hoist + post-forward pattern in
1892/// `route_from_request`) overrides any earlier partial copy.
1893fn snapshot_response_edits<F>(target: &mut Vec<HeaderEditSnapshot>, src: &[HeaderEdit], filter: F)
1894where
1895 F: Fn(&HeaderEdit) -> bool,
1896{
1897 target.clear();
1898 for edit in src.iter().filter(|e| filter(e)) {
1899 target.push(HeaderEditSnapshot {
1900 key: edit.key.to_vec(),
1901 val: edit.val.to_vec(),
1902 mode: edit.mode,
1903 });
1904 }
1905}
1906
1907/// Resolve the protocol scheme to use when emitting a redirect's `Location`
1908/// header. Maps the proto enum onto `"http"` / `"https"`, with `USE_SAME`
1909/// preserving the request's scheme (HTTPS for TLS listeners, HTTP otherwise).
1910fn resolve_redirect_scheme(scheme: RedirectScheme, context: &HttpContext) -> &'static str {
1911 match scheme {
1912 RedirectScheme::UseHttps => "https",
1913 RedirectScheme::UseHttp => "http",
1914 RedirectScheme::UseSame => {
1915 if context.tls_server_name.is_some() {
1916 "https"
1917 } else {
1918 "http"
1919 }
1920 }
1921 }
1922}
1923
1924/// Build the `Location` URL for a redirect response. Defaults the port
1925/// suffix only when the operator provided one or when scheme defaults
1926/// would mismatch (port 80 on https / 443 on http stays implicit).
1927///
1928/// `host_override` and `path_override` carry the frontend's
1929/// `RewriteParts::run` output for `RedirectPolicy::PERMANENT` flows so
1930/// the 301 `Location` reflects `rewrite_host` / `rewrite_path` instead
1931/// of the original `:authority` / `:path`. The legacy
1932/// `cluster.https_redirect` path passes `None` for both — it has no
1933/// per-frontend rewrite knobs.
1934fn build_redirect_location(
1935 scheme: &str,
1936 context: &HttpContext,
1937 port: Option<u32>,
1938 host_override: Option<&str>,
1939 path_override: Option<&str>,
1940) -> String {
1941 let authority = host_override
1942 .or(context.authority.as_deref())
1943 .unwrap_or_default();
1944 let path = path_override.or(context.path.as_deref()).unwrap_or("/");
1945 // Strip an existing `:port` from the authority — operators typically
1946 // configure `https_redirect_port` precisely because the listener's
1947 // port differs from the redirect target. Bracketed IPv6 literals
1948 // like `[::1]` survive intact: `rsplit_once(':')` only triggers when
1949 // the suffix after the final `:` is entirely ASCII digits.
1950 let host_only = match authority.rsplit_once(':') {
1951 Some((host, port_part))
1952 if !port_part.is_empty() && port_part.bytes().all(|b| b.is_ascii_digit()) =>
1953 {
1954 host
1955 }
1956 _ => authority,
1957 };
1958 let port_suffix = match port {
1959 Some(80) if scheme == "http" => String::new(),
1960 Some(443) if scheme == "https" => String::new(),
1961 Some(p) => format!(":{p}"),
1962 None => String::new(),
1963 };
1964 format!("{scheme}://{host_only}{port_suffix}{path}")
1965}
1966
1967/// Exact-match test between an HTTP `:authority` / `Host` value and a TLS SNI.
1968///
1969/// Matching rules:
1970/// * The authority is stripped of its optional `:port` suffix. RFC 6066 §3
1971/// forbids a port in the SNI extension, so the SNI is compared against
1972/// the host component only.
1973/// * The comparison is case-insensitive (RFC 9110 §4.2.3 — hosts are
1974/// case-insensitive). The SNI is assumed to be already lowercased by
1975/// the caller (see `https.rs::upgrade_handshake`); only the authority
1976/// side needs on-the-fly `to_ascii_lowercase`.
1977/// * No wildcard logic: if the operator serves a wildcard certificate,
1978/// the SNI negotiated by the client is still the specific name that
1979/// client sent, and the request `:authority` must equal that specific
1980/// name exactly. This is the tightest possible TLS trust boundary.
1981///
1982/// The `:port` suffix is only stripped when the suffix is non-empty and
1983/// entirely ASCII digits. This keeps bracketed IPv6 literals like `[::1]`
1984/// intact: `rsplit_once(':')` would otherwise mis-split them.
1985pub(crate) fn authority_matches_sni(authority: &str, sni_lowercased: &str) -> bool {
1986 let host = strip_authority_port(authority);
1987 if host.len() != sni_lowercased.len() {
1988 return false;
1989 }
1990 host.as_bytes()
1991 .iter()
1992 .zip(sni_lowercased.as_bytes())
1993 .all(|(a, b)| a.to_ascii_lowercase() == *b)
1994}
1995
1996/// Strip the optional `:port` suffix from an authority value. Bracketed
1997/// IPv6 literals (`[::1]`, `[::1]:8443`) keep their inner colons intact:
1998/// the suffix is only stripped when the tail after the last `:` is
1999/// non-empty and entirely ASCII digits.
2000fn strip_authority_port(authority: &str) -> &str {
2001 match authority.rsplit_once(':') {
2002 Some((h, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => h,
2003 _ => authority,
2004 }
2005}
2006
2007/// RFC 6125 §6.4.3 wildcard-aware match of `:authority` against a SAN set
2008/// snapshot taken at TLS handshake.
2009///
2010/// Returns the matched SAN entry on success so the caller can log it.
2011///
2012/// Matching rules:
2013/// * Port suffix on the authority is stripped (same logic as
2014/// [`authority_matches_sni`], IPv6-bracket safe).
2015/// * Compare is ASCII case-insensitive (`:authority` is ASCII per
2016/// RFC 9113 §8.3.1; SAN entries are stored pre-lowercased by
2017/// `https.rs::upgrade_handshake`).
2018/// * `*.suffix` matches exactly one DNS label at the leftmost position
2019/// and only when that label is non-empty: it does NOT match the apex,
2020/// does NOT cross dots, and embedded wildcards (`foo.*.example.com`,
2021/// `*foo.example.com`) are forbidden.
2022/// * Empty `names` ⇒ `None` (default-cert path — Sōzu is not
2023/// authoritative for any name).
2024pub(crate) fn authority_matched_cert_name<'a>(
2025 authority: &str,
2026 names: &'a [String],
2027) -> Option<&'a str> {
2028 let mut host = strip_authority_port(authority);
2029 // RFC 1034 §3.1 absolute-form: `example.com.` and `example.com` name
2030 // the same host. The SAN snapshot already strips trailing dots at
2031 // `https.rs::upgrade_handshake`, and the SNI side strips them at the
2032 // same site; strip on the authority side so a client emitting
2033 // absolute-form `:authority` (or H1 `Host`) does not get a false 421.
2034 // Only one trailing dot is removed because RFC 1034 forbids multiple
2035 // trailing dots on a domain literal.
2036 if let Some(trimmed) = host.strip_suffix('.') {
2037 host = trimmed;
2038 }
2039 if host.is_empty() {
2040 return None;
2041 }
2042 for entry in names {
2043 if let Some(suffix) = entry.strip_prefix("*.") {
2044 // RFC 6125 §6.4.3: the wildcard label is the *entire* left-most
2045 // label. Embedded wildcards (`f*.example.com`, `*f.example.com`)
2046 // are rejected because we reach this branch only when the entry
2047 // starts with the exact two bytes `*.`. We still must reject
2048 // wildcards anywhere else in the entry by requiring no further
2049 // `*` in `suffix`.
2050 if suffix.contains('*') {
2051 continue;
2052 }
2053 // Authority has the form `<left-most-label>.<rest>`; the
2054 // wildcard substitutes for exactly that left-most label, which
2055 // must be non-empty and contain no dot.
2056 let Some((leftmost, rest)) = host.split_once('.') else {
2057 continue;
2058 };
2059 if leftmost.is_empty() {
2060 continue;
2061 }
2062 if rest.eq_ignore_ascii_case(suffix) {
2063 return Some(entry);
2064 }
2065 continue;
2066 }
2067 if entry.contains('*') {
2068 // Internal wildcards (`foo.*.example.com`) are not RFC 6125-
2069 // valid. Skip rather than mis-match.
2070 continue;
2071 }
2072 if host.eq_ignore_ascii_case(entry) {
2073 return Some(entry);
2074 }
2075 }
2076 None
2077}
2078
2079#[cfg(test)]
2080mod tests {
2081 use std::{cell::RefCell, collections::BTreeMap, rc::Rc, time::Duration};
2082
2083 use sozu_command::{
2084 config::ListenerBuilder,
2085 proto::command::{
2086 ForwardedHeaders, PathRule, RedirectPolicy, RequestHttpFrontend, RulePosition,
2087 SocketAddress,
2088 },
2089 };
2090
2091 use super::{Router, RoutingView, authority_matches_sni};
2092 use crate::{
2093 L7Proxy,
2094 http::HttpProxy,
2095 protocol::{
2096 http::{editor::HttpContext, parser::Method},
2097 mux::{buffer_source::PoolBufferSource, stream::Stream},
2098 },
2099 };
2100
2101 /// A frontend declared as `*.example.com` and carrying `--tags` must put
2102 /// those tags on the access-log line of a request whose authority is
2103 /// `foo.example.com` (sozu#1379).
2104 ///
2105 /// The bug this pins: the tag cache was WRITTEN under the frontend
2106 /// RULE's hostname (`listener.set_tags(front.hostname, …)` in
2107 /// `lib/src/http.rs` / `lib/src/https.rs`) and READ back under the
2108 /// REQUEST's authority (`listener.get_tags(hostname)` in
2109 /// `lib/src/protocol/mux/stream.rs`), against a `BTreeMap<String,
2110 /// CachedTags>` that does exact-key lookup only. `*.example.com` is a
2111 /// key `foo.example.com` can never produce, so every access-log line of
2112 /// every wildcard, regex, ported or differently-cased frontend lost its
2113 /// tags silently. The fix resolves the tags through the router, which
2114 /// already knows which frontend rule matched.
2115 ///
2116 /// The test drives the production add path (`HttpProxy::add_http_frontend`),
2117 /// the production routing path (`Router::route_from_request`) and the
2118 /// production emit path (`Stream::generate_access_log`), and asserts on
2119 /// the rendered access-log line rather than on any intermediate field,
2120 /// so it stays valid whatever the resolution mechanism becomes.
2121 ///
2122 /// To SEE THIS RED: in `Router::route_from_request`
2123 /// (`lib/src/protocol/mux/router.rs`), replace the
2124 /// `context.tags = tags;` stash that follows the `RouteResult`
2125 /// destructure with `let _ = tags;`. The access log then falls back to
2126 /// the authority-keyed listener map, which holds only the
2127 /// `*.example.com` key, and the line is emitted with an empty `[]` tag
2128 /// field. Dropping `|| front.tags.is_some()` from `has_policy` in
2129 /// `Router::add_http_front_with_hsts_origin`
2130 /// (`lib/src/router/mod.rs`) reddens it the same way, one step
2131 /// earlier: the frontend is then stored as a tagless
2132 /// `Route::ClusterId` and there is nothing left for the stash to
2133 /// carry.
2134 #[test]
2135 fn a_wildcard_frontends_tags_reach_the_access_log_of_a_concrete_authority() {
2136 const TAG: &str = "owner=team-wildcard-1379";
2137
2138 let output = crate::capture_test_logs(|| {
2139 let port = crate::testing::provide_port();
2140 let address = SocketAddress::new_v4(127, 0, 0, 1, port);
2141 let config = ListenerBuilder::new_http(address)
2142 .to_http(None)
2143 .expect("test http listener config must build");
2144 let parts = crate::testing::prebuild_server(10, 16_384, false)
2145 .expect("test server parts must build");
2146 let pool = parts.pool.clone();
2147 let mut proxy =
2148 HttpProxy::new(parts.registry, parts.sessions, parts.pool, parts.backends);
2149 let token = mio::Token(0);
2150 proxy
2151 .add_listener(config, token)
2152 .expect("test listener must register");
2153 proxy
2154 .add_http_frontend(RequestHttpFrontend {
2155 cluster_id: Some("cluster-1379".to_owned()),
2156 address,
2157 hostname: "*.example.com".to_owned(),
2158 path: PathRule::prefix("/".to_owned()),
2159 position: RulePosition::Tree.into(),
2160 tags: BTreeMap::from([("owner".to_owned(), "team-wildcard-1379".to_owned())]),
2161 ..Default::default()
2162 })
2163 .expect("the wildcard frontend must register");
2164 let listener = proxy
2165 .get_listener(&token)
2166 .expect("the registered listener must be reachable");
2167 let proxy: Rc<RefCell<dyn L7Proxy>> = Rc::new(RefCell::new(proxy));
2168
2169 let mut context = HttpContext::new(
2170 rusty_ulid::Ulid::generate(),
2171 rusty_ulid::Ulid::generate(),
2172 crate::Protocol::HTTP,
2173 "127.0.0.1:80"
2174 .parse()
2175 .expect("test public address must parse"),
2176 Some(
2177 "127.0.0.1:12345"
2178 .parse()
2179 .expect("test session address must parse"),
2180 ),
2181 "SOZUBALANCEID".to_owned(),
2182 "Sozu-Id".to_owned(),
2183 false,
2184 false,
2185 );
2186 context.authority = Some("foo.example.com".to_owned());
2187 context.path = Some("/".to_owned());
2188 context.method = Some(Method::Get);
2189
2190 let mut stream = Stream::new(
2191 &mut PoolBufferSource::new(Rc::downgrade(&pool)),
2192 context,
2193 crate::protocol::mux::test_support::test_answers(),
2194 65_535,
2195 )
2196 .expect("test stream must check out its buffers");
2197 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
2198 let (front, stream_context) = {
2199 let split = &mut stream;
2200 (&mut split.front, &mut split.context)
2201 };
2202 let proxy_ref = proxy.borrow();
2203 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
2204 router
2205 .route_from_request(stream_context, front, &listener, &view)
2206 .expect("the wildcard frontend must route foo.example.com");
2207 drop(proxy_ref);
2208
2209 // This test asserts on the access log, not on metrics.
2210 let _ = stream.generate_access_log(false, None, listener, None, None);
2211 });
2212
2213 assert!(
2214 output.contains(TAG),
2215 "the access log of a request routed by `*.example.com` must carry that frontend's tags, got: {output}"
2216 );
2217 }
2218
2219 /// `HttpContext::original_authority` records the authority the client
2220 /// sent only when the frontend rewrites the host, the one case where it
2221 /// is sent on as `X-Forwarded-Host`; a request without a host rewrite
2222 /// leaves it `None` instead of paying a copy of the authority (#1589).
2223 ///
2224 /// To SEE THIS RED: stamp `context.original_authority` with a copy of
2225 /// `host` in `Router::route_from_request` before the frontend lookup,
2226 /// as it was before #1589. The plain request then carries
2227 /// `Some("plain.example.com")`.
2228 #[test]
2229 fn only_a_host_rewrite_records_the_original_authority() {
2230 let port = crate::testing::provide_port();
2231 let address = SocketAddress::new_v4(127, 0, 0, 1, port);
2232 let config = ListenerBuilder::new_http(address)
2233 .to_http(None)
2234 .expect("test http listener config must build");
2235 let parts = crate::testing::prebuild_server(10, 16_384, false)
2236 .expect("test server parts must build");
2237 let pool = parts.pool.clone();
2238 let mut proxy = HttpProxy::new(parts.registry, parts.sessions, parts.pool, parts.backends);
2239 let token = mio::Token(0);
2240 proxy
2241 .add_listener(config, token)
2242 .expect("test listener must register");
2243 for (hostname, rewrite_host) in [
2244 ("rewrite.example.com", Some("internal.example.com")),
2245 ("plain.example.com", None),
2246 ] {
2247 proxy
2248 .add_http_frontend(RequestHttpFrontend {
2249 cluster_id: Some("cluster-1589".to_owned()),
2250 address,
2251 hostname: hostname.to_owned(),
2252 path: PathRule::prefix("/".to_owned()),
2253 position: RulePosition::Tree.into(),
2254 rewrite_host: rewrite_host.map(str::to_owned),
2255 ..Default::default()
2256 })
2257 .unwrap_or_else(|error| panic!("{hostname} must register: {error}"));
2258 }
2259 let listener = proxy
2260 .get_listener(&token)
2261 .expect("the registered listener must be reachable");
2262 let proxy: Rc<RefCell<dyn L7Proxy>> = Rc::new(RefCell::new(proxy));
2263 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
2264
2265 for (authority, expected) in [
2266 ("rewrite.example.com", Some("rewrite.example.com")),
2267 ("plain.example.com", None),
2268 ] {
2269 let mut context = HttpContext::new(
2270 rusty_ulid::Ulid::generate(),
2271 rusty_ulid::Ulid::generate(),
2272 crate::Protocol::HTTP,
2273 "127.0.0.1:80"
2274 .parse()
2275 .expect("test public address must parse"),
2276 None,
2277 "SOZUBALANCEID".to_owned(),
2278 "Sozu-Id".to_owned(),
2279 false,
2280 false,
2281 );
2282 context.authority = Some(authority.to_owned());
2283 context.path = Some("/".to_owned());
2284 context.method = Some(Method::Get);
2285 let mut stream = Stream::new(
2286 &mut PoolBufferSource::new(Rc::downgrade(&pool)),
2287 context,
2288 crate::protocol::mux::test_support::test_answers(),
2289 65_535,
2290 )
2291 .expect("test stream must check out its buffers");
2292 let split = &mut stream;
2293 let proxy_ref = proxy.borrow();
2294 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
2295 router
2296 .route_from_request(&mut split.context, &mut split.front, &listener, &view)
2297 .unwrap_or_else(|error| panic!("{authority} must route: {error}"));
2298
2299 assert_eq!(
2300 split.context.original_authority.as_deref(),
2301 expected,
2302 "{authority}: original_authority is recorded exactly when the host is rewritten",
2303 );
2304 let forwarded_host = split.front.blocks.iter().find_map(|block| match block {
2305 kawa::Block::Header(pair)
2306 if pair.key.data(split.front.storage.buffer()) == b"X-Forwarded-Host" =>
2307 {
2308 Some(pair.val.data(split.front.storage.buffer()).to_vec())
2309 }
2310 _ => None,
2311 });
2312 assert_eq!(
2313 forwarded_host.as_deref(),
2314 expected.map(str::as_bytes),
2315 "{authority}: X-Forwarded-Host carries the authority the client sent, on a rewrite only",
2316 );
2317 }
2318 }
2319
2320 /// A frontend whose policy is `RedirectPolicy::SilentClose` refuses the
2321 /// request with `RetrieveClusterError::SilentClose` whether or not it is
2322 /// bound to a cluster, before any redirect or 401 decision, so the caller
2323 /// writes no response at all (sozu-proxy/sozu#767).
2324 ///
2325 /// To SEE THIS RED: delete the `RedirectPolicy::SilentClose` early return
2326 /// that opens the policy steps of `Router::route_from_request`
2327 /// (`lib/src/protocol/mux/router.rs`). The bound frontend then forwards
2328 /// (`Ok`) and the clusterless one answers `UnauthorizedRoute`.
2329 #[test]
2330 fn a_silent_close_frontend_refuses_without_an_answer_bound_or_clusterless() {
2331 let port = crate::testing::provide_port();
2332 let address = SocketAddress::new_v4(127, 0, 0, 1, port);
2333 let config = ListenerBuilder::new_http(address)
2334 .to_http(None)
2335 .expect("test http listener config must build");
2336 let parts = crate::testing::prebuild_server(10, 16_384, false)
2337 .expect("test server parts must build");
2338 let pool = parts.pool.clone();
2339 let mut proxy = HttpProxy::new(parts.registry, parts.sessions, parts.pool, parts.backends);
2340 let token = mio::Token(0);
2341 proxy
2342 .add_listener(config, token)
2343 .expect("test listener must register");
2344 for (hostname, cluster_id) in [
2345 ("bound.example.com", Some("cluster-767")),
2346 ("clusterless.example.com", None),
2347 ] {
2348 proxy
2349 .add_http_frontend(RequestHttpFrontend {
2350 cluster_id: cluster_id.map(str::to_owned),
2351 address,
2352 hostname: hostname.to_owned(),
2353 path: PathRule::prefix("/".to_owned()),
2354 position: RulePosition::Tree.into(),
2355 redirect: Some(RedirectPolicy::SilentClose as i32),
2356 ..Default::default()
2357 })
2358 .unwrap_or_else(|error| panic!("{hostname} must register: {error}"));
2359 }
2360 let listener = proxy
2361 .get_listener(&token)
2362 .expect("the registered listener must be reachable");
2363 let proxy: Rc<RefCell<dyn L7Proxy>> = Rc::new(RefCell::new(proxy));
2364 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
2365
2366 for authority in ["bound.example.com", "clusterless.example.com"] {
2367 let mut context = HttpContext::new(
2368 rusty_ulid::Ulid::generate(),
2369 rusty_ulid::Ulid::generate(),
2370 crate::Protocol::HTTP,
2371 "127.0.0.1:80"
2372 .parse()
2373 .expect("test public address must parse"),
2374 None,
2375 "SOZUBALANCEID".to_owned(),
2376 "Sozu-Id".to_owned(),
2377 false,
2378 false,
2379 );
2380 context.authority = Some(authority.to_owned());
2381 context.path = Some("/".to_owned());
2382 context.method = Some(Method::Get);
2383 let mut stream = Stream::new(
2384 &mut PoolBufferSource::new(Rc::downgrade(&pool)),
2385 context,
2386 crate::protocol::mux::test_support::test_answers(),
2387 65_535,
2388 )
2389 .expect("test stream must check out its buffers");
2390 let split = &mut stream;
2391 let proxy_ref = proxy.borrow();
2392 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
2393 let result =
2394 router.route_from_request(&mut split.context, &mut split.front, &listener, &view);
2395
2396 assert!(
2397 matches!(result, Err(crate::RetrieveClusterError::SilentClose)),
2398 "{authority}: a silent-close frontend must refuse with SilentClose, got {result:?}",
2399 );
2400 assert_eq!(
2401 split.context.redirect_status, None,
2402 "{authority}: a silent close must not stage a redirect answer",
2403 );
2404 }
2405 }
2406
2407 /// On a host rewrite, `X-Forwarded-Host` follows the listener's
2408 /// `forwarded_headers` mode: `both` and `x_forwarded` replace a client
2409 /// one with the authority the client sent, `rfc7239` and `none` inject
2410 /// none, and `none` leaves a client one as sent. (`rfc7239` elided the
2411 /// client one earlier, in `HttpContext::on_request_headers`.)
2412 ///
2413 /// To SEE THIS RED: in `apply_request_rewrites_and_headers`, set
2414 /// `synthesises_xfh` to `rewriting_host` alone. `rfc7239` and `none` then
2415 /// carry `X-Forwarded-Host: rewrite.example.com`.
2416 #[test]
2417 fn a_host_rewrite_discloses_x_forwarded_host_only_in_x_forwarded_modes() {
2418 let port = crate::testing::provide_port();
2419 let address = SocketAddress::new_v4(127, 0, 0, 1, port);
2420 let config = ListenerBuilder::new_http(address)
2421 .to_http(None)
2422 .expect("test http listener config must build");
2423 let parts = crate::testing::prebuild_server(10, 16_384, false)
2424 .expect("test server parts must build");
2425 let pool = parts.pool.clone();
2426 let mut proxy = HttpProxy::new(parts.registry, parts.sessions, parts.pool, parts.backends);
2427 let token = mio::Token(0);
2428 proxy
2429 .add_listener(config, token)
2430 .expect("test listener must register");
2431 proxy
2432 .add_http_frontend(RequestHttpFrontend {
2433 cluster_id: Some("cluster-322".to_owned()),
2434 address,
2435 hostname: "rewrite.example.com".to_owned(),
2436 path: PathRule::prefix("/".to_owned()),
2437 position: RulePosition::Tree.into(),
2438 rewrite_host: Some("internal.example.com".to_owned()),
2439 ..Default::default()
2440 })
2441 .expect("the rewriting frontend must register");
2442 let listener = proxy
2443 .get_listener(&token)
2444 .expect("the registered listener must be reachable");
2445 let proxy: Rc<RefCell<dyn L7Proxy>> = Rc::new(RefCell::new(proxy));
2446 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
2447
2448 for (mode, client_xfh, expected) in [
2449 (
2450 ForwardedHeaders::Both,
2451 true,
2452 vec![b"rewrite.example.com".to_vec()],
2453 ),
2454 (
2455 ForwardedHeaders::XForwarded,
2456 true,
2457 vec![b"rewrite.example.com".to_vec()],
2458 ),
2459 (ForwardedHeaders::Rfc7239, false, vec![]),
2460 (
2461 ForwardedHeaders::None,
2462 true,
2463 vec![b"client.example".to_vec()],
2464 ),
2465 ] {
2466 let mut context = HttpContext::new(
2467 rusty_ulid::Ulid::generate(),
2468 rusty_ulid::Ulid::generate(),
2469 crate::Protocol::HTTP,
2470 "127.0.0.1:80"
2471 .parse()
2472 .expect("test public address must parse"),
2473 None,
2474 "SOZUBALANCEID".to_owned(),
2475 "Sozu-Id".to_owned(),
2476 false,
2477 false,
2478 );
2479 context.forwarded_headers = mode;
2480 context.authority = Some("rewrite.example.com".to_owned());
2481 context.path = Some("/".to_owned());
2482 context.method = Some(Method::Get);
2483 let mut stream = Stream::new(
2484 &mut PoolBufferSource::new(Rc::downgrade(&pool)),
2485 context,
2486 crate::protocol::mux::test_support::test_answers(),
2487 65_535,
2488 )
2489 .expect("test stream must check out its buffers");
2490 if client_xfh {
2491 stream.front.push_block(kawa::Block::Header(kawa::Pair {
2492 key: kawa::Store::Static(b"X-Forwarded-Host"),
2493 val: kawa::Store::Static(b"client.example"),
2494 }));
2495 }
2496 let split = &mut stream;
2497 let proxy_ref = proxy.borrow();
2498 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
2499 router
2500 .route_from_request(&mut split.context, &mut split.front, &listener, &view)
2501 .unwrap_or_else(|error| panic!("{mode:?} must route: {error}"));
2502
2503 let buffer = split.front.storage.buffer();
2504 let forwarded_hosts: Vec<Vec<u8>> = split
2505 .front
2506 .blocks
2507 .iter()
2508 .filter_map(|block| match block {
2509 kawa::Block::Header(pair)
2510 if !pair.is_elided()
2511 && pair
2512 .key
2513 .data(buffer)
2514 .eq_ignore_ascii_case(b"X-Forwarded-Host") =>
2515 {
2516 Some(pair.val.data(buffer).to_vec())
2517 }
2518 _ => None,
2519 })
2520 .collect();
2521 assert_eq!(
2522 forwarded_hosts, expected,
2523 "{mode:?}: X-Forwarded-Host after a host rewrite"
2524 );
2525 }
2526 }
2527
2528 // Asserts on `debug!` output: gate on the same condition that compiles it in.
2529 #[cfg(any(debug_assertions, feature = "logs-debug", feature = "logs-trace"))]
2530 #[test]
2531 fn routing_runtime_logs_bound_method_authority_sni_and_certificate_names() {
2532 use super::{log_coalescing_accepted, log_sni_authority_mismatch};
2533
2534 const METHOD_SECRET: &str = "MUX_ROUTE_METHOD_SECRET_SENTINEL";
2535 const AUTHORITY_SECRET: &str = "MUX_ROUTE_AUTHORITY_SECRET_SENTINEL";
2536 const SNI_SECRET: &str = "MUX_ROUTE_SNI_SECRET_SENTINEL";
2537 const SAN_SECRET: &str = "MUX_ROUTE_SAN_SECRET_SENTINEL";
2538
2539 let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
2540 let method = long_value(METHOD_SECRET);
2541 let authority = long_value(AUTHORITY_SECRET);
2542 let sni = long_value(SNI_SECRET);
2543 let san = long_value(SAN_SECRET);
2544 let method_len = method.len();
2545 let authority_len = authority.len();
2546 let sni_len = sni.len();
2547 let san_len = san.len();
2548
2549 let output = crate::capture_test_logs_at_level("debug", move || {
2550 let mut context = HttpContext::new(
2551 rusty_ulid::Ulid::generate(),
2552 rusty_ulid::Ulid::generate(),
2553 crate::Protocol::HTTPS,
2554 "127.0.0.1:443"
2555 .parse()
2556 .expect("test public address must parse"),
2557 Some(
2558 "127.0.0.1:12345"
2559 .parse()
2560 .expect("test session address must parse"),
2561 ),
2562 String::new(),
2563 String::new(),
2564 false,
2565 false,
2566 );
2567 context.method = Some(crate::protocol::http::parser::Method::Custom(method));
2568 context.authority = Some(authority.clone());
2569 context.tls_cert_names = Some(std::sync::Arc::new(vec![san.clone()]));
2570
2571 log_coalescing_accepted(&context, &authority, &sni, &san);
2572 log_sni_authority_mismatch(
2573 &context,
2574 &authority,
2575 &sni,
2576 context.tls_cert_names.as_deref().map(Vec::as_slice),
2577 );
2578 });
2579
2580 for secret in [METHOD_SECRET, AUTHORITY_SECRET, SNI_SECRET, SAN_SECRET] {
2581 assert!(
2582 !output.contains(secret),
2583 "mux routing runtime log leaked {secret}: {output}"
2584 );
2585 }
2586 for metadata in [
2587 format!("bytes={method_len}"),
2588 format!("authority_bytes=Some({authority_len})"),
2589 format!("authority_bytes={authority_len}"),
2590 format!("sni_bytes={sni_len}"),
2591 format!("matched_san_bytes={san_len}"),
2592 "certificate_sans_count=1".to_owned(),
2593 format!("certificate_sans_bytes={san_len}"),
2594 ] {
2595 assert!(
2596 output.contains(&metadata),
2597 "mux routing runtime log omitted {metadata}: {output}"
2598 );
2599 }
2600 assert!(
2601 output.len() <= 2048,
2602 "mux routing runtime capture is not bounded: {} bytes",
2603 output.len()
2604 );
2605 }
2606
2607 #[test]
2608 fn match_exact() {
2609 assert!(authority_matches_sni("example.com", "example.com"));
2610 }
2611
2612 #[test]
2613 fn match_different_case() {
2614 assert!(authority_matches_sni("Example.COM", "example.com"));
2615 }
2616
2617 #[test]
2618 fn match_authority_with_port() {
2619 assert!(authority_matches_sni("example.com:8443", "example.com"));
2620 }
2621
2622 #[test]
2623 fn reject_different_host() {
2624 assert!(!authority_matches_sni(
2625 "tenant-b.example.com",
2626 "tenant-a.example.com"
2627 ));
2628 }
2629
2630 #[test]
2631 fn reject_substring_attack() {
2632 // Length check guards against an authority that is a prefix or
2633 // suffix of the SNI (or vice versa).
2634 assert!(!authority_matches_sni("example.co", "example.com"));
2635 assert!(!authority_matches_sni("example.commons", "example.com"));
2636 }
2637
2638 #[test]
2639 fn reject_wildcard_not_expanded() {
2640 // Wildcard cert selection happens at the cert-resolver layer; the SNI
2641 // we see here is the concrete name the client sent. Do not silently
2642 // accept `*.example.com` as matching `foo.example.com`.
2643 assert!(!authority_matches_sni("foo.example.com", "*.example.com"));
2644 }
2645
2646 #[test]
2647 fn ipv6_bracketed_literal_with_port() {
2648 // `[::1]:8443` must still match the SNI `[::1]`; only the trailing
2649 // `:8443` is a port (all digits → stripped).
2650 assert!(authority_matches_sni("[::1]:8443", "[::1]"));
2651 }
2652
2653 #[test]
2654 fn ipv6_bracketed_without_port() {
2655 // The `:` characters inside the brackets must not be mistaken for a
2656 // port separator: the tail after the last `:` is `1]`, not all
2657 // digits, so it is NOT stripped and the whole string compares.
2658 assert!(authority_matches_sni("[::1]", "[::1]"));
2659 }
2660}
2661
2662#[cfg(test)]
2663mod authority_matched_cert_name_tests {
2664 use super::authority_matched_cert_name;
2665
2666 #[test]
2667 fn cert_name_match_exact_single_san() {
2668 let names = vec!["example.com".to_owned()];
2669 assert_eq!(
2670 authority_matched_cert_name("example.com", &names),
2671 Some("example.com"),
2672 );
2673 }
2674
2675 #[test]
2676 fn cert_name_match_wildcard_left_most() {
2677 let names = vec!["*.cleverapps.io".to_owned()];
2678 assert_eq!(
2679 authority_matched_cert_name("staging-3.cleverapps.io", &names),
2680 Some("*.cleverapps.io"),
2681 );
2682 }
2683
2684 #[test]
2685 fn cert_name_reject_wildcard_apex() {
2686 // RFC 6125 §6.4.3: `*.example.com` does NOT cover the apex
2687 // `example.com` — the wildcard label must consume exactly one
2688 // non-empty label.
2689 let names = vec!["*.example.com".to_owned()];
2690 assert_eq!(authority_matched_cert_name("example.com", &names), None);
2691 }
2692
2693 #[test]
2694 fn cert_name_reject_wildcard_two_labels() {
2695 // `*.example.com` cannot cross dots: `a.b.example.com` has two
2696 // labels before `example.com` and must be rejected.
2697 let names = vec!["*.example.com".to_owned()];
2698 assert_eq!(authority_matched_cert_name("a.b.example.com", &names), None,);
2699 }
2700
2701 #[test]
2702 fn cert_name_reject_wildcard_not_left_most() {
2703 // Embedded wildcards (`foo.*.example.com`) are not RFC 6125-valid
2704 // and must be skipped, not mis-matched.
2705 let names = vec!["foo.*.example.com".to_owned()];
2706 assert_eq!(
2707 authority_matched_cert_name("foo.bar.example.com", &names),
2708 None,
2709 );
2710 }
2711
2712 #[test]
2713 fn cert_name_match_case_insensitive() {
2714 // ASCII case folding only — `:authority` is ASCII per RFC 9113
2715 // §8.3.1 and the snapshot is pre-lowercased at handshake.
2716 let names = vec!["EXAMPLE.com".to_owned()];
2717 assert!(authority_matched_cert_name("Example.COM", &names).is_some());
2718 }
2719
2720 #[test]
2721 fn cert_name_match_with_port() {
2722 // The port suffix on `:authority` must be stripped before the
2723 // SAN compare.
2724 let names = vec!["example.com".to_owned()];
2725 assert!(authority_matched_cert_name("example.com:8443", &names).is_some());
2726 }
2727
2728 #[test]
2729 fn cert_name_match_absolute_form_trailing_dot() {
2730 // RFC 1034 §3.1: an absolute-form domain literal carries one
2731 // trailing dot (`example.com.`) and resolves to the same host as
2732 // the relative form. The SAN snapshot stores the relative form
2733 // (https.rs strips the trailing dot at handshake), so the matcher
2734 // must strip it on the authority side too — otherwise a client
2735 // emitting an absolute-form `:authority` gets a false 421.
2736 let names = vec!["example.com".to_owned()];
2737 assert!(authority_matched_cert_name("example.com.", &names).is_some());
2738 // And with both port and trailing dot.
2739 assert!(authority_matched_cert_name("example.com.:8443", &names).is_some());
2740 // The wildcard branch must also accept the absolute form.
2741 let wildcard = vec!["*.example.com".to_owned()];
2742 assert!(authority_matched_cert_name("foo.example.com.", &wildcard).is_some());
2743 }
2744
2745 #[test]
2746 fn cert_name_match_idn_a_label() {
2747 // IDNA A-labels (xn--…) are ASCII and compare byte-for-byte once
2748 // the snapshot is lowercased.
2749 let names = vec!["xn--bcher-kva.example.com".to_owned()];
2750 assert!(authority_matched_cert_name("xn--bcher-kva.example.com", &names).is_some());
2751 }
2752
2753 #[test]
2754 fn cert_name_reject_empty_names() {
2755 // Empty snapshot = default cert served = Sōzu is not
2756 // authoritative for any name; every authority must miss.
2757 assert_eq!(authority_matched_cert_name("example.com", &[]), None);
2758 }
2759
2760 #[test]
2761 fn cert_name_match_multi_san_one_hit() {
2762 let names = vec!["foo.com".to_owned(), "*.example.org".to_owned()];
2763 assert_eq!(
2764 authority_matched_cert_name("bar.example.org", &names),
2765 Some("*.example.org"),
2766 );
2767 }
2768
2769 #[test]
2770 fn cert_name_reject_substring_attack() {
2771 // `*.example.com` must not match `example.commons` — the suffix
2772 // after the first label is `commons`, not `example.com`.
2773 let names = vec!["*.example.com".to_owned()];
2774 assert_eq!(authority_matched_cert_name("example.commons", &names), None,);
2775 }
2776
2777 #[test]
2778 fn cert_name_ipv6_bracketed_literal_with_port() {
2779 // The `:` characters inside the brackets must not be mistaken for
2780 // a port separator: only the trailing `:8443` is stripped, and
2781 // `[::1]` compares equal to `[::1]`.
2782 let names = vec!["[::1]".to_owned()];
2783 assert!(authority_matched_cert_name("[::1]:8443", &names).is_some());
2784 }
2785}
2786
2787/// Backend-selection order: `Router::plan_connect` must resolve a tie the same way
2788/// on every process, for the identical set of backend connections and the
2789/// identical request (issue #1338).
2790///
2791/// **Why these assert a SPECIFIC token rather than "the same one twice".**
2792/// `RandomState` is seeded per `HashMap`, not per iteration: two walks of one
2793/// live `HashMap` agree, so a test that runs the selection twice inside one
2794/// process and compares passes against a `HashMap` by construction. What does
2795/// NOT agree is two SEPARATELY CONSTRUCTED maps — `RandomState::default()`
2796/// bumps a thread-local key on every instantiation — so each round below
2797/// builds a fresh [`Router`], and therefore a fresh hasher, and asserts the
2798/// token the total order on `Token` picks.
2799///
2800/// That makes the red statistical rather than structural, and the bound is
2801/// stated so it can be checked rather than trusted: with `n` staged backends
2802/// and `r` rounds a `HashMap` survives with probability about `n.pow(-r)`,
2803/// which is `4^-24` here — about one run in 2.8e14. A `BTreeMap` survives
2804/// with probability 1. Measured on the pre-image, with the three production
2805/// lines reverted and these guards untouched: three consecutive runs failed
2806/// all three at round 0 or round 1. The least-loaded guard was handed
2807/// `Token(23)` and `Token(13)` where the total order gives `Token(7)`, and
2808/// the last-wins fallback `Token(7)` where it gives `Token(41)`.
2809///
2810/// **To SEE THESE RED:** in this module, put `Router::backends` back to
2811/// `HashMap<Token, Connection<SessionTcpStream>>`, `Router::new`'s initialiser
2812/// back to `HashMap::new()` and the import back to `collections::HashMap`
2813/// (the `BackendConnections` type then has no user left; delete it).
2814/// Reverting the file wholesale deletes these tests instead of reddening them.
2815#[cfg(test)]
2816mod backend_selection_order_tests {
2817 use std::{cell::RefCell, collections::HashMap, net::SocketAddr, rc::Rc, time::Duration};
2818
2819 use mio::Token;
2820 use rusty_ulid::Ulid;
2821 use sozu_command::{
2822 config::ListenerBuilder,
2823 proto::command::{
2824 Cluster, ListenerType, PathRule, RequestHttpFrontend, RulePosition, SocketAddress,
2825 },
2826 };
2827
2828 use super::Router;
2829 use crate::{
2830 BackendConnectionError, L7Proxy,
2831 backends::Backend,
2832 http::{HttpListener, HttpProxy},
2833 pool::Pool,
2834 protocol::{
2835 http::parser::Method,
2836 mux::{
2837 BackendId, BackendRegistry, BackendStatus, Connection, Context, Position,
2838 StreamState,
2839 buffer_source::PoolBufferSource,
2840 h2::H2ConnectionConfig,
2841 h2_flood_detector::H2FloodConfig,
2842 router::{ConnectPlan, ConnectStep, IpGateVerdict, RoutingView},
2843 },
2844 },
2845 socket::SessionTcpStream,
2846 };
2847
2848 /// Unwrap a step these fixtures can only produce one way.
2849 ///
2850 /// Every `Context` here is built with `session_address: None`, so the
2851 /// per-(cluster, source-IP) gate is skipped and `Router::plan_connect`
2852 /// must decide outright. A `CheckIpLimit` would mean the gate fired
2853 /// without a source IP, which is its own bug.
2854 fn decided(step: ConnectStep) -> ConnectPlan {
2855 match step {
2856 ConnectStep::Decided(plan) => plan,
2857 ConnectStep::CheckIpLimit(_) => panic!(
2858 "a stream with no session address must not reach the \
2859 per-(cluster, source-IP) gate"
2860 ),
2861 }
2862 }
2863
2864 /// Staged deliberately out of order and non-contiguous, so a green run
2865 /// cannot be an artefact of insertion order or of a dense key space.
2866 const STAGED_TOKENS: [usize; 4] = [23, 7, 41, 13];
2867 const LOWEST_TOKEN: usize = 7;
2868 const HIGHEST_TOKEN: usize = 41;
2869 /// See the module-level note for the `n.pow(-r)` bound this feeds.
2870 const ROUNDS: usize = 24;
2871
2872 const H2_CLUSTER: &str = "cluster-1338-h2";
2873 const H1_CLUSTER: &str = "cluster-1338-h1";
2874 const H2_AUTHORITY: &str = "h2.backend-order.example.com";
2875 const H1_AUTHORITY: &str = "h1.backend-order.example.com";
2876
2877 /// Which arm of `Router::plan_connect`'s scan the staged backends land in.
2878 enum Staged {
2879 /// `Position::Client(_, _, BackendStatus::Connected)` on an H2
2880 /// connection — the least-loaded arm, strict `<`, first-at-minimum.
2881 ConnectedH2,
2882 /// `BackendStatus::Connecting` on an H2 connection — the fallback
2883 /// arm, assigned with no `break` and no "already chosen" guard.
2884 ConnectingH2,
2885 /// `BackendStatus::KeepAlive` on an H1 connection — assigned with a
2886 /// `break`, so first-seen.
2887 KeepAliveH1,
2888 }
2889
2890 impl Staged {
2891 fn cluster(&self) -> &'static str {
2892 match self {
2893 Staged::ConnectedH2 | Staged::ConnectingH2 => H2_CLUSTER,
2894 Staged::KeepAliveH1 => H1_CLUSTER,
2895 }
2896 }
2897
2898 fn authority(&self) -> &'static str {
2899 match self {
2900 Staged::ConnectedH2 | Staged::ConnectingH2 => H2_AUTHORITY,
2901 Staged::KeepAliveH1 => H1_AUTHORITY,
2902 }
2903 }
2904 }
2905
2906 /// The proxy, listener, clusters, frontends and buffer pool
2907 /// `Router::plan_connect` needs. Built once and shared by every round:
2908 /// the order under test belongs to `Router::backends`, which each round
2909 /// rebuilds from scratch.
2910 struct RoutingFixture {
2911 proxy: Rc<RefCell<dyn L7Proxy>>,
2912 /// The map `proxy` was built over, which a `Mux` receives at
2913 /// construction.
2914 backends: Rc<RefCell<crate::backends::BackendMap>>,
2915 listener: Rc<RefCell<HttpListener>>,
2916 pool: Rc<RefCell<Pool>>,
2917 }
2918
2919 fn routing_fixture() -> RoutingFixture {
2920 let port = crate::testing::provide_port();
2921 let address = SocketAddress::new_v4(127, 0, 0, 1, port);
2922 let config = ListenerBuilder::new_http(address)
2923 .to_http(None)
2924 .expect("test http listener config must build");
2925 let parts =
2926 crate::testing::prebuild_server(32, 65_536, false).expect("test server must build");
2927 let pool = parts.pool.clone();
2928 let backends = Rc::clone(&parts.backends);
2929 let mut proxy = HttpProxy::new(parts.registry, parts.sessions, parts.pool, parts.backends);
2930 let listener_token = Token(0);
2931 proxy
2932 .add_listener(config, listener_token)
2933 .expect("test listener must register");
2934 for (cluster_id, authority, http2) in [
2935 (H2_CLUSTER, H2_AUTHORITY, Some(true)),
2936 (H1_CLUSTER, H1_AUTHORITY, None),
2937 ] {
2938 proxy
2939 .add_cluster(Cluster {
2940 cluster_id: cluster_id.to_owned(),
2941 http2,
2942 ..Default::default()
2943 })
2944 .expect("the test cluster must register");
2945 proxy
2946 .add_http_frontend(RequestHttpFrontend {
2947 cluster_id: Some(cluster_id.to_owned()),
2948 address,
2949 hostname: authority.to_owned(),
2950 path: PathRule::prefix("/".to_owned()),
2951 position: RulePosition::Tree.into(),
2952 ..Default::default()
2953 })
2954 .expect("the test frontend must register");
2955 }
2956 let listener = proxy
2957 .get_listener(&listener_token)
2958 .expect("the registered listener must be reachable");
2959 RoutingFixture {
2960 proxy: Rc::new(RefCell::new(proxy)),
2961 backends,
2962 listener,
2963 pool,
2964 }
2965 }
2966
2967 /// One backend connection in the requested arm, plus the accepted peer the
2968 /// caller must keep alive for the length of the round.
2969 fn staged_backend(
2970 pool: &Rc<RefCell<Pool>>,
2971 staged: &Staged,
2972 backend_registry: &mut BackendRegistry,
2973 ) -> (Connection<SessionTcpStream>, std::net::TcpStream) {
2974 let (socket, peer) = super::super::test_support::connected_socket();
2975 let backend_address: SocketAddr =
2976 "127.0.0.1:2".parse().expect("backend address must parse");
2977 let backend = Rc::new(RefCell::new(Backend::new(
2978 "test-backend",
2979 backend_address,
2980 None,
2981 None,
2982 None,
2983 )));
2984 // Through the embedder's table, exactly as a real dial does: the
2985 // handle stops here and the connection carries the opaque id.
2986 let backend = backend_registry.id_for(&backend);
2987 let session_ulid = Ulid::generate();
2988 let socket = SessionTcpStream::new(socket, session_ulid, Some(backend_address));
2989 let mut connection = match staged {
2990 Staged::ConnectedH2 | Staged::ConnectingH2 => Connection::new_h2_client(
2991 session_ulid,
2992 socket,
2993 staged.cluster().into(),
2994 backend,
2995 &mut PoolBufferSource::new(Rc::downgrade(pool)),
2996 Duration::from_secs(30),
2997 H2FloodConfig::default(),
2998 H2ConnectionConfig::default(),
2999 Duration::from_secs(30),
3000 None,
3001 )
3002 .expect("the test pool must hand out a buffer for the H2 backend"),
3003 Staged::KeepAliveH1 => Connection::new_h1_client(
3004 session_ulid,
3005 socket,
3006 staged.cluster().into(),
3007 backend,
3008 Duration::from_secs(30),
3009 ),
3010 };
3011 // `new_h*_client` opens in `Connecting`; the other two arms are
3012 // reached by moving the status the way a completed dial does.
3013 if let Position::Client(_, _, status) = connection.position_mut() {
3014 match staged {
3015 Staged::ConnectedH2 => *status = BackendStatus::Connected,
3016 Staged::ConnectingH2 => {}
3017 Staged::KeepAliveH1 => *status = BackendStatus::KeepAlive,
3018 }
3019 }
3020 (connection, peer)
3021 }
3022
3023 /// Stage [`STAGED_TOKENS`] into a FRESH [`Router`] — a fresh `RandomState`
3024 /// in the pre-image — drive one `Router::plan_connect`, and report the token it
3025 /// attached the stream to.
3026 fn selected_backend_token(fixture: &RoutingFixture, staged: &Staged) -> Token {
3027 let pool = &fixture.pool;
3028 let mut context = Context::new(
3029 Ulid::generate(),
3030 Rc::downgrade(pool),
3031 fixture.listener.clone(),
3032 // No session address: the per-(cluster, source-IP) gate is
3033 // skipped, so this never reaches the proxy's session manager.
3034 None,
3035 "127.0.0.1:80"
3036 .parse()
3037 .expect("test public address must parse"),
3038 );
3039 let stream_id = context
3040 .create_stream(Ulid::generate(), 65_535)
3041 .expect("the test pool must hand out a stream");
3042 {
3043 let stream = &mut context.streams[stream_id];
3044 stream.state = StreamState::Link;
3045 stream.context.authority = Some(staged.authority().to_owned());
3046 stream.context.path = Some("/".to_owned());
3047 stream.context.method = Some(Method::Get);
3048 }
3049
3050 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3051 let mut backend_registry = BackendRegistry::default();
3052 let mut peers = Vec::with_capacity(STAGED_TOKENS.len());
3053 for token in STAGED_TOKENS {
3054 let (connection, peer) = staged_backend(pool, staged, &mut backend_registry);
3055 peers.push(peer);
3056 router.backends.insert(Token(token), connection);
3057 }
3058
3059 // Every staged backend is reusable, so the plan must be `Attached`:
3060 // the router completed the attach itself and asked the embedder for
3061 // nothing. A `Dial` here would mean the reuse scan missed, which is
3062 // the failure this harness exists to catch.
3063 let proxy_ref = fixture.proxy.borrow();
3064 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
3065 let plan = router
3066 .plan_connect(stream_id, &mut context, &view)
3067 .map(decided)
3068 .expect("the router must reuse one of the staged backends");
3069 assert!(
3070 matches!(plan, ConnectPlan::Attached),
3071 "the staged backends are all reusable, so no dial may be requested: got {plan:?}"
3072 );
3073
3074 match context.streams[stream_id].state {
3075 StreamState::Linked(token) => token,
3076 other => panic!("connect must link the stream to a backend, got {other:?}"),
3077 }
3078 }
3079
3080 /// Drive one `Router::plan_connect` against a single staged connection of
3081 /// the `staged` arm, whose backend is retired when `retired` is set, and
3082 /// return the plan.
3083 fn plan_against_one_staged(
3084 fixture: &RoutingFixture,
3085 staged: &Staged,
3086 retired: bool,
3087 ) -> (ConnectPlan, StreamState) {
3088 let mut context = Context::new(
3089 Ulid::generate(),
3090 Rc::downgrade(&fixture.pool),
3091 fixture.listener.clone(),
3092 None,
3093 "127.0.0.1:80"
3094 .parse()
3095 .expect("test public address must parse"),
3096 );
3097 let stream_id = context
3098 .create_stream(Ulid::generate(), 65_535)
3099 .expect("the test pool must hand out a stream");
3100 {
3101 let stream = &mut context.streams[stream_id];
3102 stream.state = StreamState::Link;
3103 stream.context.authority = Some(staged.authority().to_owned());
3104 stream.context.path = Some("/".to_owned());
3105 stream.context.method = Some(Method::Get);
3106 }
3107 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3108 let mut backend_registry = BackendRegistry::default();
3109 let (connection, _peer) = staged_backend(&fixture.pool, staged, &mut backend_registry);
3110 if retired {
3111 // What `BackendList::remove_backend` and `BackendMap::remove_cluster`
3112 // do to the handle the session's registry still holds.
3113 let Position::Client(_, backend, _) = connection.position() else {
3114 unreachable!("a staged backend is a client connection")
3115 };
3116 backend_registry
3117 .handle(backend)
3118 .expect("the staged backend was interned")
3119 .borrow_mut()
3120 .set_closing();
3121 }
3122 router.backends.insert(Token(LOWEST_TOKEN), connection);
3123 let is_retired = |backend: &BackendId| backend_registry.is_retired(backend);
3124 let proxy_ref = fixture.proxy.borrow();
3125 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind())
3126 .with_backend_retired(&is_retired);
3127 let plan = router
3128 .plan_connect(stream_id, &mut context, &view)
3129 .map(decided)
3130 .expect("routing must resolve the staged cluster");
3131 (plan, context.streams[stream_id].state)
3132 }
3133
3134 /// The pool-reuse scan skips a connection whose backend has left the
3135 /// configuration, in each of its three reusable arms: H2 `Connected`, H2
3136 /// `Connecting` and H1 `KeepAlive`. The same connection with a live
3137 /// backend is reused, so a `Dial` here is the skip and not a scan that
3138 /// would have missed anyway.
3139 #[test]
3140 fn a_retired_backend_connection_is_never_reused() {
3141 let fixture = routing_fixture();
3142 for (staged, arm) in [
3143 (Staged::ConnectedH2, "H2 Connected"),
3144 (Staged::ConnectingH2, "H2 Connecting"),
3145 (Staged::KeepAliveH1, "H1 KeepAlive"),
3146 ] {
3147 let (plan, state) = plan_against_one_staged(&fixture, &staged, false);
3148 assert!(
3149 matches!(plan, ConnectPlan::Attached),
3150 "premise, {arm}: a connection to a live backend is reused, got {plan:?}"
3151 );
3152 assert_eq!(state, StreamState::Linked(Token(LOWEST_TOKEN)));
3153
3154 let (plan, state) = plan_against_one_staged(&fixture, &staged, true);
3155 assert!(
3156 matches!(&plan, ConnectPlan::Dial { cluster_id, .. } if &**cluster_id == staged.cluster()),
3157 "{arm}: a connection to a retired backend must not be reused, got {plan:?}"
3158 );
3159 assert_eq!(
3160 state,
3161 StreamState::Link,
3162 "{arm}: the stream must not be linked to the retired connection"
3163 );
3164 }
3165 }
3166
3167 /// `Mux::ready_inner` closes a retired connection only once it is idle:
3168 /// an H1 keep-alive, or a connected H2 connection with no stream. A dial
3169 /// still in progress is left to its own connect path.
3170 #[test]
3171 fn idle_pooled_backend_names_only_streamless_pooled_connections() {
3172 let fixture = routing_fixture();
3173 for (staged, arm, idle) in [
3174 (Staged::ConnectedH2, "H2 Connected", true),
3175 (Staged::ConnectingH2, "H2 Connecting", false),
3176 (Staged::KeepAliveH1, "H1 KeepAlive", true),
3177 ] {
3178 let mut backend_registry = BackendRegistry::default();
3179 let (connection, _peer) = staged_backend(&fixture.pool, &staged, &mut backend_registry);
3180 assert_eq!(
3181 connection.idle_pooled_backend().is_some(),
3182 idle,
3183 "{arm}: idle_pooled_backend must answer {idle}"
3184 );
3185 }
3186 }
3187
3188 /// An admitted stream must have been **counted** before the decision
3189 /// resumes — the ordering the gate-as-a-step split has to preserve.
3190 ///
3191 /// `SessionManager::track_cluster_ip` mutates
3192 /// `connections_per_cluster_ip`, which is keyed on `(cluster, ip)`
3193 /// **across every session on the worker** and read again by
3194 /// `lib/src/tcp.rs`'s own gate. So an `IpGateVerdict::Admitted` is an
3195 /// assertion that the slot has already been taken. Resume as `Admitted`
3196 /// without having tracked and the stream goes through uncounted, and the
3197 /// next session from the same IP finds a slot that should be gone.
3198 ///
3199 /// The test drives the real production sequence — `Router::plan_connect`
3200 /// → `super::consult_ip_gate` → `Router::plan_connect_resume` — for two
3201 /// DIFFERENT frontend tokens from the SAME source IP, against a limit of
3202 /// one. Two tokens, not one: the gate is deliberately idempotent within a
3203 /// token (an H2 session multiplexing many streams to one cluster holds a
3204 /// single slot), so a second stream on the same token could never trip
3205 /// the limit and would prove nothing.
3206 ///
3207 /// To SEE THIS RED: delete the `track_cluster_ip` call from
3208 /// `super::consult_ip_gate`, leaving it to return
3209 /// `IpGateVerdict::Admitted` without counting. The second token is then
3210 /// admitted too and the `AtLimit` assertion below fires.
3211 #[test]
3212 fn an_admitted_stream_is_counted_before_the_decision_resumes() {
3213 let fixture = routing_fixture();
3214 let sessions = fixture.proxy.borrow().sessions();
3215 let source: SocketAddr = "203.0.113.7:51000"
3216 .parse()
3217 .expect("test source address must parse");
3218
3219 // One connection per (cluster, ip).
3220 let mut clusters = HashMap::new();
3221 clusters.insert(
3222 H2_CLUSTER.into(),
3223 Cluster {
3224 cluster_id: H2_CLUSTER.to_owned(),
3225 max_connections_per_ip: Some(1),
3226 ..Default::default()
3227 },
3228 );
3229 let view = RoutingView::new(&clusters, ListenerType::Http);
3230
3231 // Drive one stream all the way through the three-step sequence and
3232 // report what the gate said and what the decision became.
3233 let run = |token: Token| {
3234 let mut context = Context::new(
3235 Ulid::generate(),
3236 Rc::downgrade(&fixture.pool),
3237 fixture.listener.clone(),
3238 Some(source),
3239 "127.0.0.1:80"
3240 .parse()
3241 .expect("test public address must parse"),
3242 );
3243 let stream_id = context
3244 .create_stream(Ulid::generate(), 65_535)
3245 .expect("the test pool must hand out a stream");
3246 {
3247 let stream = &mut context.streams[stream_id];
3248 stream.state = StreamState::Link;
3249 stream.context.authority = Some(H2_AUTHORITY.to_owned());
3250 stream.context.path = Some("/".to_owned());
3251 stream.context.method = Some(Method::Get);
3252 }
3253 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3254 let step = router
3255 .plan_connect(stream_id, &mut context, &view)
3256 .expect("routing must resolve the staged cluster");
3257 let ConnectStep::CheckIpLimit(resume) = step else {
3258 panic!("a stream WITH a session address must reach the per-IP gate")
3259 };
3260 let verdict = super::super::consult_ip_gate(
3261 &sessions,
3262 token,
3263 context
3264 .http_context(stream_id)
3265 .cluster_id
3266 .as_ref()
3267 .expect("plan_connect stamps the routed cluster"),
3268 &resume,
3269 );
3270 let admitted = matches!(verdict, IpGateVerdict::Admitted);
3271 let outcome =
3272 router.plan_connect_resume(stream_id, &mut context, &view, resume, verdict);
3273 let retry_after = context.streams[stream_id].context.retry_after_seconds;
3274 (admitted, outcome, retry_after)
3275 };
3276
3277 let (admitted, outcome, _) = run(Token(1));
3278 assert!(
3279 admitted,
3280 "premise: the first token must be under the limit, or the second proves nothing"
3281 );
3282 assert!(
3283 matches!(outcome, Ok(ConnectPlan::Dial { .. })),
3284 "an admitted stream must go on to request a dial, got {outcome:?}"
3285 );
3286
3287 let (admitted, outcome, retry_after) = run(Token(2));
3288 assert!(
3289 !admitted,
3290 "a SECOND token from the same source IP must find the slot already counted — \
3291 an Admitted verdict here means the first stream resumed without being tracked"
3292 );
3293 assert!(
3294 matches!(
3295 outcome,
3296 Err(BackendConnectionError::TooManyConnectionsPerIp { .. })
3297 ),
3298 "the refused stream must surface TooManyConnectionsPerIp, got {outcome:?}"
3299 );
3300 assert_eq!(
3301 retry_after, None,
3302 "with no cluster or global override the resolved Retry-After is 0, which is \
3303 stashed as None so the 429 mapping elides the header rather than sending 0"
3304 );
3305 }
3306
3307 /// A source holding a whole subnet defeats the per-(cluster, source-IP)
3308 /// cap outright: it takes a fresh address for every connection, so the
3309 /// per-address counter never reaches 2. The per-(cluster,
3310 /// source-SUBNET) cap is the answer, and this pins that it fires on
3311 /// DISTINCT addresses drawn from one masked network
3312 /// (sozu-proxy/sozu#1270).
3313 ///
3314 /// Three tokens, three different `203.0.113.x` addresses — every one a
3315 /// distinct `/32`, all inside `203.0.113.0/24`. The per-IP cap is set
3316 /// to 10, comfortably above the 1 connection each address holds, so it
3317 /// cannot be what refuses the fourth: only the subnet counter can.
3318 /// That is the whole point of the two caps being independent.
3319 ///
3320 /// To SEE THIS RED: delete the `|| self.cluster_subnet_at_limit(...)`
3321 /// disjunct from `SessionManager::cluster_connection_at_limit` in
3322 /// `lib/src/server.rs`, leaving every other piece of the feature —
3323 /// config keys, proto fields, masking, tracking — in place. The fourth
3324 /// address is then admitted and the `AtLimit` assertion below fires.
3325 #[test]
3326 fn a_subnet_cap_refuses_a_fresh_address_from_an_already_counted_subnet() {
3327 let fixture = routing_fixture();
3328 let sessions = fixture.proxy.borrow().sessions();
3329 {
3330 let mut manager = sessions.borrow_mut();
3331 // /24, so 203.0.113.* all mask to one key.
3332 manager.subnet_ipv4_prefix = 24;
3333 manager.max_connections_per_subnet = 3;
3334 // Deliberately far above what any single address will hold.
3335 manager.max_connections_per_ip = 10;
3336 }
3337
3338 let mut clusters = HashMap::new();
3339 clusters.insert(
3340 H2_CLUSTER.into(),
3341 Cluster {
3342 cluster_id: H2_CLUSTER.to_owned(),
3343 ..Default::default()
3344 },
3345 );
3346 let view = RoutingView::new(&clusters, ListenerType::Http);
3347
3348 // Drive one stream, from its own source address on its own token,
3349 // through the real three-step production sequence.
3350 let run = |token: Token, source: SocketAddr| {
3351 let mut context = Context::new(
3352 Ulid::generate(),
3353 Rc::downgrade(&fixture.pool),
3354 fixture.listener.clone(),
3355 Some(source),
3356 "127.0.0.1:80"
3357 .parse()
3358 .expect("test public address must parse"),
3359 );
3360 let stream_id = context
3361 .create_stream(Ulid::generate(), 65_535)
3362 .expect("the test pool must hand out a stream");
3363 {
3364 let stream = &mut context.streams[stream_id];
3365 stream.state = StreamState::Link;
3366 stream.context.authority = Some(H2_AUTHORITY.to_owned());
3367 stream.context.path = Some("/".to_owned());
3368 stream.context.method = Some(Method::Get);
3369 }
3370 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3371 let step = router
3372 .plan_connect(stream_id, &mut context, &view)
3373 .expect("routing must resolve the staged cluster");
3374 let ConnectStep::CheckIpLimit(resume) = step else {
3375 panic!("a stream WITH a session address must reach the connection gate")
3376 };
3377 let verdict = super::super::consult_ip_gate(
3378 &sessions,
3379 token,
3380 context
3381 .http_context(stream_id)
3382 .cluster_id
3383 .as_ref()
3384 .expect("plan_connect stamps the routed cluster"),
3385 &resume,
3386 );
3387 let admitted = matches!(verdict, IpGateVerdict::Admitted);
3388 let outcome =
3389 router.plan_connect_resume(stream_id, &mut context, &view, resume, verdict);
3390 (admitted, outcome)
3391 };
3392
3393 let address = |host: u8| -> SocketAddr {
3394 format!("203.0.113.{host}:51000")
3395 .parse()
3396 .expect("test source address must parse")
3397 };
3398
3399 // Premise: three DISTINCT addresses inside one /24 fill the cap.
3400 for (index, host) in [1u8, 2, 3].into_iter().enumerate() {
3401 let (admitted, outcome) = run(Token(index + 1), address(host));
3402 assert!(
3403 admitted,
3404 "premise: 203.0.113.{host} is the {} of 3 allowed in the subnet and must be \
3405 admitted, or the refusal below proves nothing",
3406 index + 1
3407 );
3408 assert!(
3409 matches!(outcome, Ok(ConnectPlan::Dial { .. })),
3410 "an admitted stream must go on to request a dial, got {outcome:?}"
3411 );
3412 }
3413
3414 // Premise: no single address is anywhere near the per-IP cap, so
3415 // the per-IP counter cannot be what refuses the next one.
3416 {
3417 let manager = sessions.borrow();
3418 for host in [1u8, 2, 3] {
3419 let ip = address(host).ip();
3420 assert!(
3421 !manager.cluster_ip_at_limit(Token(99), H2_CLUSTER, &ip, None),
3422 "premise: 203.0.113.{host} holds 1 of 10 per-IP slots and must be under \
3423 its own cap — otherwise the refusal below is the per-IP cap, not the subnet"
3424 );
3425 }
3426 }
3427
3428 // A FOURTH, never-seen address in the same /24 is refused.
3429 let (admitted, outcome) = run(Token(4), address(4));
3430 assert!(
3431 !admitted,
3432 "a fresh address from an already-counted /24 must be refused — an Admitted \
3433 verdict here is the #1270 bypass: a new address per connection defeats the cap"
3434 );
3435 assert!(
3436 matches!(
3437 outcome,
3438 Err(BackendConnectionError::TooManyConnectionsPerIp { .. })
3439 ),
3440 "the refused stream must surface the too-many-connections error, got {outcome:?}"
3441 );
3442
3443 // Negative space: the same fourth address in a DIFFERENT /24 is
3444 // admitted, so the refusal above is the subnet key and not a
3445 // blanket cap on new addresses.
3446 let (admitted, _) = run(
3447 Token(5),
3448 "198.51.100.4:51000"
3449 .parse()
3450 .expect("test source address must parse"),
3451 );
3452 assert!(
3453 admitted,
3454 "an address in an UNCOUNTED subnet must still be admitted — otherwise the cap \
3455 is refusing on something other than the subnet key"
3456 );
3457 }
3458
3459 /// The default must change nothing. With `max_connections_per_subnet`
3460 /// left at its `0` default, the admission path must behave exactly as
3461 /// it did before this feature existed — and, because the subnet
3462 /// tracking is skipped outright rather than merely ignored, it must
3463 /// also leave no bookkeeping behind.
3464 ///
3465 /// The same four addresses in one /24 that the test above refuses are
3466 /// all admitted here, and the per-IP cap still fires on its own terms
3467 /// when a single address exceeds it.
3468 #[test]
3469 fn an_unset_subnet_cap_leaves_the_admission_path_untouched() {
3470 let fixture = routing_fixture();
3471 let sessions = fixture.proxy.borrow().sessions();
3472 {
3473 let mut manager = sessions.borrow_mut();
3474 // A /24 prefix is configured but the cap is OFF: the prefix
3475 // alone must not gate anything.
3476 manager.subnet_ipv4_prefix = 24;
3477 manager.max_connections_per_subnet = 0;
3478 manager.max_connections_per_ip = 1;
3479 }
3480
3481 let mut clusters = HashMap::new();
3482 clusters.insert(
3483 H2_CLUSTER.into(),
3484 Cluster {
3485 cluster_id: H2_CLUSTER.to_owned(),
3486 ..Default::default()
3487 },
3488 );
3489 let view = RoutingView::new(&clusters, ListenerType::Http);
3490
3491 let run = |token: Token, source: SocketAddr| {
3492 let mut context = Context::new(
3493 Ulid::generate(),
3494 Rc::downgrade(&fixture.pool),
3495 fixture.listener.clone(),
3496 Some(source),
3497 "127.0.0.1:80"
3498 .parse()
3499 .expect("test public address must parse"),
3500 );
3501 let stream_id = context
3502 .create_stream(Ulid::generate(), 65_535)
3503 .expect("the test pool must hand out a stream");
3504 {
3505 let stream = &mut context.streams[stream_id];
3506 stream.state = StreamState::Link;
3507 stream.context.authority = Some(H2_AUTHORITY.to_owned());
3508 stream.context.path = Some("/".to_owned());
3509 stream.context.method = Some(Method::Get);
3510 }
3511 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3512 let step = router
3513 .plan_connect(stream_id, &mut context, &view)
3514 .expect("routing must resolve the staged cluster");
3515 let ConnectStep::CheckIpLimit(resume) = step else {
3516 panic!("a stream WITH a session address must reach the connection gate")
3517 };
3518 let verdict = super::super::consult_ip_gate(
3519 &sessions,
3520 token,
3521 context
3522 .http_context(stream_id)
3523 .cluster_id
3524 .as_ref()
3525 .expect("plan_connect stamps the routed cluster"),
3526 &resume,
3527 );
3528 matches!(verdict, IpGateVerdict::Admitted)
3529 };
3530
3531 // Four distinct addresses in one /24 — the exact traffic the test
3532 // above refuses — are all admitted while the cap is off.
3533 for (index, host) in [1u8, 2, 3, 4].into_iter().enumerate() {
3534 let source: SocketAddr = format!("203.0.113.{host}:51000")
3535 .parse()
3536 .expect("test source address must parse");
3537 assert!(
3538 run(Token(index + 1), source),
3539 "with max_connections_per_subnet = 0, 203.0.113.{host} must be admitted — \
3540 the default must not gate traffic the previous release allowed"
3541 );
3542 }
3543
3544 // The subnet accounting must be genuinely untouched, not merely
3545 // consulted-and-ignored: a disabled limiter allocates nothing.
3546 assert!(
3547 sessions.borrow().subnet_tracking_is_empty(),
3548 "a disabled subnet limiter must leave both subnet maps empty — a populated one \
3549 means every deployment that never enables this feature pays for it anyway"
3550 );
3551
3552 // And the pre-existing per-IP cap still fires on its own terms:
3553 // a SECOND token from an address already counted once is refused
3554 // at a per-IP limit of 1.
3555 let repeat: SocketAddr = "203.0.113.1:51000"
3556 .parse()
3557 .expect("test source address must parse");
3558 assert!(
3559 !run(Token(90), repeat),
3560 "the per-IP cap must still refuse a second connection from the same address — \
3561 this feature must not have relaxed the limiter that already existed"
3562 );
3563 }
3564
3565 /// The routing decision reads cluster configuration from the view it was
3566 /// handed, not from the `L7Proxy` handle it still carries.
3567 ///
3568 /// Question 6 of [#1340](https://github.com/sozu-proxy/sozu/issues/1340)
3569 /// puts routing inside the core, and the only thing that makes that worth
3570 /// anything is that the data routing reads is supplied rather than
3571 /// fetched — otherwise a simulator still cannot drive the decision. This
3572 /// hands `Router::plan_connect` a view that DISAGREES with the proxy
3573 /// about the one cluster knob the plan surfaces, and asserts the plan
3574 /// follows the view.
3575 ///
3576 /// The fixture registers `H2_CLUSTER` with `http2: Some(true)`. The view
3577 /// below says `Some(false)` for the same cluster id. A `Dial { h2: true }`
3578 /// would mean the decision went back to `L7Proxy::clusters` behind the
3579 /// view's back.
3580 ///
3581 /// To SEE THIS RED: restore either cluster read to the handle — in
3582 /// `Router::plan_connect` write `proxy.borrow().clusters().get(&cluster_id)`
3583 /// in place of `view.cluster(&cluster_id)` — and the assertion below
3584 /// reports `h2: true`.
3585 #[test]
3586 fn the_routing_decision_reads_cluster_config_from_the_view() {
3587 let fixture = routing_fixture();
3588 let pool = &fixture.pool;
3589 let mut context = Context::new(
3590 Ulid::generate(),
3591 Rc::downgrade(pool),
3592 fixture.listener.clone(),
3593 // No session address: the per-(cluster, source-IP) gate — the one
3594 // read still taken through the handle — is skipped, so this test
3595 // isolates the reads that moved.
3596 None,
3597 "127.0.0.1:80"
3598 .parse()
3599 .expect("test public address must parse"),
3600 );
3601 let stream_id = context
3602 .create_stream(Ulid::generate(), 65_535)
3603 .expect("the test pool must hand out a stream");
3604 {
3605 let stream = &mut context.streams[stream_id];
3606 stream.state = StreamState::Link;
3607 stream.context.authority = Some(H2_AUTHORITY.to_owned());
3608 stream.context.path = Some("/".to_owned());
3609 stream.context.method = Some(Method::Get);
3610 }
3611
3612 // A cluster map that is NOT the proxy's, disagreeing on `http2`.
3613 let mut clusters = HashMap::new();
3614 clusters.insert(
3615 H2_CLUSTER.into(),
3616 Cluster {
3617 cluster_id: H2_CLUSTER.to_owned(),
3618 http2: Some(false),
3619 ..Default::default()
3620 },
3621 );
3622 assert_eq!(
3623 fixture
3624 .proxy
3625 .borrow()
3626 .clusters()
3627 .get(H2_CLUSTER)
3628 .and_then(|c| c.http2),
3629 Some(true),
3630 "premise: the proxy and the view must disagree, or this proves nothing"
3631 );
3632 let view = RoutingView::new(&clusters, ListenerType::Http);
3633
3634 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3635 let plan = router
3636 .plan_connect(stream_id, &mut context, &view)
3637 .map(decided)
3638 .expect("routing must resolve the staged cluster");
3639
3640 match plan {
3641 ConnectPlan::Dial {
3642 ref cluster_id, h2, ..
3643 } => {
3644 assert_eq!(
3645 &**cluster_id, H2_CLUSTER,
3646 "premise: routing resolved a cluster"
3647 );
3648 assert!(
3649 !h2,
3650 "the plan must carry the view's `http2`, not the proxy's — \
3651 `h2: true` means the decision read `L7Proxy::clusters` behind the view"
3652 );
3653 }
3654 ConnectPlan::Attached => {
3655 panic!("premise: an empty router has nothing to reuse, so it must ask for a dial")
3656 }
3657 }
3658 }
3659
3660 /// A plan is a decision, not a deed: `ConnectPlan::Dial` must leave the
3661 /// router and the stream exactly as it found them.
3662 ///
3663 /// This is the property Question 6 of
3664 /// [#1340](https://github.com/sozu-proxy/sozu/issues/1340) is actually
3665 /// about — the core decides and the embedder performs — and it is the one
3666 /// a later edit is most likely to erode, by "just" doing one small effect
3667 /// inline because the data is right there. Asserting on the return value
3668 /// alone would not catch that: a `Dial` that had already dialled is still
3669 /// a `Dial`. So this asserts on the state the router and the stream are
3670 /// left in.
3671 ///
3672 /// To SEE THIS RED: perform any effect in `Router::plan_connect`'s
3673 /// new-backend path before it returns — inserting into `self.backends`,
3674 /// or calling `context.link_stream(stream_id, token)` — and the
3675 /// corresponding assertion below fires.
3676 ///
3677 /// SCOPE, because the name would otherwise over-read.
3678 /// `Router::plan_connect` is NOT pure yet. It still performs one registry
3679 /// mutation: `SessionManager::track_cluster_ip`, reached through
3680 /// `L7Proxy::sessions` in the per-(cluster, source-IP) limit gate. That is
3681 /// the mutation sitting *between* cluster resolution and backend
3682 /// selection that #1340 recorded as Question 6's first wrinkle, and it is
3683 /// deliberately unchanged here — it moves when the borrowed view lands,
3684 /// as either a second returned step or part of that view.
3685 ///
3686 /// This `Context` is therefore built with `session_address: None`, which
3687 /// skips the gate entirely, and the assertions below cover the router's
3688 /// backend map, the stream's link state, the backend-stream reverse index
3689 /// and the accounting ledger — NOT the session manager. Read the
3690 /// assertion list rather than the test name.
3691 #[test]
3692 fn a_dial_plan_performs_no_effect_of_its_own() {
3693 let fixture = routing_fixture();
3694 let pool = &fixture.pool;
3695 let mut context = Context::new(
3696 Ulid::generate(),
3697 Rc::downgrade(pool),
3698 fixture.listener.clone(),
3699 // No session address, so the per-(cluster, source-IP) gate is
3700 // skipped and this never reaches the proxy's session manager.
3701 None,
3702 "127.0.0.1:80"
3703 .parse()
3704 .expect("test public address must parse"),
3705 );
3706 let stream_id = context
3707 .create_stream(Ulid::generate(), 65_535)
3708 .expect("the test pool must hand out a stream");
3709 {
3710 let stream = &mut context.streams[stream_id];
3711 stream.state = StreamState::Link;
3712 stream.context.authority = Some(H2_AUTHORITY.to_owned());
3713 stream.context.path = Some("/".to_owned());
3714 stream.context.method = Some(Method::Get);
3715 }
3716
3717 // An EMPTY router: nothing to reuse, so routing must fall through to
3718 // the new-backend path and ask the embedder for a dial.
3719 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3720 let proxy_ref = fixture.proxy.borrow();
3721 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
3722 let plan = router
3723 .plan_connect(stream_id, &mut context, &view)
3724 .map(decided)
3725 .expect("routing must resolve the staged cluster");
3726
3727 match &plan {
3728 ConnectPlan::Dial { cluster_id, .. } => assert_eq!(
3729 &**cluster_id, H2_CLUSTER,
3730 "the dial request must name the cluster routing resolved"
3731 ),
3732 ConnectPlan::Attached => {
3733 panic!("premise: an empty router has nothing to reuse, so it must ask for a dial")
3734 }
3735 }
3736
3737 assert!(
3738 router.backends.is_empty(),
3739 "a plan must not have taken a backend connection into the router: {:?}",
3740 router.backends.keys().collect::<Vec<_>>()
3741 );
3742 assert_eq!(
3743 context.streams[stream_id].state,
3744 StreamState::Link,
3745 "a plan must not have linked the stream — the embedder has not dialled yet"
3746 );
3747 assert!(
3748 context.backend_streams.is_empty(),
3749 "a plan must not have touched the backend-stream reverse index"
3750 );
3751 assert!(
3752 context.backend_deltas.is_empty(),
3753 "a plan must not have charged any backend accounting: {:?}",
3754 context.backend_deltas
3755 );
3756 }
3757
3758 /// The leak with the most user-visible consequence: the H2 least-loaded
3759 /// arm compares stream counts with a strict `<`, so every staged backend
3760 /// sitting at the same count leaves the choice entirely to iteration
3761 /// order — hash order decided which machine served the request.
3762 #[test]
3763 fn an_h2_stream_count_tie_always_picks_the_lowest_backend_token() {
3764 let fixture = routing_fixture();
3765 for round in 0..ROUNDS {
3766 let chosen = selected_backend_token(&fixture, &Staged::ConnectedH2);
3767 assert_eq!(
3768 chosen,
3769 Token(LOWEST_TOKEN),
3770 "round {round}: a stream-count tie must resolve to the lowest token, \
3771 not to whatever the map iterated first"
3772 );
3773 }
3774 }
3775
3776 /// The same scan's fallback arm, and it does NOT bias the same way:
3777 /// it assigns with neither a `break` nor an "already chosen" guard, so
3778 /// the LAST matching connecting backend wins. The total order pins that
3779 /// to the highest token — asserting the lowest here would be red against
3780 /// both containers.
3781 #[test]
3782 fn an_all_connecting_h2_fallback_always_picks_the_highest_backend_token() {
3783 let fixture = routing_fixture();
3784 for round in 0..ROUNDS {
3785 let chosen = selected_backend_token(&fixture, &Staged::ConnectingH2);
3786 assert_eq!(
3787 chosen,
3788 Token(HIGHEST_TOKEN),
3789 "round {round}: the connecting-backend fallback assigns last-wins, \
3790 so the total order must land on the highest token"
3791 );
3792 }
3793 }
3794
3795 /// The H1 keep-alive arm carries the same first-seen bias as the H2
3796 /// least-loaded one — `reuse_token = Some(*token); break;` — and it is
3797 /// live wherever an H2 frontend fans several concurrent streams onto an
3798 /// H1 cluster: each stream dials its own socket, every socket parks
3799 /// `KeepAlive` for the same cluster, and the next request picks among
3800 /// them by map order.
3801 #[test]
3802 fn an_h1_keep_alive_reuse_always_picks_the_lowest_backend_token() {
3803 let fixture = routing_fixture();
3804 for round in 0..ROUNDS {
3805 let chosen = selected_backend_token(&fixture, &Staged::KeepAliveH1);
3806 assert_eq!(
3807 chosen,
3808 Token(LOWEST_TOKEN),
3809 "round {round}: the first matching keep-alive socket wins, so the total \
3810 order must land on the lowest token"
3811 );
3812 }
3813 }
3814
3815 /// #1579: the routing decision for a request that reuses a pooled
3816 /// backend connection allocates nothing of its own, in steady state.
3817 ///
3818 /// The reuse branch of `Router::decide_after_gate` stamps the stream's
3819 /// `HttpContext` and `SessionMetrics` with the id the connection's
3820 /// `BackendId` carries. That id is an `Rc<str>`, so the stamp is a
3821 /// reference-count increment; a `String` copy there costs one heap
3822 /// allocation per field and per request.
3823 ///
3824 /// Measured against a control that performs the same mux bookkeeping the
3825 /// branch triggers — `Connection::start_stream` then
3826 /// `Context::link_stream` on the same keep-alive socket — and nothing
3827 /// else. Each request is released the way production releases it
3828 /// (`Context::unlink_stream`, then the ledger drained through
3829 /// `BackendRegistry::apply_all` as a Mux pass drains it), so the control
3830 /// carries exactly the bookkeeping's costs and the difference is what the
3831 /// decision adds.
3832 ///
3833 /// Both are also held at zero outright (#1583): the reverse-index entry
3834 /// keeps its emptied `Vec` for the next `link_stream`, and the ledger is
3835 /// drained in place, so the bookkeeping itself allocates nothing either.
3836 ///
3837 /// TO SEE THIS RED: stamp either field with
3838 /// `Some(backend.backend_id.to_string().into())` instead of the `Rc`
3839 /// clone; the difference is then two allocations per request per field
3840 /// (the `String`, then the `Rc<str>` built from it). For the absolute
3841 /// half, make `remove_backend_stream` drop the emptied entry again, or
3842 /// make `BackendRegistry::apply_all` iterate `mem::take(deltas)`: each
3843 /// costs one allocation per request in both measurements.
3844 #[test]
3845 fn a_request_on_a_reused_backend_connection_allocates_nothing() {
3846 use std::hint::black_box;
3847
3848 use crate::test_allocations::allocations;
3849
3850 const REQUESTS: usize = 64;
3851 let backend_token = Token(LOWEST_TOKEN);
3852
3853 let fixture = routing_fixture();
3854 let mut context = Context::new(
3855 Ulid::generate(),
3856 Rc::downgrade(&fixture.pool),
3857 fixture.listener.clone(),
3858 None,
3859 "127.0.0.1:80"
3860 .parse()
3861 .expect("test public address must parse"),
3862 );
3863 let stream_id = context
3864 .create_stream(Ulid::generate(), 65_535)
3865 .expect("the test pool must hand out a stream");
3866 context.streams[stream_id].context.method = Some(Method::Get);
3867 // Where `Router::plan_connect` leaves the routed cluster for the
3868 // decision to read.
3869 context.streams[stream_id].context.cluster_id = Some(H1_CLUSTER.into());
3870
3871 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
3872 let mut backend_registry = BackendRegistry::default();
3873 let (connection, _peer) =
3874 staged_backend(&fixture.pool, &Staged::KeepAliveH1, &mut backend_registry);
3875 router.backends.insert(backend_token, connection);
3876
3877 // Released as `ConnectionH1::end_stream` releases a terminated
3878 // response on a keep-alive backend, then the ledger drained as the
3879 // next Mux pass drains it.
3880 let backend_registry = &backend_registry;
3881 let release = |router: &mut Router, context: &mut Context<HttpListener>| {
3882 assert_eq!(
3883 context.streams[stream_id].state,
3884 StreamState::Linked(backend_token)
3885 );
3886 context.unlink_stream(stream_id);
3887 backend_registry.apply_all(&mut context.backend_deltas, &fixture.backends);
3888 context.streams[stream_id].forget_upstream_replay();
3889 context.streams[stream_id].state = StreamState::Link;
3890 let Some(Connection::H1(h1)) = router.backends.get_mut(&backend_token) else {
3891 unreachable!("the staged backend is an H1 connection")
3892 };
3893 h1.stream = None;
3894 if let Position::Client(_, _, status) = &mut h1.position {
3895 *status = BackendStatus::KeepAlive;
3896 }
3897 };
3898 // The worker's retired-backend check rides the decision, so it is
3899 // measured with it.
3900 let clusters = HashMap::new();
3901 let retired = |backend: &super::BackendId| backend_registry.is_retired(backend);
3902 let view = RoutingView::new(&clusters, ListenerType::Http).with_backend_retired(&retired);
3903 // One request through the routing decision.
3904 let decision = |router: &mut Router, context: &mut Context<HttpListener>| -> usize {
3905 let before = allocations();
3906 let plan =
3907 router.decide_after_gate(stream_id, black_box(&mut *context), &view, false, false);
3908 let allocated = allocations() - before;
3909 assert!(
3910 matches!(plan, Ok(ConnectPlan::Attached)),
3911 "the keep-alive socket must be reused, got {plan:?}"
3912 );
3913 release(router, context);
3914 allocated
3915 };
3916 // The same socket attached by hand: only the mux bookkeeping.
3917 let control = |router: &mut Router, context: &mut Context<HttpListener>| -> usize {
3918 let before = allocations();
3919 let started = router
3920 .backends
3921 .get_mut(&backend_token)
3922 .expect("the staged backend is registered")
3923 .start_stream(stream_id, black_box(&mut *context));
3924 context.link_stream(stream_id, backend_token);
3925 let allocated = allocations() - before;
3926 assert!(started, "the keep-alive socket must accept the stream");
3927 release(router, context);
3928 allocated
3929 };
3930
3931 // Warm-up: metric keys and tables are sized by the first request.
3932 decision(&mut router, &mut context);
3933 control(&mut router, &mut context);
3934 let (mut decided, mut controlled) = (0, 0);
3935 for _ in 0..REQUESTS {
3936 decided += decision(&mut router, &mut context);
3937 controlled += control(&mut router, &mut context);
3938 }
3939
3940 decision(&mut router, &mut context);
3941 let stream = &context.streams[stream_id];
3942 assert_eq!(stream.context.backend_id.as_deref(), Some("test-backend"));
3943 assert_eq!(stream.metrics.backend_id.as_deref(), Some("test-backend"));
3944 assert_eq!(
3945 decided.saturating_sub(controlled),
3946 0,
3947 "{REQUESTS} requests on a reused keep-alive backend made {decided} \
3948 heap allocations in the routing decision against {controlled} for \
3949 the mux bookkeeping alone, expected no difference"
3950 );
3951 assert_eq!(
3952 (decided, controlled),
3953 (0, 0),
3954 "{REQUESTS} requests on a reused keep-alive backend must allocate \
3955 nothing in the decision nor in the mux bookkeeping it triggers"
3956 );
3957 }
3958
3959 /// #1583: the whole reuse branch copies no cluster id, gated or not.
3960 ///
3961 /// `ClusterId` is a reference-counted `Arc<str>` in sozu-command-lib:
3962 /// `route_from_request` hands `Router::plan_connect` a handle on the
3963 /// route table's own id, `plan_connect` moves it into the stream's
3964 /// `HttpContext`, and every later reader borrows it from there or bumps
3965 /// its count — the per-(cluster, source-IP) gate's `SessionManager`
3966 /// bookkeeping included, whose maps are keyed by the same handle. With
3967 /// the reverse-index entry and the delta ledger keeping their capacity,
3968 /// the branch allocates nothing at all past the debug build's history.
3969 ///
3970 /// Measured end to end — `plan_connect`, then on the gated path
3971 /// `consult_ip_gate` with its `SessionManager::track_cluster_connection`
3972 /// and `plan_connect_resume`, then the production release — against a
3973 /// control made of the one cost this branch does not own and which runs
3974 /// inside `plan_connect`: the debug-build `DebugEvent::Str` history push,
3975 /// absent from a release build, where the control is zero. The rest of
3976 /// `route_from_request` is measured, not controlled: it copies no
3977 /// authority and the route lookup records its trie segments on the stack
3978 /// (#1589). Both paths run, because production traffic always carries a
3979 /// source address and so always takes the gated one, while the fixtures
3980 /// above never do.
3981 ///
3982 /// TO SEE THIS RED: stamp `stream_context.cluster_id` in `plan_connect`
3983 /// with a copy (`Some(ClusterId::from(&*cluster_id))`) instead of moving
3984 /// the routed handle in: one allocation per request, on both paths. Hand
3985 /// `SessionManager::track_cluster_connection` such a copy in
3986 /// `consult_ip_gate` and the gated path alone pays one more. The same
3987 /// count comes back if `route_from_request` stamps
3988 /// `HttpContext::original_authority` with a copy of the authority on a
3989 /// request without a host rewrite, or if `Router::lookup` hands the trie
3990 /// a `Vec::with_capacity(16)` again.
3991 #[test]
3992 fn a_routed_request_on_a_reused_backend_connection_copies_no_cluster_id() {
3993 use std::hint::black_box;
3994
3995 use crate::test_allocations::allocations;
3996
3997 const REQUESTS: usize = 64;
3998 let backend_token = Token(LOWEST_TOKEN);
3999 let fixture = routing_fixture();
4000 let sessions = fixture.proxy.borrow().sessions();
4001
4002 for gated in [false, true] {
4003 let mut context = Context::new(
4004 Ulid::generate(),
4005 Rc::downgrade(&fixture.pool),
4006 fixture.listener.clone(),
4007 gated.then(|| {
4008 "127.0.0.1:4242"
4009 .parse()
4010 .expect("test session address must parse")
4011 }),
4012 "127.0.0.1:80"
4013 .parse()
4014 .expect("test public address must parse"),
4015 );
4016 let stream_id = context
4017 .create_stream(Ulid::generate(), 65_535)
4018 .expect("the test pool must hand out a stream");
4019 {
4020 let stream = &mut context.streams[stream_id];
4021 stream.state = StreamState::Link;
4022 stream.context.authority = Some(H1_AUTHORITY.to_owned());
4023 stream.context.path = Some("/".to_owned());
4024 stream.context.method = Some(Method::Get);
4025 }
4026 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
4027 let mut backend_registry = BackendRegistry::default();
4028 let (connection, _peer) =
4029 staged_backend(&fixture.pool, &Staged::KeepAliveH1, &mut backend_registry);
4030 router.backends.insert(backend_token, connection);
4031 let proxy_ref = fixture.proxy.borrow();
4032 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
4033
4034 // One request through the whole branch, the gate's bookkeeping
4035 // included, then released as production releases it.
4036 let branch = |router: &mut Router, context: &mut Context<HttpListener>| -> usize {
4037 let before = allocations();
4038 let step = router
4039 .plan_connect(stream_id, black_box(&mut *context), &view)
4040 .expect("the reused request must be planned");
4041 let mut allocated = allocations() - before;
4042 let plan = match step {
4043 ConnectStep::Decided(plan) => {
4044 assert!(!gated, "a stream with a source address must be gated");
4045 plan
4046 }
4047 ConnectStep::CheckIpLimit(resume) => {
4048 assert!(gated, "a stream without a source address must not be gated");
4049 let before = allocations();
4050 let verdict = super::super::consult_ip_gate(
4051 &sessions,
4052 Token(0),
4053 context
4054 .http_context(stream_id)
4055 .cluster_id
4056 .as_ref()
4057 .expect("plan_connect stamps the routed cluster"),
4058 &resume,
4059 );
4060 let plan = router.plan_connect_resume(
4061 stream_id,
4062 &mut *context,
4063 &view,
4064 resume,
4065 verdict,
4066 );
4067 allocated += allocations() - before;
4068 plan.expect("the gate is unlimited, so the stream is admitted")
4069 }
4070 };
4071 assert!(
4072 matches!(plan, ConnectPlan::Attached),
4073 "the keep-alive socket must be reused, got {plan:?}"
4074 );
4075 let before = allocations();
4076 context.unlink_stream(stream_id);
4077 backend_registry.apply_all(&mut context.backend_deltas, &fixture.backends);
4078 allocated += allocations() - before;
4079 let stream = &mut context.streams[stream_id];
4080 stream.forget_upstream_replay();
4081 stream.state = StreamState::Link;
4082 stream.attempts = 0;
4083 let Some(Connection::H1(h1)) = router.backends.get_mut(&backend_token) else {
4084 unreachable!("the staged backend is an H1 connection")
4085 };
4086 h1.stream = None;
4087 if let Position::Client(_, _, status) = &mut h1.position {
4088 *status = BackendStatus::KeepAlive;
4089 }
4090 allocated
4091 };
4092 // The one cost `plan_connect` carries that is not this branch's:
4093 // the debug history push. Routing hands over a handle on the
4094 // route table's cluster id, so nothing else may allocate.
4095 let control = |context: &mut Context<HttpListener>| -> usize {
4096 let before = allocations();
4097 #[cfg(debug_assertions)]
4098 {
4099 use crate::protocol::mux::DebugEvent;
4100
4101 let route = context.streams[stream_id].context.get_route();
4102 context.debug.push(DebugEvent::Str(route));
4103 }
4104 #[cfg(not(debug_assertions))]
4105 let _ = &context;
4106 allocations() - before
4107 };
4108
4109 // Warm-up: metric keys, the reverse index and the ledger are
4110 // sized by the first request.
4111 branch(&mut router, &mut context);
4112 control(&mut context);
4113 let (mut branched, mut controlled) = (0, 0);
4114 for _ in 0..REQUESTS {
4115 branched += branch(&mut router, &mut context);
4116 controlled += control(&mut context);
4117 }
4118
4119 assert_eq!(
4120 context.http_context(stream_id).cluster_id.as_deref(),
4121 Some(H1_CLUSTER)
4122 );
4123 assert_eq!(
4124 branched, controlled,
4125 "gated={gated}: {REQUESTS} requests on a reused keep-alive backend \
4126 made {branched} heap allocations through the reuse branch against \
4127 {controlled} for the debug history, expected no difference"
4128 );
4129 }
4130 }
4131
4132 /// #1583: a dial copies no cluster id: the new connection shares the
4133 /// routed one.
4134 ///
4135 /// `ClusterId` is a reference-counted `Arc<str>` in sozu-command-lib, so
4136 /// the route table, the stream's `HttpContext::cluster_id`, the
4137 /// `ConnectPlan::Dial` and the new connection's `Position::Client` all
4138 /// point at the one allocation the route table made when the frontend was
4139 /// added. `Mux::attach_dialed` moves the plan's handle into the connection.
4140 ///
4141 /// TO SEE THIS RED: build the connection in `Mux::attach_dialed` from
4142 /// `ClusterId::from(&*cluster_id)`, or stamp the plan in
4143 /// `Router::decide_after_gate` with one; the connection then owns a
4144 /// second copy at another address.
4145 #[test]
4146 fn a_dial_moves_the_planned_cluster_id_into_the_backend_connection() {
4147 use crate::{Protocol, ProxySession, protocol::mux::Mux, server::ListenSession};
4148
4149 let fixture = routing_fixture();
4150 let upstream =
4151 std::net::TcpListener::bind("127.0.0.1:0").expect("a loopback listener must bind");
4152 fixture.backends.borrow_mut().add_backend(
4153 H1_CLUSTER,
4154 Backend::new(
4155 "dial-backend",
4156 upstream
4157 .local_addr()
4158 .expect("a bound listener has an address"),
4159 None,
4160 None,
4161 None,
4162 ),
4163 );
4164 let mut context = Context::new(
4165 Ulid::generate(),
4166 Rc::downgrade(&fixture.pool),
4167 fixture.listener.clone(),
4168 None,
4169 "127.0.0.1:80"
4170 .parse()
4171 .expect("test public address must parse"),
4172 );
4173 let stream_id = context
4174 .create_stream(Ulid::generate(), 65_535)
4175 .expect("the test pool must hand out a stream");
4176 {
4177 let stream = &mut context.streams[stream_id];
4178 stream.state = StreamState::Link;
4179 stream.context.authority = Some(H1_AUTHORITY.to_owned());
4180 stream.context.path = Some("/".to_owned());
4181 stream.context.method = Some(Method::Get);
4182 }
4183 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
4184 let mut backend_registry = BackendRegistry::default();
4185
4186 let plan = {
4187 let proxy_ref = fixture.proxy.borrow();
4188 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
4189 router
4190 .plan_connect(stream_id, &mut context, &view)
4191 .map(decided)
4192 .expect("an empty router must ask for a dial")
4193 };
4194 let ConnectPlan::Dial {
4195 cluster_id,
4196 h2,
4197 frontend_should_stick,
4198 } = plan
4199 else {
4200 panic!("premise: an empty router has nothing to reuse, so it must ask for a dial")
4201 };
4202 let planned = cluster_id.as_ptr();
4203
4204 let session: Rc<RefCell<dyn ProxySession>> = Rc::new(RefCell::new(ListenSession {
4205 protocol: Protocol::HTTPListen,
4206 }));
4207 Mux::<mio::net::TcpStream, HttpListener>::dial_backend(
4208 &mut router,
4209 &mut backend_registry,
4210 &fixture.backends,
4211 stream_id,
4212 &mut context,
4213 &session,
4214 &fixture.proxy,
4215 cluster_id,
4216 h2,
4217 frontend_should_stick,
4218 )
4219 .expect("the loopback backend must dial");
4220
4221 let token = match context.streams[stream_id].state {
4222 StreamState::Linked(token) => token,
4223 other => panic!("the dial must link the stream, got {other:?}"),
4224 };
4225 let Some(Position::Client(owned, _, _)) =
4226 router.backends.get(&token).map(Connection::position)
4227 else {
4228 panic!("the dialled connection must be a client")
4229 };
4230 assert_eq!(&**owned, H1_CLUSTER);
4231 assert_eq!(
4232 context.http_context(stream_id).cluster_id.as_deref(),
4233 Some(H1_CLUSTER)
4234 );
4235 assert!(
4236 std::ptr::eq(owned.as_ptr(), planned),
4237 "the connection must own the id the plan carried, not a copy of it"
4238 );
4239 let routed = context
4240 .http_context(stream_id)
4241 .cluster_id
4242 .as_ref()
4243 .expect("plan_connect stamps the routed cluster");
4244 assert!(
4245 std::ptr::eq(owned.as_ptr(), routed.as_ptr()),
4246 "the connection must share the stream's routed id, not a copy of it"
4247 );
4248 }
4249
4250 /// Dial one fresh stream of `H1_CLUSTER` through `Mux::dial_backend`
4251 /// against a single backend at `address`, then drain the ledger as the
4252 /// next pass would. Returns the dial's result and the backend.
4253 fn dial_one(
4254 address: std::net::SocketAddr,
4255 ) -> (Result<(), BackendConnectionError>, Rc<RefCell<Backend>>) {
4256 dial_with(address, DialFault::None)
4257 }
4258
4259 /// A local failure `dial_with` stages between the plan and the dial, each
4260 /// reaching one exit of `Mux::attach_dialed`, past a successful `connect(2)`.
4261 enum DialFault {
4262 /// No fault: the dial runs as it does in production.
4263 None,
4264 /// Every buffer of the pool is checked out, so
4265 /// `Connection::new_h2_client` returns `None`. Dials `H2_CLUSTER`.
4266 PoolExhausted,
4267 /// The request already holds output encoded for another connection,
4268 /// so `ConnectionH2::start_stream` refuses it (#1632). Dials
4269 /// `H2_CLUSTER`.
4270 StartRefused,
4271 /// `L7Proxy::register_socket` fails, as it does under fd pressure.
4272 /// Dials `H1_CLUSTER`.
4273 RegisterRefused,
4274 }
4275
4276 /// The fixture's proxy, except that registering a socket fails.
4277 ///
4278 /// Everything `Mux::dial_backend` calls besides `register_socket` goes to
4279 /// the real proxy, so the slab session it adds is removed from it again.
4280 struct RegisterRefuses(Rc<RefCell<dyn L7Proxy>>);
4281
4282 impl L7Proxy for RegisterRefuses {
4283 fn kind(&self) -> ListenerType {
4284 self.0.borrow().kind()
4285 }
4286 fn register_socket(
4287 &self,
4288 _socket: &mut mio::net::TcpStream,
4289 _token: Token,
4290 _interest: mio::Interest,
4291 ) -> Result<(), std::io::Error> {
4292 Err(std::io::Error::from_raw_os_error(24)) // EMFILE
4293 }
4294 fn add_session(&self, session: Rc<RefCell<dyn crate::ProxySession>>) -> Token {
4295 self.0.borrow().add_session(session)
4296 }
4297 fn remove_session(&self, token: Token) -> bool {
4298 self.0.borrow().remove_session(token)
4299 }
4300 fn clusters(&self) -> &HashMap<sozu_command::state::ClusterId, Cluster> {
4301 unreachable!("Mux::dial_backend never reads the cluster table")
4302 }
4303 fn sessions(&self) -> Rc<RefCell<crate::server::SessionManager>> {
4304 self.0.borrow().sessions()
4305 }
4306 }
4307
4308 /// [`dial_one`] with `fault` staged before the dial.
4309 fn dial_with(
4310 address: std::net::SocketAddr,
4311 fault: DialFault,
4312 ) -> (Result<(), BackendConnectionError>, Rc<RefCell<Backend>>) {
4313 use crate::{Protocol, ProxySession, protocol::mux::Mux, server::ListenSession};
4314
4315 let (cluster, authority) = match fault {
4316 DialFault::PoolExhausted | DialFault::StartRefused => (H2_CLUSTER, H2_AUTHORITY),
4317 DialFault::None | DialFault::RegisterRefused => (H1_CLUSTER, H1_AUTHORITY),
4318 };
4319 let fixture = routing_fixture();
4320 fixture.backends.borrow_mut().add_backend(
4321 cluster,
4322 Backend::new("dial-once", address, None, None, None),
4323 );
4324 let backend = fixture
4325 .backends
4326 .borrow_mut()
4327 .backends
4328 .get_mut(cluster)
4329 .and_then(|list| list.find_backend(&address).cloned())
4330 .expect("the backend was just added");
4331 let mut context = Context::new(
4332 Ulid::generate(),
4333 Rc::downgrade(&fixture.pool),
4334 fixture.listener.clone(),
4335 None,
4336 "127.0.0.1:80"
4337 .parse()
4338 .expect("test public address must parse"),
4339 );
4340 let stream_id = context
4341 .create_stream(Ulid::generate(), 65_535)
4342 .expect("the test pool must hand out a stream");
4343 {
4344 let stream = &mut context.streams[stream_id];
4345 stream.state = StreamState::Link;
4346 stream.context.authority = Some(authority.to_owned());
4347 stream.context.path = Some("/".to_owned());
4348 stream.context.method = Some(Method::Get);
4349 }
4350 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
4351 let mut backend_registry = BackendRegistry::default();
4352 let plan = {
4353 let proxy_ref = fixture.proxy.borrow();
4354 let view = RoutingView::new(proxy_ref.clusters(), proxy_ref.kind());
4355 router
4356 .plan_connect(stream_id, &mut context, &view)
4357 .map(decided)
4358 .expect("an empty router must ask for a dial")
4359 };
4360 let ConnectPlan::Dial {
4361 cluster_id,
4362 h2,
4363 frontend_should_stick,
4364 } = plan
4365 else {
4366 panic!("premise: an empty router has nothing to reuse, so it must ask for a dial")
4367 };
4368 assert_eq!(
4369 h2,
4370 cluster == H2_CLUSTER,
4371 "premise: the plan must dial the protocol the cluster declares"
4372 );
4373 // Held until the dial returns: dropping a checkout gives it back.
4374 let mut exhausted = Vec::new();
4375 let mut proxy = fixture.proxy.clone();
4376 match fault {
4377 DialFault::None => {}
4378 DialFault::PoolExhausted => {
4379 while let Some(buffer) = fixture.pool.borrow_mut().checkout() {
4380 exhausted.push(buffer);
4381 }
4382 }
4383 DialFault::StartRefused => {
4384 context.streams[stream_id]
4385 .front
4386 .out
4387 .push_back(kawa::OutBlock::Store(kawa::Store::Static(b"encoded")));
4388 }
4389 DialFault::RegisterRefused => {
4390 proxy = Rc::new(RefCell::new(RegisterRefuses(fixture.proxy.clone())));
4391 }
4392 }
4393 let session: Rc<RefCell<dyn ProxySession>> = Rc::new(RefCell::new(ListenSession {
4394 protocol: Protocol::HTTPListen,
4395 }));
4396 let dialed = Mux::<mio::net::TcpStream, HttpListener>::dial_backend(
4397 &mut router,
4398 &mut backend_registry,
4399 &fixture.backends,
4400 stream_id,
4401 &mut context,
4402 &session,
4403 &proxy,
4404 cluster_id,
4405 h2,
4406 frontend_should_stick,
4407 );
4408 drop(exhausted);
4409 assert!(
4410 dialed.is_ok() || router.backends.is_empty(),
4411 "a dial that failed must keep no backend connection, got {dialed:?}"
4412 );
4413 backend_registry.apply_all(&mut context.backend_deltas, &fixture.backends);
4414 (dialed, backend)
4415 }
4416
4417 /// #1684: a dial that connects counts its connection once. Selection
4418 /// reserved it, and the dial keeps that reservation as the connection's
4419 /// count rather than taking a second one.
4420 ///
4421 /// TO SEE THIS RED: call `inc_connections` on the dialled backend in
4422 /// `Mux::dial_backend` after the connect succeeds, as
4423 /// `Backend::try_connect` does for the TCP proxy; the backend then holds
4424 /// two connections for one dial.
4425 #[test]
4426 fn a_successful_dial_counts_its_connection_once() {
4427 let upstream =
4428 std::net::TcpListener::bind("127.0.0.1:0").expect("a loopback listener must bind");
4429 let (dialed, backend) = dial_one(
4430 upstream
4431 .local_addr()
4432 .expect("a bound listener has an address"),
4433 );
4434 dialed.expect("the loopback backend must dial");
4435 let backend = backend.borrow();
4436 assert_eq!(
4437 backend.active_connections, 1,
4438 "one dial must hold exactly one connection on its backend"
4439 );
4440 assert_eq!(backend.failures, 0, "a successful dial records no failure");
4441 }
4442
4443 /// #1684: a `connect(2)` that fails synchronously releases the reservation
4444 /// selection took and records the failure, once the ledger is drained.
4445 ///
4446 /// `255.255.255.255` is the limited broadcast address: connecting a TCP
4447 /// socket to it fails before any packet leaves (`ENETUNREACH`, or
4448 /// `EACCES` where a default route exists). The dial is asserted to fail,
4449 /// so a host where it somehow connected fails this test loudly instead of
4450 /// passing it vacuously.
4451 ///
4452 /// TO SEE THIS RED: return the error from `Mux::dial_backend` without
4453 /// pushing the `BackendChange::DialFailed` delta; the reservation then
4454 /// outlives the failed dial.
4455 #[test]
4456 fn a_synchronously_failed_dial_releases_its_reservation() {
4457 let (dialed, backend) = dial_one("255.255.255.255:80".parse().expect("a literal address"));
4458 assert!(
4459 matches!(
4460 dialed,
4461 Err(BackendConnectionError::Backend(
4462 crate::backends::BackendError::ConnectionFailures { failures: 1, .. }
4463 ))
4464 ),
4465 "a connect to the broadcast address must fail synchronously, got {dialed:?}"
4466 );
4467 let backend = backend.borrow();
4468 assert_eq!(
4469 backend.active_connections, 0,
4470 "the failed dial must release the reservation selection took"
4471 );
4472 assert_eq!(
4473 backend.failures, 1,
4474 "the failed dial must count one failure"
4475 );
4476 }
4477
4478 /// #1713: dial one stream with `fault` staged against a listening
4479 /// backend, and assert the dial failed with `expected` and left the
4480 /// backend exactly as it found it: the reservation selection took is
4481 /// released, and no failure is recorded, because the backend did not fail.
4482 fn assert_a_local_failure_releases_the_reservation(
4483 fault: DialFault,
4484 expected: fn(&BackendConnectionError) -> bool,
4485 ) {
4486 use crate::retry::{RetryAction, RetryPolicy};
4487
4488 let upstream =
4489 std::net::TcpListener::bind("127.0.0.1:0").expect("a loopback listener must bind");
4490 let (dialed, backend) = dial_with(
4491 upstream
4492 .local_addr()
4493 .expect("a bound listener has an address"),
4494 fault,
4495 );
4496 assert!(
4497 dialed.as_ref().is_err_and(expected),
4498 "premise: the staged fault must fail the dial at its exit, got {dialed:?}"
4499 );
4500 let backend = backend.borrow();
4501 assert_eq!(
4502 backend.active_connections, 0,
4503 "a dial abandoned after its connect must release the reservation \
4504 selection took"
4505 );
4506 assert_eq!(
4507 backend.failures, 0,
4508 "a local failure must not count against the backend"
4509 );
4510 assert_eq!(
4511 backend.retry_policy.can_try(std::time::Instant::now()),
4512 Some(RetryAction::OKAY),
4513 "a local failure must not arm the backend's backoff"
4514 );
4515 }
4516
4517 /// #1713: `Connection::new_h2_client` returning `None` (buffer pool
4518 /// exhausted) releases the reservation.
4519 ///
4520 /// TO SEE THIS RED: in `Mux::dial_backend`, return the error of
4521 /// `Mux::attach_dialed` without pushing the `BackendChange::ConnectionClosed`
4522 /// delta; the backend then keeps `active_connections == 1`.
4523 #[test]
4524 fn an_h2_client_the_pool_cannot_build_releases_its_reservation() {
4525 assert_a_local_failure_releases_the_reservation(DialFault::PoolExhausted, |error| {
4526 matches!(error, BackendConnectionError::MaxBuffers)
4527 });
4528 }
4529
4530 /// #1713: a refused `Connection::start_stream` releases the reservation.
4531 ///
4532 /// TO SEE THIS RED: as for
4533 /// [`an_h2_client_the_pool_cannot_build_releases_its_reservation`].
4534 #[test]
4535 fn a_refused_stream_start_releases_its_reservation() {
4536 assert_a_local_failure_releases_the_reservation(DialFault::StartRefused, |error| {
4537 matches!(error, BackendConnectionError::MaxSessionsMemory)
4538 });
4539 }
4540
4541 /// #1713: the `register_socket` rollback releases the reservation, not
4542 /// only the gauges, `active_requests` and the slab session.
4543 ///
4544 /// TO SEE THIS RED: as for
4545 /// [`an_h2_client_the_pool_cannot_build_releases_its_reservation`].
4546 #[test]
4547 fn a_socket_the_poller_refuses_releases_its_reservation() {
4548 assert_a_local_failure_releases_the_reservation(DialFault::RegisterRefused, |error| {
4549 matches!(error, BackendConnectionError::MaxSessionsMemory)
4550 });
4551 }
4552
4553 /// #1610: a session's first backend connection is stored without a heap
4554 /// allocation, and the connections still iterate in `Token` order however
4555 /// they were inserted and removed.
4556 ///
4557 /// `Router::backends` was a `BTreeMap`, whose first insertion allocates an
4558 /// 11-slot leaf of ~1.7 KiB connections, ~18.8 KiB, for the one backend a
4559 /// session almost always has.
4560 ///
4561 /// TO SEE THIS RED: in `BackendConnections::insert`, send the `None` arm
4562 /// to `self.rest.insert(token, connection)` instead of filling `first`
4563 /// (and let `remove` and `is_empty` read `rest` alone). The first
4564 /// insertion then makes one allocation.
4565 #[test]
4566 fn the_first_backend_connection_of_a_session_allocates_nothing() {
4567 use crate::test_allocations::allocations;
4568
4569 let fixture = routing_fixture();
4570 let mut backend_registry = BackendRegistry::default();
4571 let mut staged = |router: &mut Router, token: usize| {
4572 let (connection, peer) =
4573 staged_backend(&fixture.pool, &Staged::KeepAliveH1, &mut backend_registry);
4574 let before = allocations();
4575 let replaced = router.backends.insert(Token(token), connection);
4576 let allocated = allocations() - before;
4577 assert!(replaced.is_none(), "Token({token}) was not held yet");
4578 (allocated, peer)
4579 };
4580
4581 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
4582 let (first, _first_peer) = staged(&mut router, LOWEST_TOKEN + 2);
4583 assert_eq!(
4584 first, 0,
4585 "the first backend connection of a session must not allocate"
4586 );
4587
4588 // A lower token takes the inline slot, a higher one goes to the map.
4589 let (_, _lower_peer) = staged(&mut router, LOWEST_TOKEN);
4590 let (_, _higher_peer) = staged(&mut router, LOWEST_TOKEN + 5);
4591 let order = |router: &Router| router.backends.keys().map(|t| t.0).collect::<Vec<_>>();
4592 assert_eq!(
4593 order(&router),
4594 [LOWEST_TOKEN, LOWEST_TOKEN + 2, LOWEST_TOKEN + 5]
4595 );
4596 assert_eq!(router.backends.len(), 3);
4597
4598 // Removing the inline connection promotes the lowest remaining one.
4599 assert!(router.backends.remove(&Token(LOWEST_TOKEN)).is_some());
4600 assert_eq!(order(&router), [LOWEST_TOKEN + 2, LOWEST_TOKEN + 5]);
4601 assert!(router.backends.contains_key(&Token(LOWEST_TOKEN + 2)));
4602 assert!(!router.backends.contains_key(&Token(LOWEST_TOKEN)));
4603 assert!(router.backends.remove(&Token(LOWEST_TOKEN + 5)).is_some());
4604 assert!(router.backends.remove(&Token(LOWEST_TOKEN + 2)).is_some());
4605 assert!(router.backends.is_empty());
4606 }
4607}
4608
4609/// [`Router::backend_from_request`] against the embedder's real selector.
4610///
4611/// Question 12 of [#1340](https://github.com/sozu-proxy/sozu/issues/1340)
4612/// replaced the proxy handle this function took with a backend-set capability,
4613/// now a [`BackendSelector`] (#1684); it had no test before that change. Each
4614/// fixture owns its own `BackendMap` and `BackendRegistry`, so nothing here
4615/// depends on a proxy, a listener or a session — and, since selection no
4616/// longer dials, on any socket.
4617#[cfg(test)]
4618mod backend_selector_tests {
4619 use std::{
4620 cell::RefCell,
4621 net::SocketAddr,
4622 time::{Duration, Instant},
4623 };
4624
4625 use sozu_command::proto::command::{LoadBalancingAlgorithms, LoadMetric};
4626
4627 use super::Router;
4628 use crate::{
4629 backends::{Backend, BackendMap},
4630 protocol::{
4631 http::editor::HttpContext,
4632 mux::{BackendChange, BackendDelta, BackendId, BackendRegistry, RegistrySelector},
4633 },
4634 retry::{RetryAction, RetryPolicy},
4635 };
4636
4637 const CLUSTER: &str = "cluster-1340-q12";
4638 const STICKY_B: &str = "sticky-b";
4639
4640 /// Two backends under `policy` on connections. The addresses are never
4641 /// dialled: selection reserves, and only a caller of the selector connects.
4642 /// `LeastLoaded` is the default because round-robin would alternate
4643 /// whatever the counters said, and a test that passes without reading the
4644 /// load observes nothing.
4645 struct Fixture {
4646 backends: RefCell<BackendMap>,
4647 registry: BackendRegistry,
4648 router: Router,
4649 now: Instant,
4650 }
4651
4652 fn fixture_with(policy: LoadBalancingAlgorithms) -> Fixture {
4653 let now = Instant::now();
4654 let address = |port: u16| SocketAddr::from(([127, 0, 0, 1], port));
4655 let mut backends = BackendMap::with_seed(0x1684);
4656 backends.set_load_balancing_policy_for_cluster(
4657 CLUSTER,
4658 policy,
4659 Some(LoadMetric::Connections),
4660 );
4661 backends.add_backend(
4662 CLUSTER,
4663 Backend::new_at("backend-a", address(9501), None, None, None, now),
4664 );
4665 backends.add_backend(
4666 CLUSTER,
4667 Backend::new_at(
4668 "backend-b",
4669 address(9502),
4670 Some(STICKY_B.to_owned()),
4671 None,
4672 None,
4673 now,
4674 ),
4675 );
4676 Fixture {
4677 backends: RefCell::new(backends),
4678 registry: BackendRegistry::default(),
4679 router: Router::new(Duration::from_secs(10), Duration::from_secs(10)),
4680 now,
4681 }
4682 }
4683
4684 fn fixture() -> Fixture {
4685 fixture_with(LoadBalancingAlgorithms::LeastLoaded)
4686 }
4687
4688 fn request(cookie: Option<&str>) -> HttpContext {
4689 let mut context = HttpContext::new(
4690 rusty_ulid::Ulid::generate(),
4691 rusty_ulid::Ulid::generate(),
4692 crate::Protocol::HTTP,
4693 "127.0.0.1:80"
4694 .parse()
4695 .expect("test public address must parse"),
4696 None,
4697 "SOZUBALANCEID".to_owned(),
4698 "Sozu-Id".to_owned(),
4699 false,
4700 false,
4701 );
4702 context.sticky_session_found = cookie.map(ToOwned::to_owned);
4703 context
4704 }
4705
4706 impl Fixture {
4707 fn select(&mut self, should_stick: bool, context: &mut HttpContext) -> BackendId {
4708 let mut backends = self.backends.borrow_mut();
4709 let mut selector = RegistrySelector {
4710 backends: &mut backends,
4711 registry: &mut self.registry,
4712 now: self.now,
4713 exclude: &[],
4714 };
4715 let backend = self
4716 .router
4717 .backend_from_request(CLUSTER, should_stick, context, &mut selector)
4718 .expect("a cluster with two available backends must select");
4719 assert_eq!(
4720 context.backend_id.as_deref(),
4721 Some(&*backend.backend_id),
4722 "the request must be stamped with the backend it was reserved on"
4723 );
4724 backend
4725 }
4726
4727 fn backend(&self, id: &BackendId) -> std::cell::Ref<'_, Backend> {
4728 self.registry
4729 .handle(id)
4730 .expect("an id this fixture minted must resolve")
4731 .borrow()
4732 }
4733
4734 fn active_connections(&self, backend: &BackendId) -> usize {
4735 self.backend(backend).active_connections
4736 }
4737 }
4738
4739 /// Two streams linking in one pass must spread under `LeastLoaded`, and
4740 /// no dial happens in between.
4741 ///
4742 /// The second selection reads `active_connections`, which only the first
4743 /// selection's reservation can have raised: nothing here connects, and no
4744 /// delta drain runs. So this holds only if selecting reserves under the
4745 /// same borrow as the choice (#1684). A selector that stopped reserving —
4746 /// counting the connection at the dial instead — would send both streams
4747 /// to `backend-a`.
4748 ///
4749 /// TO SEE THIS RED: make `Backend::reserve_connection` return `Ok(())`
4750 /// without calling `inc_connections`.
4751 #[test]
4752 fn a_second_selection_observes_the_first_selections_reservation() {
4753 let mut fixture = fixture();
4754
4755 let first = fixture.select(false, &mut request(None));
4756 let second = fixture.select(false, &mut request(None));
4757
4758 assert_eq!(
4759 &*first.backend_id, "backend-a",
4760 "with both backends idle the first minimum wins"
4761 );
4762 assert_ne!(
4763 first.backend_id,
4764 second.backend_id,
4765 "the second selection landed on the first one's backend: the first \
4766 selection did not reserve its connection \
4767 (first={} active={}, second={} active={})",
4768 first.backend_id,
4769 fixture.active_connections(&first),
4770 second.backend_id,
4771 fixture.active_connections(&second),
4772 );
4773 assert_eq!(
4774 (
4775 fixture.active_connections(&first),
4776 fixture.active_connections(&second)
4777 ),
4778 (1, 1),
4779 "each selection must reserve exactly one connection on the backend it chose"
4780 );
4781 }
4782
4783 /// #1684: power-of-two-choices weighs every reservation too.
4784 ///
4785 /// With two backends the sample is the whole set, so each selection must
4786 /// take the one with fewer reserved connections, and a tie resolves by
4787 /// coin flip. Over any run of selections the two counts therefore never
4788 /// drift more than one apart, and together they count every selection.
4789 ///
4790 /// TO SEE THIS RED: make `Backend::reserve_connection` return `Ok(())`
4791 /// without calling `inc_connections`; nothing is reserved and the total
4792 /// stays at zero.
4793 #[test]
4794 fn power_of_two_weighs_every_reservation() {
4795 const SELECTIONS: usize = 64;
4796 let mut fixture = fixture_with(LoadBalancingAlgorithms::PowerOfTwo);
4797 let first = fixture.select(false, &mut request(None));
4798 let mut ids = vec![first];
4799 for selections in 2..=SELECTIONS {
4800 let before: Vec<usize> = ids
4801 .iter()
4802 .map(|id| fixture.active_connections(id))
4803 .collect();
4804 let picked = fixture.select(false, &mut request(None));
4805 if !ids.iter().any(|id| id.backend_id == picked.backend_id) {
4806 ids.push(picked.clone());
4807 }
4808 let loads: Vec<usize> = ids
4809 .iter()
4810 .map(|id| fixture.active_connections(id))
4811 .collect();
4812 assert_eq!(
4813 loads.iter().sum::<usize>(),
4814 selections,
4815 "{selections} selections must hold {selections} reservations, got {loads:?}"
4816 );
4817 if ids.len() == 2 {
4818 let (low, high) = (loads[0].min(loads[1]), loads[0].max(loads[1]));
4819 assert!(
4820 high - low <= 1,
4821 "power-of-two let the reservations drift apart: {loads:?} (before {before:?})"
4822 );
4823 }
4824 }
4825 assert_eq!(ids.len(), 2, "both backends must be used");
4826 }
4827
4828 /// #1684: a dial whose `connect(2)` fails releases its reservation and
4829 /// records the failure, once the ledger is drained.
4830 ///
4831 /// TO SEE THIS RED: drop `self.dec_connections()` from
4832 /// `Backend::release_failed_dial`; the reservation then outlives the
4833 /// failed dial.
4834 #[test]
4835 fn a_failed_dial_releases_its_reservation_and_records_the_failure() {
4836 let mut fixture = fixture();
4837 let backend = fixture.select(false, &mut request(None));
4838 assert_eq!(
4839 fixture.active_connections(&backend),
4840 1,
4841 "selection reserves"
4842 );
4843
4844 let mut deltas = vec![BackendDelta {
4845 slot: backend.slot(),
4846 change: BackendChange::DialFailed(fixture.now),
4847 }];
4848 fixture.registry.apply_all(&mut deltas, &fixture.backends);
4849
4850 assert!(deltas.is_empty(), "the drain empties the ledger");
4851 let released = fixture.backend(&backend);
4852 assert_eq!(
4853 released.active_connections, 0,
4854 "a failed dial must release the reservation selection took"
4855 );
4856 assert_eq!(released.failures, 1, "a failed dial must count one failure");
4857 assert_eq!(
4858 released.retry_policy.can_try(fixture.now),
4859 Some(RetryAction::WAIT),
4860 "a failed dial must arm the backoff at the instant it failed"
4861 );
4862 }
4863
4864 /// A cookie pins the request only when its frontend sticks.
4865 ///
4866 /// The call this replaced matched on `(frontend_should_stick, cookie)`
4867 /// and sent `(false, Some(_))` to the load balancer; the selector now takes
4868 /// an [`super::Affinity`] built by the caller, so that arm is the one a
4869 /// signature change could flip without a compile error.
4870 #[test]
4871 fn a_cookie_pins_only_a_frontend_that_sticks() {
4872 let mut unpinned = fixture();
4873 let mut context = request(Some(STICKY_B));
4874 let backend = unpinned.select(false, &mut context);
4875 assert_eq!(
4876 &*backend.backend_id, "backend-a",
4877 "a frontend that does not stick must ignore the client's cookie"
4878 );
4879 assert_eq!(
4880 context.sticky_session, None,
4881 "a frontend that does not stick must not answer with a cookie"
4882 );
4883
4884 let mut pinned = fixture();
4885 let mut context = request(Some(STICKY_B));
4886 let backend = pinned.select(true, &mut context);
4887 assert_eq!(
4888 &*backend.backend_id, "backend-b",
4889 "a sticky frontend must follow the cookie to its backend"
4890 );
4891 assert_eq!(context.sticky_session.as_deref(), Some(STICKY_B));
4892 assert_eq!(
4893 pinned.active_connections(&backend),
4894 1,
4895 "a sticky selection reserves like any other"
4896 );
4897
4898 let mut fallback = fixture();
4899 let mut context = request(None);
4900 let backend = fallback.select(true, &mut context);
4901 assert_eq!(
4902 context.sticky_session.as_deref(),
4903 Some(&*backend.backend_id),
4904 "a sticky frontend with no cookie answers with the chosen \
4905 backend's id when it has no sticky id of its own"
4906 );
4907 }
4908
4909 /// [`fixture`] under `HRW`, the policy that reads the affinity key.
4910 fn hrw_fixture() -> Fixture {
4911 fixture_with(LoadBalancingAlgorithms::Hrw)
4912 }
4913
4914 /// A request whose `plan_connect` derived `key`.
4915 fn keyed_request(cookie: Option<&str>, key: u64) -> HttpContext {
4916 let mut context = request(cookie);
4917 context.affinity_key = Some(key);
4918 context
4919 }
4920
4921 /// #524: the key a request carries reaches the load balancer, so `HRW`
4922 /// sends one key to one backend selection after selection.
4923 ///
4924 /// TO SEE THIS RED: pass `None` instead of `context.affinity_key` to
4925 /// `BackendSelector::select` in `Router::backend_from_request`;
4926 /// `HRW` then round-robins and the second selection of each key lands on
4927 /// the other backend.
4928 #[test]
4929 fn a_keyed_request_stays_on_one_backend_under_hrw() {
4930 let mut fixture = hrw_fixture();
4931 for key in 0..16u64 {
4932 let first = fixture.select(false, &mut keyed_request(None, key));
4933 for _ in 0..3 {
4934 let again = fixture.select(false, &mut keyed_request(None, key));
4935 assert_eq!(
4936 again.backend_id, first.backend_id,
4937 "key {key} must stay on one backend under HRW"
4938 );
4939 }
4940 }
4941 }
4942
4943 /// #524: a sticky cookie naming a live backend wins over the key; the key
4944 /// only decides where a request the cookie did not pin lands.
4945 #[test]
4946 fn a_sticky_cookie_wins_over_the_affinity_key() {
4947 let mut fixture = hrw_fixture();
4948 let key = (0..64u64)
4949 .find(|&key| {
4950 &*fixture
4951 .select(false, &mut keyed_request(None, key))
4952 .backend_id
4953 == "backend-a"
4954 })
4955 .expect("HRW sends some of 64 keys to each of two backends");
4956
4957 let mut context = keyed_request(Some(STICKY_B), key);
4958 let backend = fixture.select(true, &mut context);
4959 assert_eq!(
4960 &*backend.backend_id, "backend-b",
4961 "the cookie must pin the request whatever its key says"
4962 );
4963
4964 let mut context = keyed_request(None, key);
4965 let backend = fixture.select(true, &mut context);
4966 assert_eq!(
4967 &*backend.backend_id, "backend-a",
4968 "without a cookie a sticky frontend follows the key"
4969 );
4970 }
4971
4972 /// #1579: stamping a selected backend onto the request allocates nothing.
4973 ///
4974 /// `Router::backend_from_request` runs once per backend dial and writes
4975 /// the chosen backend's id into `HttpContext::backend_id`. The id arrives
4976 /// as the `Rc<str>` of the selected `BackendId`, so the stamp is a
4977 /// reference-count increment. The selector is a stub that always answers
4978 /// one id, so the count covers the router's own work and not the load
4979 /// balancer. It needs no socket: a selector does not dial (#1684).
4980 ///
4981 /// TO SEE THIS RED: stamp `Some(selected.backend.backend_id.to_string().into())`
4982 /// instead of the `Rc` clone; the assertion then reports two allocations
4983 /// per dial.
4984 #[test]
4985 fn stamping_a_dialled_backend_allocates_nothing() {
4986 use std::hint::black_box;
4987
4988 use super::{Affinity, BackendSelector, SelectedBackend};
4989 use crate::{backends::BackendError, test_allocations::allocations};
4990
4991 const DIALS: usize = 64;
4992
4993 /// Always answers `backend`, minted as a simulator would mint it.
4994 struct StubSelector {
4995 backend: BackendId,
4996 }
4997
4998 impl BackendSelector for StubSelector {
4999 fn select(
5000 &mut self,
5001 _cluster_id: &str,
5002 _affinity: Affinity<'_>,
5003 _key: Option<u64>,
5004 ) -> Result<SelectedBackend, BackendError> {
5005 Ok(SelectedBackend {
5006 backend: self.backend.clone(),
5007 sticky_session: None,
5008 })
5009 }
5010 }
5011
5012 let mut selector = StubSelector {
5013 backend: BackendId::new(
5014 0,
5015 std::rc::Rc::from("stamped-backend"),
5016 SocketAddr::from(([127, 0, 0, 1], 9503)),
5017 ),
5018 };
5019 let mut router = Router::new(Duration::from_secs(10), Duration::from_secs(10));
5020 let mut context = request(None);
5021
5022 // Warm-up: the first stamp fills the slot the later ones overwrite.
5023 drop(
5024 router
5025 .backend_from_request(CLUSTER, false, &mut context, &mut selector)
5026 .expect("the stub selector always selects"),
5027 );
5028 let mut allocated = 0;
5029 for _ in 0..DIALS {
5030 let before = allocations();
5031 let selected = router.backend_from_request(
5032 black_box(CLUSTER),
5033 false,
5034 black_box(&mut context),
5035 &mut selector,
5036 );
5037 allocated += allocations() - before;
5038 drop(selected.expect("the stub selector always selects"));
5039 }
5040
5041 assert_eq!(context.backend_id.as_deref(), Some("stamped-backend"));
5042 assert_eq!(
5043 allocated, 0,
5044 "{DIALS} dials made {allocated} heap allocations stamping the \
5045 backend onto the request, expected none"
5046 );
5047 }
5048}
5049
5050/// [`affinity_key`]: which request bytes key `HRW` and `MAGLEV`, and that
5051/// deriving the key costs no allocation (#524).
5052#[cfg(test)]
5053mod affinity_key_tests {
5054 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
5055
5056 use sozu_command::proto::command::{Cluster, LoadBalancingAlgorithms};
5057
5058 use super::{StickyCookie, affinity_key};
5059 use crate::{
5060 load_balancing::{affinity_key_from_ip, affinity_key_from_value},
5061 pool::Pool,
5062 protocol::mux::GenericHttpStream,
5063 };
5064
5065 /// A listener sticky cookie the requests below do not carry.
5066 const NO_STICKY: StickyCookie<'static> = StickyCookie {
5067 name: "SOZUBALANCEID",
5068 value: None,
5069 };
5070
5071 const CLIENT: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)), 40_000);
5072
5073 /// A request kawa parsed from `request`, on its own pool buffer.
5074 fn parsed(pool: &mut Pool, request: &[u8]) -> GenericHttpStream {
5075 let mut kawa: GenericHttpStream = kawa::Kawa::new(
5076 kawa::Kind::Request,
5077 kawa::Buffer::new(
5078 pool.checkout()
5079 .expect("the test pool must hand out a buffer"),
5080 ),
5081 );
5082 kawa.storage.space()[..request.len()].copy_from_slice(request);
5083 kawa.storage.fill(request.len());
5084 kawa::h1::parse(&mut kawa, &mut kawa::h1::NoCallbacks);
5085 assert!(kawa.is_main_phase(), "premise: the request must parse");
5086 kawa
5087 }
5088
5089 fn cluster(
5090 policy: LoadBalancingAlgorithms,
5091 header: Option<&str>,
5092 cookie: Option<&str>,
5093 ) -> Cluster {
5094 Cluster {
5095 cluster_id: "affinity".to_owned(),
5096 load_balancing: policy as i32,
5097 affinity_header: header.map(ToOwned::to_owned),
5098 affinity_cookie: cookie.map(ToOwned::to_owned),
5099 ..Default::default()
5100 }
5101 }
5102
5103 const REQUEST: &[u8] = b"GET / HTTP/1.1\r\nHost: example.com\r\nx-tenant: acme\r\n\
5104 Cookie: theme=dark; tenant=globex\r\nX-Empty: \r\n\r\n";
5105
5106 /// Only `HRW`, `MAGLEV` and a sharded cluster read a key; every other
5107 /// cluster gets `None`, pays nothing and selects exactly as before.
5108 ///
5109 /// TO SEE THIS RED: drop the `shard_percent` arm of
5110 /// `cluster_reads_affinity_key`; the sharded round-robin cluster then
5111 /// derives no key and is silently not sharded.
5112 #[test]
5113 fn only_hrw_maglev_and_sharded_clusters_derive_a_key() {
5114 let mut pool = Pool::with_capacity(1, 1, 4096);
5115 let front = parsed(&mut pool, REQUEST);
5116 for policy in [
5117 LoadBalancingAlgorithms::RoundRobin,
5118 LoadBalancingAlgorithms::Random,
5119 LoadBalancingAlgorithms::LeastLoaded,
5120 LoadBalancingAlgorithms::PowerOfTwo,
5121 ] {
5122 assert_eq!(
5123 affinity_key(
5124 &cluster(policy, Some("X-Tenant"), None),
5125 &front,
5126 Some(CLIENT),
5127 NO_STICKY
5128 ),
5129 None,
5130 "{policy:?} does not read a key"
5131 );
5132 }
5133 // #524: a sharded cluster reads the key under any policy, to rank
5134 // the client's shard by.
5135 let sharded_round_robin = Cluster {
5136 shard_percent: Some(25),
5137 ..cluster(LoadBalancingAlgorithms::RoundRobin, None, None)
5138 };
5139 assert_eq!(
5140 affinity_key(&sharded_round_robin, &front, Some(CLIENT), NO_STICKY),
5141 Some(affinity_key_from_ip(CLIENT.ip())),
5142 "a sharded cluster derives a key whatever its policy"
5143 );
5144 for policy in [
5145 LoadBalancingAlgorithms::Hrw,
5146 LoadBalancingAlgorithms::Maglev,
5147 ] {
5148 assert_eq!(
5149 affinity_key(
5150 &cluster(policy, None, None),
5151 &front,
5152 Some(CLIENT),
5153 NO_STICKY
5154 ),
5155 Some(affinity_key_from_ip(CLIENT.ip())),
5156 "{policy:?} keys on the source IP by default"
5157 );
5158 }
5159 }
5160
5161 /// The configured header keys the request, its name matched without
5162 /// regard to case; a header the request lacks, or carries empty, falls
5163 /// back to the source IP; with no source address either there is no key.
5164 #[test]
5165 fn a_configured_header_keys_the_request_and_the_source_ip_is_the_fallback() {
5166 let mut pool = Pool::with_capacity(1, 1, 4096);
5167 let front = parsed(&mut pool, REQUEST);
5168 let hrw = LoadBalancingAlgorithms::Hrw;
5169 assert_eq!(
5170 affinity_key(
5171 &cluster(hrw, Some("X-Tenant"), None),
5172 &front,
5173 Some(CLIENT),
5174 NO_STICKY
5175 ),
5176 Some(affinity_key_from_value(b"acme"))
5177 );
5178 assert_eq!(
5179 affinity_key(
5180 &cluster(hrw, Some("X-Absent"), None),
5181 &front,
5182 Some(CLIENT),
5183 NO_STICKY
5184 ),
5185 Some(affinity_key_from_ip(CLIENT.ip()))
5186 );
5187 assert_eq!(
5188 affinity_key(
5189 &cluster(hrw, Some("X-Empty"), None),
5190 &front,
5191 Some(CLIENT),
5192 NO_STICKY
5193 ),
5194 Some(affinity_key_from_ip(CLIENT.ip())),
5195 "an empty value keys nothing and falls back to the source IP"
5196 );
5197 assert_eq!(
5198 affinity_key(
5199 &cluster(hrw, Some("X-Absent"), None),
5200 &front,
5201 None,
5202 NO_STICKY
5203 ),
5204 None,
5205 "no header and no source address: no key, and the policy round-robins"
5206 );
5207 }
5208
5209 /// The configured cookie keys the request, its name matched exactly
5210 /// (RFC 6265 cookie names are case-sensitive).
5211 #[test]
5212 fn a_configured_cookie_keys_the_request() {
5213 let mut pool = Pool::with_capacity(1, 1, 4096);
5214 let front = parsed(&mut pool, REQUEST);
5215 let maglev = LoadBalancingAlgorithms::Maglev;
5216 assert_eq!(
5217 affinity_key(
5218 &cluster(maglev, None, Some("tenant")),
5219 &front,
5220 Some(CLIENT),
5221 NO_STICKY
5222 ),
5223 Some(affinity_key_from_value(b"globex"))
5224 );
5225 assert_eq!(
5226 affinity_key(
5227 &cluster(maglev, None, Some("Tenant")),
5228 &front,
5229 Some(CLIENT),
5230 NO_STICKY
5231 ),
5232 Some(affinity_key_from_ip(CLIENT.ip())),
5233 "a cookie name differing in case is another cookie"
5234 );
5235 }
5236
5237 /// #524: deriving the key allocates nothing: the header blocks and the
5238 /// cookie jar are read where they lie and hashed in place.
5239 ///
5240 /// TO SEE THIS RED: copy the value out before hashing it in
5241 /// `affinity_key`, e.g. `affinity_key_from_value(&value.to_vec())`; each
5242 /// derivation then makes one allocation.
5243 #[test]
5244 fn deriving_an_affinity_key_allocates_nothing() {
5245 use std::hint::black_box;
5246
5247 use crate::test_allocations::allocations;
5248
5249 const DERIVATIONS: usize = 64;
5250 let mut pool = Pool::with_capacity(1, 1, 4096);
5251 let front = parsed(&mut pool, REQUEST);
5252 let clusters = [
5253 cluster(LoadBalancingAlgorithms::Hrw, None, None),
5254 cluster(LoadBalancingAlgorithms::Hrw, Some("X-Tenant"), None),
5255 cluster(LoadBalancingAlgorithms::Maglev, None, Some("tenant")),
5256 cluster(LoadBalancingAlgorithms::Maglev, Some("X-Absent"), None),
5257 ];
5258 let mut allocated = 0;
5259 for cluster in &clusters {
5260 for _ in 0..DERIVATIONS {
5261 let before = allocations();
5262 let key = affinity_key(
5263 black_box(cluster),
5264 black_box(&front),
5265 Some(CLIENT),
5266 NO_STICKY,
5267 );
5268 allocated += allocations() - before;
5269 assert!(key.is_some());
5270 }
5271 }
5272 assert_eq!(
5273 allocated,
5274 0,
5275 "{} derivations made {allocated} heap allocations, expected none",
5276 DERIVATIONS * clusters.len()
5277 );
5278 }
5279
5280 /// A cluster keying on the listener's own sticky-session cookie reads the
5281 /// value captured before that cookie was elided from the jar; without the
5282 /// capture it would never find the cookie and always fall back to the
5283 /// source IP.
5284 ///
5285 /// TO SEE THIS RED: drop the sticky-name arm of `affinity_key`, so the
5286 /// lookup reads only the jar.
5287 #[test]
5288 fn an_affinity_cookie_named_like_the_sticky_cookie_reads_its_captured_value() {
5289 let mut pool = Pool::with_capacity(1, 1, 4096);
5290 // The sticky cookie is already gone from the jar, as
5291 // `on_request_headers` leaves it.
5292 let front = parsed(&mut pool, REQUEST);
5293 let hrw = cluster(LoadBalancingAlgorithms::Hrw, None, Some("SOZUBALANCEID"));
5294 assert_eq!(
5295 affinity_key(
5296 &hrw,
5297 &front,
5298 Some(CLIENT),
5299 StickyCookie {
5300 name: "SOZUBALANCEID",
5301 value: Some("backend-7"),
5302 },
5303 ),
5304 Some(affinity_key_from_value(b"backend-7"))
5305 );
5306 assert_eq!(
5307 affinity_key(&hrw, &front, Some(CLIENT), NO_STICKY),
5308 Some(affinity_key_from_ip(CLIENT.ip())),
5309 "no sticky cookie on the request: the source IP keys it"
5310 );
5311 }
5312}