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