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
8use std::{cell::RefCell, collections::HashMap, rc::Rc, time::Duration};
9
10use mio::{Interest, Token, net::TcpStream};
11use sozu_command::{
12 logging::ansi_palette,
13 proto::command::{ListenerType, RedirectPolicy, RedirectScheme},
14};
15
16#[cfg(debug_assertions)]
17use super::DebugEvent;
18use super::{BackendStatus, Connection, Context, GlobalStreamId, Position, StreamState};
19use crate::{
20 BackendConnectionError, L7ListenerHandler, L7Proxy, ListenerHandler, ProxySession, Readiness,
21 RetrieveClusterError,
22 backends::{Backend, BackendError},
23 protocol::http::editor::{HeaderEditMode, HeaderEditSnapshot, HttpContext},
24 router::{HeaderEdit, RouteResult},
25 server::CONN_RETRIES,
26 socket::SessionTcpStream,
27 timer::TimeoutContainer,
28};
29
30use crate::metrics::names;
31
32/// Module-level prefix used on every log line emitted from the router.
33///
34/// Two arms:
35/// * `log_module_context!()` — zero-arg, legacy `MUX-ROUTER\t >>>` output.
36/// Kept for sites without an `HttpContext` in scope. No call site in this
37/// module currently uses this arm (every one has an `HttpContext` reachable
38/// via [`Context::http_context`] or a direct `&mut HttpContext`
39/// parameter), but the arm is retained so the macro name stays stable for
40/// future sessionless callers.
41/// * `log_module_context!($http_context)` — rich form. `$http_context` must be
42/// `&HttpContext` (or coerce to one). Produces the same
43/// `[session req cluster backend]` bracket as RUSTLS/PIPE/TCP followed by a
44/// `Session(frontend=..., method=..., authority_bytes=...)` block, so router
45/// lines are filterable by session ULID or request ULID. `cluster_id` is
46/// already carried by the bracket's third slot — not duplicated inside
47/// `Session(...)`.
48macro_rules! log_module_context {
49 () => {{
50 let (open, reset, _, _, _) = ansi_palette();
51 format!("{open}MUX-ROUTER{reset}\t >>>", open = open, reset = reset)
52 }};
53 ($http_context:expr) => {{
54 let (open, reset, grey, gray, white) = ansi_palette();
55 let http_ctx: &HttpContext = &$http_context;
56 let ctx = http_ctx.log_context();
57 format!(
58 "{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 >>>",
59 open = open,
60 reset = reset,
61 grey = grey,
62 gray = gray,
63 white = white,
64 ctx = ctx,
65 frontend = http_ctx.session_address,
66 method = http_ctx.method,
67 authority_bytes = http_ctx.authority.as_ref().map(String::len),
68 )
69 }};
70}
71
72fn log_coalescing_accepted(context: &HttpContext, authority: &str, sni: &str, matched_name: &str) {
73 debug!(
74 "{} accepted coalesced authority (authority_bytes={}, sni_bytes={}, matched_san_kind={}, matched_san_bytes={})",
75 log_module_context!(context),
76 authority.len(),
77 sni.len(),
78 if matched_name.starts_with("*.") {
79 "wildcard"
80 } else {
81 "exact"
82 },
83 matched_name.len(),
84 );
85}
86
87fn log_sni_authority_mismatch(
88 context: &HttpContext,
89 authority: &str,
90 sni: &str,
91 certificate_names: Option<&[String]>,
92) {
93 let certificate_sans_count = certificate_names.map_or(0, <[String]>::len);
94 let certificate_sans_bytes = certificate_names
95 .into_iter()
96 .flatten()
97 .map(String::len)
98 .fold(0usize, usize::saturating_add);
99 warn!(
100 "{} rejecting request: TLS cert SANs do not cover authority (authority_bytes={}, sni_bytes={}, certificate_sans_count={}, certificate_sans_bytes={})",
101 log_module_context!(context),
102 authority.len(),
103 sni.len(),
104 certificate_sans_count,
105 certificate_sans_bytes,
106 );
107}
108
109#[derive(Debug)]
110pub struct Router {
111 pub backends: HashMap<Token, Connection<SessionTcpStream>>,
112 pub configured_backend_timeout: Duration,
113 pub configured_connect_timeout: Duration,
114 /// Fallback readiness used when a backend token is missing from the map.
115 /// This prevents panicking in the Endpoint trait methods that return references.
116 pub(super) fallback_readiness: Readiness,
117}
118
119impl Router {
120 pub fn new(configured_backend_timeout: Duration, configured_connect_timeout: Duration) -> Self {
121 Self {
122 backends: HashMap::new(),
123 configured_backend_timeout,
124 configured_connect_timeout,
125 fallback_readiness: Readiness::new(),
126 }
127 }
128
129 pub(super) fn connect<L: ListenerHandler + L7ListenerHandler>(
130 &mut self,
131 stream_id: GlobalStreamId,
132 context: &mut Context<L>,
133 session: Rc<RefCell<dyn ProxySession>>,
134 proxy: Rc<RefCell<dyn L7Proxy>>,
135 // Frontend session token, threaded in from `Mux::ready` so the
136 // per-(cluster, source-IP) accounting can key on it without
137 // re-borrowing `session` — the outer event-loop call chain
138 // already holds a mutable borrow of that cell.
139 frontend_token: Token,
140 ) -> Result<(), BackendConnectionError> {
141 let stream = &mut context.streams[stream_id];
142 // when reused, a stream should be detached from its old connection, if not we could end
143 // with concurrent connections on a single endpoint
144 if !matches!(stream.state, StreamState::Link) {
145 error!(
146 "{} stream {} expected to be in Link state, got {:?}",
147 log_module_context!(stream.context),
148 stream_id,
149 stream.state
150 );
151 return Err(BackendConnectionError::MaxSessionsMemory);
152 }
153 #[cfg(debug_assertions)]
154 context
155 .debug
156 .push(DebugEvent::Str(stream.context.get_route()));
157 if stream.attempts >= CONN_RETRIES {
158 incr!(
159 "backend.connect.retries_exhausted",
160 stream.context.cluster_id.as_deref(),
161 stream.context.backend_id.as_deref()
162 );
163 return Err(BackendConnectionError::MaxConnectionRetries(
164 stream.context.cluster_id.clone(),
165 ));
166 }
167 stream.attempts += 1;
168
169 // Borrow front mutably (so route_from_request can rewrite the request
170 // line authority/path and inject request-side header edits before we
171 // forward to the backend) plus context mutably (so it can stash
172 // redirect_location / www_authenticate / original_authority /
173 // headers_response). We split-borrow manually to keep the rest of
174 // `connect` working with `stream_context` aliasing `stream.context`.
175 let (front_ref, stream_context_ref) = {
176 let stream_split = &mut *stream;
177 (&mut stream_split.front, &mut stream_split.context)
178 };
179 let cluster_id = self
180 .route_from_request(stream_context_ref, front_ref, &context.listener, &proxy)
181 .map_err(BackendConnectionError::RetrieveClusterError)?;
182 let stream_context = &mut stream.context;
183 stream_context.cluster_id = Some(cluster_id.to_owned());
184
185 let (
186 frontend_should_stick,
187 frontend_should_redirect_https,
188 h2,
189 cluster_max_connections_per_ip,
190 cluster_retry_after,
191 ) = proxy
192 .borrow()
193 .clusters()
194 .get(&cluster_id)
195 .map(|cluster| {
196 (
197 cluster.sticky_session,
198 cluster.https_redirect,
199 cluster.http2.unwrap_or(false),
200 cluster.max_connections_per_ip,
201 cluster.retry_after,
202 )
203 })
204 .unwrap_or((false, false, false, None, None));
205
206 // ── Legacy `cluster.https_redirect` short-circuit ──
207 //
208 // Resolve the legacy HTTP→HTTPS redirect BEFORE per-(cluster,
209 // source-IP) accounting so a redirect-only request never
210 // consumes an IP slot. Otherwise a same-IP client iterating an
211 // HTTP→HTTPS hop could trip 429 ahead of the 301 even though no
212 // backend would have been opened. A duplicate guard that lived
213 // here previously (rebase artefact — two identical
214 // `if frontend_should_redirect_https && …` blocks back-to-back)
215 // is folded into this single early-return.
216 // Frontend-scoped `RedirectPolicy::PERMANENT` already returns
217 // from `route_from_request` with the same error, so this only
218 // handles the legacy cluster-level path that doesn't surface
219 // from `route_from_request`.
220 if frontend_should_redirect_https && matches!(proxy.borrow().kind(), ListenerType::Http) {
221 return Err(BackendConnectionError::RetrieveClusterError(
222 RetrieveClusterError::HttpsRedirect,
223 ));
224 }
225
226 // Per-(cluster, source-IP) connection limit gate. Runs AFTER cluster
227 // resolution AND legacy redirect emission (so a 401/421/redirect
228 // frontend never trips the limit) and BEFORE any backend selection
229 // (so a rejection consumes neither a backend pool slot nor a retry
230 // budget). The check uses the source IP from the per-stream
231 // `HttpContext.session_address`, which is the proxy-protocol-aware
232 // client address when present, falling back to `peer_addr`. The
233 // limit governs distinct **frontend connections** per
234 // `(cluster, ip)`: an H2 session multiplexing N streams to the same
235 // cluster from the same IP still consumes a single slot.
236 let session_ip = stream_context.session_address.map(|sa| sa.ip());
237 if let Some(ip) = session_ip {
238 // The frontend session is mutably borrowed up the call stack
239 // (`HttpSession::ready` -> `state.ready` -> `Mux::ready` ->
240 // here), so we cannot reach `session.borrow().frontend_token()`.
241 // The token is threaded in by the caller instead.
242 let sessions_rc = proxy.borrow().sessions();
243 let at_limit = sessions_rc.borrow().cluster_ip_at_limit(
244 frontend_token,
245 &cluster_id,
246 &ip,
247 cluster_max_connections_per_ip,
248 );
249 if at_limit {
250 let retry_after = sessions_rc
251 .borrow()
252 .effective_retry_after(cluster_retry_after);
253 // Stash the resolved retry value on the stream so the
254 // mux's BackendConnectionError → 429 mapping can render
255 // (or elide) the `Retry-After` header without
256 // re-deriving the override chain.
257 stream_context.retry_after_seconds = Some(retry_after).filter(|v| *v > 0);
258 return Err(BackendConnectionError::TooManyConnectionsPerIp {
259 cluster_id: cluster_id.to_owned(),
260 });
261 }
262 // Idempotent track — H2 streams to the same `(cluster, ip)`
263 // share a single slot in the per-token set. Decrement happens
264 // wholesale on session close via `untrack_all_cluster_ip`.
265 sessions_rc
266 .borrow_mut()
267 .track_cluster_ip(frontend_token, cluster_id.clone(), ip);
268 }
269
270 /*
271 H2 connecting strategy (least-loaded):
272 - look at every backend connection
273 - among connected backends for this cluster, pick the one with the fewest active streams
274 - fall back to a connecting backend if no connected one exists
275 - if no backend is to reuse, ask the router for a socket to the "next in line" backend
276
277 H1 strategy: reuse the first KeepAlive backend for this cluster.
278 */
279
280 let mut reuse_token = None;
281 let mut best_h2_stream_count = usize::MAX;
282 for (token, backend) in &self.backends {
283 match (h2, backend.position()) {
284 (_, Position::Server) => {
285 error!(
286 "{} Backend connection unexpectedly behaves like a server",
287 log_module_context!(stream_context)
288 );
289 continue;
290 }
291 (_, Position::Client(_, _, BackendStatus::Disconnecting)) => {}
292
293 (true, Position::Client(other_cluster_id, _, BackendStatus::Connected)) => {
294 if *other_cluster_id == cluster_id && !backend.is_draining() {
295 // Pick the non-draining H2 connection with the fewest active streams
296 let Connection::H2(h2c) = backend else {
297 continue;
298 };
299 let stream_count = h2c.streams.len();
300 if stream_count
301 >= h2c.peer_settings.settings_max_concurrent_streams as usize
302 {
303 continue;
304 }
305 if stream_count < best_h2_stream_count {
306 best_h2_stream_count = stream_count;
307 reuse_token = Some(*token);
308 }
309 }
310 }
311 (true, Position::Client(other_cluster_id, _, BackendStatus::Connecting(_))) => {
312 // Only use a connecting backend if no connected one was found
313 if *other_cluster_id == cluster_id
314 && best_h2_stream_count == usize::MAX
315 && matches!(backend, Connection::H2(_))
316 {
317 reuse_token = Some(*token)
318 }
319 }
320 (true, Position::Client(other_cluster_id, _, BackendStatus::KeepAlive)) => {
321 if *other_cluster_id == cluster_id && matches!(backend, Connection::H2(_)) {
322 error!(
323 "{} ConnectionH2 unexpectedly behaves like H1 with KeepAlive",
324 log_module_context!(stream_context)
325 );
326 }
327 }
328
329 (false, Position::Client(old_cluster_id, _, BackendStatus::KeepAlive)) => {
330 if *old_cluster_id == cluster_id {
331 reuse_token = Some(*token);
332 break;
333 }
334 }
335 // can't bundle H1 streams together
336 (false, Position::Client(_, _, BackendStatus::Connected))
337 | (false, Position::Client(_, _, BackendStatus::Connecting(_))) => {}
338 }
339 }
340 trace!(
341 "{} connect: (stick={}, h2={}) -> (reuse={:?})",
342 log_module_context!(stream_context),
343 frontend_should_stick,
344 h2,
345 reuse_token
346 );
347
348 if let Some(token) = reuse_token {
349 // Pool reuse: an existing backend connection (H2 multiplex slot or
350 // H1 keep-alive socket) is being reattached to this stream. Pair
351 // with `backend.pool.miss` below — together they describe the
352 // pool's hit/miss ratio. Counted before any commit so the metric
353 // is consistent with the trace log.
354 incr!(names::backend::POOL_HIT);
355 trace!(
356 "{} reused backend: {:#?}",
357 log_module_context!(stream_context),
358 self.backends.get(&token)
359 );
360 // Link backend to stream for the reused connection path. We check
361 // that the backend can accept a new stream before committing any
362 // per-stream state.
363 let Some(backend_conn) = self.backends.get_mut(&token) else {
364 error!(
365 "{} reused backend token {:?} missing from backends map",
366 log_module_context!(stream_context),
367 token
368 );
369 return Err(BackendConnectionError::MaxSessionsMemory);
370 };
371 if !backend_conn.start_stream(stream_id, context) {
372 // Use `context.http_context(stream_id)` instead of reusing
373 // `stream_context`: `start_stream` above takes `&mut
374 // context`, which reborrows the slab mutably and ends any
375 // outstanding `stream_context` reference. A fresh shared
376 // borrow via the accessor is borrow-check clean.
377 error!(
378 "{} Backend rejected stream start (max concurrent streams reached)",
379 log_module_context!(context.http_context(stream_id))
380 );
381 return Err(BackendConnectionError::MaxSessionsMemory);
382 }
383 // For reused backends: set context fields and metrics lifecycle
384 if let Some(backend_conn) = self.backends.get(&token)
385 && let Position::Client(_, backend_ref, _) = backend_conn.position()
386 {
387 let backend = backend_ref.borrow();
388 let stream = &mut context.streams[stream_id];
389 stream.context.backend_id = Some(backend.backend_id.to_owned());
390 stream.context.backend_address = Some(backend.address);
391 stream.metrics.backend_id = Some(backend.backend_id.to_owned());
392 stream.metrics.backend_start();
393 stream.metrics.backend_connected();
394 }
395 context.link_stream(stream_id, token);
396 return Ok(());
397 }
398
399 // New-backend path: fall through.
400 //
401 // Pool miss: no reusable connection was found (no live H2 multiplex
402 // slot for this cluster, no H1 keep-alive socket). A fresh TCP dial
403 // and full backend handshake will follow. Pair with `backend.pool.hit`
404 // above. The metric is incremented BEFORE `backend_from_request` so
405 // the count includes attempts that fail at backend selection
406 // (BackendError::NoBackendForCluster, etc.) — every miss is a slot
407 // we did not save. The dial itself may still fail
408 // (BackendConnectionError::*), in which case `backend.pool.size` is
409 // never bumped (see the gauge below) but the miss is already counted.
410 incr!(names::backend::POOL_MISS);
411 let token = {
412 //
413 // SECURITY (CWE-400): defer every stateful side-effect
414 // (backend.connections / connections_per_backend gauges, slab
415 // add_session, mio register_socket, self.backends.insert,
416 // stream.metrics.backend_start) until AFTER `new_h2_client` AND
417 // `start_stream` have both succeeded. If either fails we must
418 // return Err without leaking a slab entry, an epoll registration,
419 // a gauge counter, or a router-map entry.
420 //
421 // The TcpStream lives on the stack here and is moved into the
422 // Connection by `new_h2_client`/`new_h1_client`; on failure the
423 // Connection (or the raw TcpStream, for the pool-exhaustion
424 // branch that drops inside `new_h2_client`) is dropped, closing
425 // the fd. No token is ever allocated, so there is nothing to
426 // roll back.
427 let (socket, backend) = self.backend_from_request(
428 &cluster_id,
429 frontend_should_stick,
430 stream_context,
431 proxy.clone(),
432 &context.listener,
433 )?;
434
435 if let Err(e) = socket.set_nodelay(true) {
436 error!(
437 "{} error setting nodelay on back socket({:?}): {:?}",
438 log_module_context!(context.http_context(stream_id)),
439 socket,
440 e
441 );
442 }
443
444 // Cache the backend's configured address so SOCKET log lines
445 // fired on ECONNREFUSED (or any failed async `connect()`) can
446 // still render `peer=<backend>` — `getpeername(2)` returns
447 // ENOTCONN in that state, so the live lookup path would show
448 // `peer=None` exactly when the operator needs the backend id.
449 let backend_peer = Some(backend.borrow().address);
450 let socket = SessionTcpStream::new(socket, context.session_ulid, backend_peer);
451
452 // Build an un-armed timeout: we can't call `TimeoutContainer::new`
453 // yet because that requires the slab token, and we only allocate
454 // the token on the happy path. `.set(token)` below arms it.
455 let timeout_container = TimeoutContainer::new_empty(self.configured_connect_timeout);
456 let flood_config = context.listener.borrow().get_h2_flood_config();
457 let connection_config = context.listener.borrow().get_h2_connection_config();
458 let stream_idle_timeout = context.listener.borrow().get_h2_stream_idle_timeout();
459 let graceful_shutdown_deadline = context
460 .listener
461 .borrow()
462 .get_h2_graceful_shutdown_deadline();
463 let backend_id_for_gauge = backend.borrow().backend_id.to_owned();
464 let mut connection = if h2 {
465 match Connection::new_h2_client(
466 context.session_ulid,
467 socket,
468 cluster_id.to_owned(),
469 backend,
470 context.pool.clone(),
471 timeout_container,
472 flood_config,
473 connection_config,
474 stream_idle_timeout,
475 graceful_shutdown_deadline,
476 ) {
477 Some(connection) => connection,
478 // pool exhaustion: socket already dropped by new_h2_client,
479 // no side-effects were committed.
480 None => return Err(BackendConnectionError::MaxBuffers),
481 }
482 } else {
483 Connection::new_h1_client(
484 context.session_ulid,
485 socket,
486 cluster_id.to_owned(),
487 backend,
488 timeout_container,
489 )
490 };
491
492 // Check the backend can accept a new stream BEFORE committing any
493 // registry state. `start_stream` increments `active_requests` via
494 // `pre_start_stream_client_bookkeeping` and undoes it itself on
495 // failure (see `Connection::start_stream`), so dropping the
496 // connection on a false return leaves backend accounting clean.
497 if !connection.start_stream(stream_id, context) {
498 error!(
499 "{} Backend rejected stream start (max concurrent streams reached)",
500 log_module_context!(context.http_context(stream_id))
501 );
502 // `connection` (socket + timeout_container) drops here.
503 return Err(BackendConnectionError::MaxSessionsMemory);
504 }
505
506 // --- Happy path: commit side-effects in one atomic-ish block ---
507 let stream = &mut context.streams[stream_id];
508 stream.metrics.backend_start();
509 stream.metrics.backend_id = stream.context.backend_id.to_owned();
510 gauge_add!(names::backend::CONNECTIONS, 1);
511 // `backend.pool.size` mirrors `backend.connections` exactly: one
512 // entry per `Router::backends` token. The `-1` partner lives in
513 // `connection.rs::pre_close_client_bookkeeping` (graceful close)
514 // and `mod.rs::close_backend` (session teardown). Symmetric
515 // pairing with both decrement sites is the only defence against
516 // the gauge underflow class of bug fixed by a650ad69 / d2f01ed4.
517 gauge_add!(names::backend::POOL_SIZE, 1);
518 gauge_add!(
519 names::backend::CONNECTIONS_PER_BACKEND,
520 1,
521 Some(&cluster_id),
522 Some(&backend_id_for_gauge)
523 );
524
525 let token = proxy.borrow().add_session(session);
526
527 {
528 let socket_ref = connection.socket_mut();
529 if let Err(e) = proxy.borrow().register_socket(
530 socket_ref,
531 token,
532 Interest::READABLE | Interest::WRITABLE,
533 ) {
534 // SECURITY (CWE-400): treat mio registration failure as a
535 // hard connect failure. Without this rollback the gauges
536 // (`backend.connections`, `backend.pool.size`,
537 // `connections_per_backend`), the slab session, and the
538 // already-incremented `Backend.active_requests` counter
539 // (bumped in `Connection::start_stream` ->
540 // `pre_start_stream_client_bookkeeping`) all leak until
541 // the connect timeout fires. Under fd pressure
542 // (EMFILE/ENFILE) this can occur in tight bursts and
543 // poison capacity dashboards.
544 error!(
545 "{} error registering back socket: {:?} — rolling back",
546 log_module_context!(context.http_context(stream_id)),
547 e
548 );
549 // Undo the gauge increments committed above.
550 gauge_add!(names::backend::CONNECTIONS, -1);
551 gauge_add!(names::backend::POOL_SIZE, -1);
552 gauge_add!(
553 names::backend::CONNECTIONS_PER_BACKEND,
554 -1,
555 Some(&cluster_id),
556 Some(&backend_id_for_gauge)
557 );
558 // Drop the slab session and the connection. The connection
559 // is local to this scope; dropping it here also closes the
560 // underlying TcpStream and releases the
561 // `Backend.active_requests` increment via the regular
562 // session drop path (`pre_close_client_bookkeeping`).
563 proxy.borrow().remove_session(token);
564 return Err(BackendConnectionError::MaxSessionsMemory);
565 }
566 }
567
568 // Arm the connect timeout now that we own a real token.
569 connection.timeout_container().set(token);
570
571 self.backends.insert(token, connection);
572 token
573 };
574
575 context.link_stream(stream_id, token);
576 Ok(())
577 }
578
579 fn route_from_request<L: ListenerHandler + L7ListenerHandler>(
580 &mut self,
581 context: &mut HttpContext,
582 front: &mut super::GenericHttpStream,
583 listener: &Rc<RefCell<L>>,
584 proxy: &Rc<RefCell<dyn L7Proxy>>,
585 ) -> Result<String, RetrieveClusterError> {
586 let (host, uri, method) = match context.extract_route() {
587 Ok(tuple) => tuple,
588 Err(cluster_error) => {
589 // we are past kawa parsing if it succeeded this can't fail
590 // if the request was malformed it was caught by kawa and we sent a 400
591 error!(
592 "{} Malformed request in connect (should be caught at parsing) {:?}: {}",
593 log_module_context!(context),
594 context,
595 cluster_error
596 );
597 return Err(cluster_error);
598 }
599 };
600 // Snapshot the pre-rewrite authority into an owned string so we
601 // can later stash it on `context.original_authority` without
602 // mutably aliasing the immutable borrow that `host: &str` still
603 // holds on `context`.
604 let captured_authority = host.to_owned();
605
606 // ── TLS cert SAN ↔ HTTP :authority binding ────────────────────────
607 // Reject any request whose `:authority` is not covered by a SAN of
608 // the certificate Sōzu actually served at the TLS handshake, with
609 // RFC 6125 §6.4.3 wildcard handling. Without this binding, an
610 // attacker holding a valid certificate for tenant A could open TLS
611 // with SNI=A then send an H2 stream with `:authority=tenantB.…` and
612 // reach tenant B's backend, crossing the TLS trust boundary
613 // (CWE-346 / CWE-444). The H2 spec explicitly allows browsers to
614 // coalesce streams onto a connection whenever the server is
615 // authoritative for the new origin (RFC 7540 §9.1.1 / RFC 9113
616 // §9.1.1), which "authoritative" means "covered by a SAN of the
617 // served cert"; rejecting coalesced streams as 421 caused the
618 // user-visible bug this predicate fixes (RFC 9110 §15.5.20).
619 //
620 // Plaintext listeners bypass the check (SNI is always `None`).
621 // Connections where SNI was sent but no cert matched (rustls served
622 // the default cert) carry `Some(empty)` SAN snapshot, so every
623 // authority is rejected — Sōzu is not authoritative for any name.
624 // Connections with no SNI fall back to the legacy exact-SNI match
625 // predicate (`authority_matches_sni`) for parity with pre-fix
626 // behaviour on the pathological "no SNI" case.
627 // Operators may opt out per-listener via
628 // `HttpsListenerConfig::strict_sni_binding = false`.
629 if let Some(sni) = context
630 .tls_server_name
631 .as_deref()
632 .filter(|_| context.strict_sni_binding)
633 {
634 let matched: Option<&str> = match context.tls_cert_names.as_deref() {
635 Some(cert_names) => authority_matched_cert_name(host, cert_names),
636 None => {
637 if authority_matches_sni(host, sni) {
638 Some(sni)
639 } else {
640 None
641 }
642 }
643 };
644 match matched {
645 Some(matched_name) => {
646 // Real coalescing = matched SAN differs from the SNI's
647 // value after the matcher's port-strip + ASCII case
648 // folding. Same-name requests are the common
649 // non-coalesced path; do not pollute the counter or
650 // logs with them. The ALPN=`h2` gate is a defensive
651 // guard, not load-bearing under current invariants —
652 // every request reaching `route_from_request` on an
653 // HTTPS listener with `tls_cert_names` populated has
654 // already gone through the H2 mux (ALPN=h2 by
655 // construction). Kept explicit so a future routing
656 // refactor that funnels H1 keep-alive through the
657 // same predicate doesn't silently double-count
658 // sequential `Host:` reuse as "coalescing".
659 if !authority_matches_sni(host, sni) && context.tls_alpn == Some("h2") {
660 incr!(names::h2::COALESCING_ACCEPTED);
661 log_coalescing_accepted(context, host, sni, matched_name);
662 }
663 }
664 None => {
665 incr!(names::http::SNI_AUTHORITY_MISMATCH);
666 log_sni_authority_mismatch(
667 context,
668 host,
669 sni,
670 context.tls_cert_names.as_deref().map(Vec::as_slice),
671 );
672 return Err(RetrieveClusterError::SniAuthorityMismatch {
673 sni: sni.to_owned(),
674 authority: host.to_owned(),
675 });
676 }
677 }
678 }
679
680 let route_result = listener.borrow().frontend_from_request(host, uri, method);
681
682 let route = match route_result {
683 Ok(route) => route,
684 Err(frontend_error) => {
685 trace!("{} {}", log_module_context!(context), frontend_error);
686 return Err(RetrieveClusterError::RetrieveFrontend(frontend_error));
687 }
688 };
689
690 // Stash the pre-rewrite authority unconditionally so log lines,
691 // access logs, and audit records that fire on ANY downstream
692 // path (denial, redirect, basic-auth 401, backend-connect
693 // failure, successful forward) carry the value the client
694 // actually sent. Capturing inside the rewrite helper alone would
695 // lose it on every branch where the rewrite is not applied.
696 context.original_authority = Some(captured_authority);
697
698 // ── Resolve the routing decision ──────────────────────────────────
699 // Snapshot the policy fields we need before consuming `route`, then
700 // map each policy outcome to either an early-error variant (which
701 // the caller turns into a default answer) or a cluster_id (which
702 // proceeds to backend connect).
703 let RouteResult {
704 cluster_id,
705 redirect,
706 redirect_scheme,
707 redirect_template,
708 rewritten_host,
709 rewritten_path,
710 rewritten_port,
711 headers_request,
712 headers_response,
713 required_auth: frontend_required_auth,
714 ..
715 } = route;
716
717 // ── HSTS (RFC 6797) snapshot hoist for HTTPS ──────────────────────
718 // The response snapshot is built in two passes so HSTS reaches
719 // every HTTPS response code (RFC 6797 §8.1 — including
720 // proxy-generated 3xx / 401 / 5xx default answers) WITHOUT
721 // changing the pre-PR scope of operator-defined `Append`
722 // response headers (which only apply on the regular forward
723 // path).
724 //
725 // Pass 1 (here, before any early return): for HTTPS only, copy
726 // ONLY the HSTS-class typed edits (`SetIfAbsent | Set`). These
727 // need to land on default answers — `set_default_answer_with_retry_after`
728 // bypasses the post-forward copy below.
729 //
730 // Pass 2 (post-forward, end of function): copy EVERY edit
731 // (including operator `Append` headers). Runs only on the
732 // regular forward path because the early returns short-circuit
733 // before reaching it.
734 //
735 // Plain-HTTP listeners are skipped here per RFC 6797 §7.2 (no
736 // STS over plaintext) — defense in depth on top of the
737 // TOML-time `ConfigError::HstsOnPlainHttp` and the worker IPC
738 // `ProxyError::HstsOnPlainHttp` rejects.
739 if matches!(context.protocol, crate::Protocol::HTTPS) {
740 snapshot_response_edits(&mut context.headers_response, &headers_response, |e| {
741 matches!(e.mode, HeaderEditMode::SetIfAbsent | HeaderEditMode::Set)
742 });
743 }
744
745 // Look up cluster-side policy knobs once. The values we need are:
746 // - `https_redirect` (legacy) and `https_redirect_port` for the 301 location URL
747 // - `authorized_hashes` and `www_authenticate` for the 401 path
748 let (legacy_https_redirect, https_redirect_port, authorized_hashes, www_authenticate) =
749 match cluster_id.as_deref() {
750 Some(id) => proxy
751 .borrow()
752 .clusters()
753 .get(id)
754 .map(|c| {
755 (
756 c.https_redirect,
757 c.https_redirect_port,
758 c.authorized_hashes.clone(),
759 c.www_authenticate.clone(),
760 )
761 })
762 .unwrap_or((false, None, Vec::new(), None)),
763 None => (false, None, Vec::new(), None),
764 };
765
766 // ── 1. Explicit redirect policies (PERMANENT / FOUND / PERMANENT_REDIRECT) ──
767 // Resolved BEFORE the clusterless-deny branch so a frontend that
768 // declares `redirect = permanent | found | permanent_redirect`
769 // emits the matching 3xx even when no cluster is bound. This is
770 // the canonical "moved" shape from the original proposal in
771 // #1161 and is the only way to express "this hostname has moved"
772 // without standing up a dummy cluster. The block does not read
773 // `cluster_id`; per-cluster values (`https_redirect_port`,
774 // `www_authenticate`, …) default to safe sentinels at the cluster
775 // lookup above when `cluster_id` is `None`, so the reorder is
776 // data-flow-safe.
777 //
778 // Status code mapping (closes #1009):
779 // Permanent → 301 (RFC 9110 §15.4.2)
780 // Found → 302 (RFC 9110 §15.4.3) — UA may rewrite POST→GET
781 // PermanentRedirect → 308 (RFC 9110 §15.4.9) — method MUST be preserved
782 let redirect_status = match redirect {
783 RedirectPolicy::Permanent => Some(301u16),
784 RedirectPolicy::Found => Some(302u16),
785 RedirectPolicy::PermanentRedirect => Some(308u16),
786 // Forward / Unauthorized are handled by other branches
787 // below; keeping them named here forces an exhaustive
788 // match so a future RedirectPolicy variant doesn't
789 // silently fall through to `None`.
790 RedirectPolicy::Forward | RedirectPolicy::Unauthorized => None,
791 };
792 if let Some(status_code) = redirect_status {
793 let scheme = resolve_redirect_scheme(redirect_scheme, context);
794 let port = rewritten_port.map(|p| p as u32).or(https_redirect_port);
795 // Feed the rewritten host AND path into the `Location` URL
796 // when the frontend's RewriteParts populated them. Without
797 // this, a `redirect = permanent` frontend with
798 // `rewrite_host = "new.example.com"` would serve clients
799 // back to the original `Host:` header, defeating the
800 // documented `old → new` shape.
801 // The host_override path also keeps `:port` stripping
802 // intact: `build_redirect_location` removes any `:port` on
803 // the override before reapplying `port_suffix`.
804 context.redirect_location = Some(build_redirect_location(
805 scheme,
806 context,
807 port,
808 rewritten_host.as_deref(),
809 rewritten_path.as_deref(),
810 ));
811 // Stash the frontend's `redirect_template` (when set) so the
812 // 3xx default-answer path can render it via
813 // `HttpAnswers::render_inline_redirect` instead of the
814 // listener / cluster default. Without this stash the field
815 // flows into `RouteResult` only to be dropped by the
816 // wildcard destructure below, so the operator-supplied
817 // template has no observable effect on the rendered
818 // redirect.
819 context.frontend_redirect_template = redirect_template;
820 // Stash the resolved status so the answer engine picks the
821 // matching default template (`http.301.redirection` /
822 // `http.302.redirection` / `http.308.redirection`).
823 context.redirect_status = Some(status_code);
824 return Err(RetrieveClusterError::HttpsRedirect);
825 }
826
827 // ── 2. Explicit `RedirectPolicy::UNAUTHORIZED` or clusterless deny ─
828 // Reached when the frontend either explicitly asks for 401 or has
829 // no backing cluster and no `Permanent` redirect to honour. The
830 // `Forward + cluster_id == None` combination collapses here so
831 // legacy clusterless frontends still emit 401 by default.
832 if matches!(redirect, RedirectPolicy::Unauthorized) || cluster_id.is_none() {
833 context.www_authenticate = www_authenticate.clone();
834 trace!("{} RouteResult::deny", log_module_context!(context));
835 return Err(RetrieveClusterError::UnauthorizedRoute);
836 }
837
838 let Some(cluster_id) = cluster_id else {
839 // Guarded by the clusterless-deny branch immediately above;
840 // the `is_none()` arm has already returned `UnauthorizedRoute`
841 // by the time control reaches here.
842 unreachable!("cluster_id was checked Some above")
843 };
844
845 // ── 3. Legacy `cluster.https_redirect` (HTTP-only listeners) ───────
846 // The caller (`Router::connect`) emits the actual 301 only on
847 // `ListenerType::Http`; gate the URL stash on the same predicate
848 // so an HTTPS listener never carries a stale `redirect_location`
849 // into a downstream default-answer path.
850 if legacy_https_redirect && matches!(proxy.borrow().kind(), ListenerType::Http) {
851 let port = https_redirect_port;
852 context.redirect_location =
853 Some(build_redirect_location("https", context, port, None, None));
854 }
855
856 // ── 4. Basic auth check (only when `required_auth` was set) ────────
857 // The check iterates the full hash list in constant time (see
858 // `crate::protocol::mux::auth::check_basic`) so the time spent
859 // does not leak which hash matched, or whether any did at all.
860 // On failure, stash the cluster's `www_authenticate` realm so the
861 // 401 default-answer can render the matching `WWW-Authenticate`
862 // header. An empty realm causes the template engine to elide the
863 // header entirely (`or_elide_header = true`).
864 if frontend_required_auth
865 && !crate::protocol::mux::auth::check_basic(front, &authorized_hashes)
866 {
867 context.www_authenticate = www_authenticate.clone();
868 trace!(
869 "{} basic-auth check failed; emitting 401",
870 log_module_context!(context)
871 );
872 return Err(RetrieveClusterError::UnauthorizedRoute);
873 }
874
875 // ── 5. Request-side mutations on the front kawa ────────────────────
876 // From here on the route is a Forward — apply the frontend's
877 // rewrite + header policy to the request kawa so the backend
878 // wire carries the operator-configured shape.
879 apply_request_rewrites_and_headers(
880 front,
881 context,
882 rewritten_host.as_deref(),
883 rewritten_path.as_deref(),
884 &headers_request,
885 );
886
887 // Pass 2 of the response-snapshot copy (see the HSTS hoist
888 // above). Runs unconditionally on the regular forward path
889 // (the early returns above bypass this site, which keeps the
890 // default-answer scope as HSTS-only). Copies EVERY edit so
891 // operator-defined `Append` response headers reach
892 // backend-served responses on both HTTP and HTTPS listeners,
893 // preserving their pre-PR scope.
894 snapshot_response_edits(&mut context.headers_response, &headers_response, |_| true);
895
896 Ok(cluster_id)
897 }
898
899 pub fn backend_from_request<L: ListenerHandler + L7ListenerHandler>(
900 &mut self,
901 cluster_id: &str,
902 frontend_should_stick: bool,
903 context: &mut HttpContext,
904 proxy: Rc<RefCell<dyn L7Proxy>>,
905 listener: &Rc<RefCell<L>>,
906 ) -> Result<(TcpStream, Rc<RefCell<Backend>>), BackendConnectionError> {
907 let (backend, conn) = self
908 .get_backend_for_sticky_session(
909 cluster_id,
910 frontend_should_stick,
911 context.sticky_session_found.as_deref(),
912 proxy,
913 )
914 .map_err(|backend_error| {
915 trace!("{} {}", log_module_context!(context), backend_error);
916 BackendConnectionError::Backend(backend_error)
917 })?;
918
919 if frontend_should_stick {
920 // update sticky name in case it changed I guess?
921 context.sticky_name = listener.borrow().get_sticky_name().to_string();
922
923 context.sticky_session = Some(
924 backend
925 .borrow()
926 .sticky_id
927 .clone()
928 .unwrap_or_else(|| backend.borrow().backend_id.to_owned()),
929 );
930 }
931
932 context.backend_id = Some(backend.borrow().backend_id.to_owned());
933 context.backend_address = Some(backend.borrow().address);
934
935 Ok((conn, backend))
936 }
937
938 fn get_backend_for_sticky_session(
939 &self,
940 cluster_id: &str,
941 frontend_should_stick: bool,
942 sticky_session: Option<&str>,
943 proxy: Rc<RefCell<dyn L7Proxy>>,
944 ) -> Result<(Rc<RefCell<Backend>>, TcpStream), BackendError> {
945 match (frontend_should_stick, sticky_session) {
946 (true, Some(sticky_session)) => proxy
947 .borrow()
948 .backends()
949 .borrow_mut()
950 .backend_from_sticky_session(cluster_id, sticky_session),
951 _ => proxy
952 .borrow()
953 .backends()
954 .borrow_mut()
955 .backend_from_cluster_id(cluster_id),
956 }
957 }
958}
959
960/// Apply the frontend's request-side rewrite + header policy to the
961/// request kawa. Mutations land before backend connect so the backend
962/// wire carries the rewritten shape:
963///
964/// 1. If `rewritten_host` is set, replace the request-line authority
965/// with the rewritten value, replace any existing `Host` request
966/// header (so H1 backends see the same value the H2 `:authority`
967/// would carry), and inject `X-Forwarded-Host` carrying the
968/// pre-rewrite authority. The X-Forwarded-Host injection ONLY fires
969/// when `rewritten_host` is set — without a rewrite there is no host
970/// swap to disclose, and HAProxy's `option forwardfor` style
971/// headers (`X-Forwarded-For`, `X-Forwarded-Proto`) still flow from
972/// the kawa parser. The pre-rewrite authority itself is captured by
973/// the caller (`route_from_request`) into `context.original_authority`
974/// on every routed request so it survives every downstream code path
975/// (audit, deny, redirect, basic-auth 401, backend-connect failure).
976/// Dedup rule: the synthetic Host AND any pre-existing Host header
977/// are dropped in the retain pass below before the rewritten Host is
978/// appended, so the wire never carries two `Host:` headers.
979/// 2. If `rewritten_path` is set, replace both the abstract path
980/// (consumed by H2 `:path`) and the request-line URI (consumed by
981/// the H1 converter) so cardinality H1↔H1, H1↔H2, H2↔H1, H2↔H2 all
982/// propagate the rewritten target.
983/// 3. For every `headers_request` edit:
984/// - empty `val` → remove every existing header with the matching
985/// name from `kawa.blocks` (HAProxy `del-header` parity);
986/// - non-empty `val` → append the header before the `end_header`
987/// flag block. Set/replace semantics: callers that want to replace
988/// a header pass two edits (one delete with empty val, one set
989/// with the new value).
990fn apply_request_rewrites_and_headers(
991 kawa: &mut super::GenericHttpStream,
992 context: &mut HttpContext,
993 rewritten_host: Option<&str>,
994 rewritten_path: Option<&str>,
995 headers_request: &[HeaderEdit],
996) {
997 use kawa::{Block, Pair, Store};
998
999 if rewritten_host.is_none() && rewritten_path.is_none() && headers_request.is_empty() {
1000 return;
1001 }
1002
1003 // `route_from_request` already captured the pre-rewrite authority
1004 // into `context.original_authority`. Re-borrow it here for the
1005 // optional X-Forwarded-Host injection rather than re-parsing the
1006 // kawa Store. Cloning a short header value (typically `host:port`)
1007 // is cheaper than another UTF-8 decode of the request-line slice.
1008 let original_authority: Option<String> = if rewritten_host.is_some() {
1009 context.original_authority.clone()
1010 } else {
1011 None
1012 };
1013
1014 // ── status-line authority / path rewrites ─────────────────────────
1015 // The kawa request status line carries both `path` and `uri` —
1016 // `path` is the abstract path (consumed by the H2 converter to
1017 // emit `:path`) while `uri` is the request-line URI (consumed by
1018 // the H1 converter at `kawa::protocol::h1::converter`). Both must
1019 // be mutated so an H1 frontend forwarding to an H1 backend AND an
1020 // H2 frontend forwarding to an H1 backend (or vice versa) see the
1021 // rewritten target on the wire.
1022 if (rewritten_host.is_some() || rewritten_path.is_some())
1023 && let kawa::StatusLine::Request {
1024 authority,
1025 path,
1026 uri,
1027 ..
1028 } = &mut kawa.detached.status_line
1029 {
1030 if let Some(new_host) = rewritten_host {
1031 *authority = Store::from_string(new_host.to_owned());
1032 }
1033 if let Some(new_path) = rewritten_path {
1034 *path = Store::from_string(new_path.to_owned());
1035 *uri = Store::from_string(new_path.to_owned());
1036 }
1037 }
1038
1039 // ── single-pass split: deletes vs. sets ───────────────────────────
1040 // Walk `headers_request` once and separate each edit into either the
1041 // delete list (empty val) or the insert list (non-empty val). Two
1042 // passes was wasteful when an operator stacks many `--header` flags;
1043 // one pass keeps the allocation profile flat.
1044 let host_lower = b"host";
1045 let xfh_lower = b"x-forwarded-host";
1046 let rewriting_host = rewritten_host.is_some();
1047 let mut keys_to_drop: Vec<Vec<u8>> = Vec::with_capacity(headers_request.len() + 2);
1048 let mut to_insert: Vec<Block> = Vec::with_capacity(headers_request.len() + 2);
1049 // Track whether any operator-supplied edit names Host or
1050 // X-Forwarded-Host so we always dedup the existing kawa Host header
1051 // before inserting the operator's value. Without this, an operator
1052 // who sets `--header request=Host=evil` on a frontend WITHOUT
1053 // `--rewrite-host` lands TWO `Host:` headers on the backend wire —
1054 // a request-smuggling primitive on backends that pick last-Host
1055 // (CWE-444 cousin).
1056 let mut operator_overrides_host = false;
1057 let mut operator_overrides_xfh = false;
1058 for edit in headers_request {
1059 let key_is_host = edit.key.eq_ignore_ascii_case(host_lower);
1060 let key_is_xfh = edit.key.eq_ignore_ascii_case(xfh_lower);
1061 operator_overrides_host |= key_is_host;
1062 operator_overrides_xfh |= key_is_xfh;
1063 if edit.val.is_empty() {
1064 keys_to_drop.push(edit.key.iter().map(u8::to_ascii_lowercase).collect());
1065 } else {
1066 to_insert.push(Block::Header(Pair {
1067 key: Store::from_slice(&edit.key),
1068 val: Store::from_slice(&edit.val),
1069 }));
1070 }
1071 }
1072 if rewriting_host || operator_overrides_host {
1073 keys_to_drop.push(host_lower.to_vec());
1074 }
1075 if rewriting_host || operator_overrides_xfh {
1076 keys_to_drop.push(xfh_lower.to_vec());
1077 }
1078
1079 // ── delete pass on existing blocks ────────────────────────────────
1080 let buf_ptr = kawa.storage.buffer();
1081 if !keys_to_drop.is_empty() {
1082 // Read `key.data(buf_ptr)` only on non-elided headers — kawa's
1083 // earlier passes (HPACK decoder, H1 header parser) tag suppressed
1084 // headers with `Store::Empty` rather than removing them, and
1085 // calling `.data()` on `Store::Empty` panics in
1086 // `kawa-0.6.8/src/storage/repr.rs`. Pinning the guard explicitly
1087 // until kawa changes its policy.
1088 let buf = buf_ptr;
1089 kawa.blocks.retain(|block| {
1090 if let Block::Header(Pair { key, val: _ }) = block {
1091 if matches!(key, Store::Empty) {
1092 return true;
1093 }
1094 let key_bytes = key.data(buf);
1095 // Both `keys_to_drop` and `key_lower` are pre-lowercased,
1096 // so a byte-equality compare is sufficient — a second
1097 // ASCII-fold pass via `compare_no_case` would just burn
1098 // cycles re-folding bytes that are already canonical.
1099 let key_lower: Vec<u8> = key_bytes.iter().map(u8::to_ascii_lowercase).collect();
1100 !keys_to_drop
1101 .iter()
1102 .any(|k| k.as_slice() == key_lower.as_slice())
1103 } else {
1104 true
1105 }
1106 });
1107 }
1108
1109 // ── insertion before the end-of-headers flag ──────────────────────
1110 // Every header we add (rewritten Host, X-Forwarded-Host,
1111 // operator-supplied set/append edits) must land before
1112 // `Block::Flags { end_header: true }` so the converter emits them
1113 // as part of the request header block. Synthetic Host/X-Forwarded-Host
1114 // are prepended (they describe the rewrite, not an operator policy).
1115 let end_header_idx = super::shared::end_of_headers_index(kawa);
1116
1117 if rewriting_host {
1118 let mut synth: Vec<Block> = Vec::with_capacity(2);
1119 if let Some(new_host) = rewritten_host {
1120 synth.push(Block::Header(Pair {
1121 key: Store::Static(b"Host"),
1122 val: Store::from_string(new_host.to_owned()),
1123 }));
1124 }
1125 if let Some(orig) = original_authority.as_deref() {
1126 synth.push(Block::Header(Pair {
1127 key: Store::Static(b"X-Forwarded-Host"),
1128 val: Store::from_string(orig.to_owned()),
1129 }));
1130 }
1131 synth.append(&mut to_insert);
1132 to_insert = synth;
1133 }
1134 if !to_insert.is_empty() {
1135 let insert_at = end_header_idx.unwrap_or(kawa.blocks.len());
1136 for (offset, block) in to_insert.into_iter().enumerate() {
1137 kawa.blocks.insert(insert_at + offset, block);
1138 }
1139 }
1140}
1141
1142/// Copy a per-frontend response-edit slice into the per-stream
1143/// `HttpContext.headers_response` snapshot, applying `filter` to each
1144/// edit. The snapshot is cleared before the copy so a second pass on
1145/// the same context (the HSTS hoist + post-forward pattern in
1146/// `route_from_request`) overrides any earlier partial copy.
1147fn snapshot_response_edits<F>(target: &mut Vec<HeaderEditSnapshot>, src: &[HeaderEdit], filter: F)
1148where
1149 F: Fn(&HeaderEdit) -> bool,
1150{
1151 target.clear();
1152 for edit in src.iter().filter(|e| filter(e)) {
1153 target.push(HeaderEditSnapshot {
1154 key: edit.key.to_vec(),
1155 val: edit.val.to_vec(),
1156 mode: edit.mode,
1157 });
1158 }
1159}
1160
1161/// Resolve the protocol scheme to use when emitting a redirect's `Location`
1162/// header. Maps the proto enum onto `"http"` / `"https"`, with `USE_SAME`
1163/// preserving the request's scheme (HTTPS for TLS listeners, HTTP otherwise).
1164fn resolve_redirect_scheme(scheme: RedirectScheme, context: &HttpContext) -> &'static str {
1165 match scheme {
1166 RedirectScheme::UseHttps => "https",
1167 RedirectScheme::UseHttp => "http",
1168 RedirectScheme::UseSame => {
1169 if context.tls_server_name.is_some() {
1170 "https"
1171 } else {
1172 "http"
1173 }
1174 }
1175 }
1176}
1177
1178/// Build the `Location` URL for a redirect response. Defaults the port
1179/// suffix only when the operator provided one or when scheme defaults
1180/// would mismatch (port 80 on https / 443 on http stays implicit).
1181///
1182/// `host_override` and `path_override` carry the frontend's
1183/// `RewriteParts::run` output for `RedirectPolicy::PERMANENT` flows so
1184/// the 301 `Location` reflects `rewrite_host` / `rewrite_path` instead
1185/// of the original `:authority` / `:path`. The legacy
1186/// `cluster.https_redirect` path passes `None` for both — it has no
1187/// per-frontend rewrite knobs.
1188fn build_redirect_location(
1189 scheme: &str,
1190 context: &HttpContext,
1191 port: Option<u32>,
1192 host_override: Option<&str>,
1193 path_override: Option<&str>,
1194) -> String {
1195 let authority = host_override
1196 .or(context.authority.as_deref())
1197 .unwrap_or_default();
1198 let path = path_override.or(context.path.as_deref()).unwrap_or("/");
1199 // Strip an existing `:port` from the authority — operators typically
1200 // configure `https_redirect_port` precisely because the listener's
1201 // port differs from the redirect target. Bracketed IPv6 literals
1202 // like `[::1]` survive intact: `rsplit_once(':')` only triggers when
1203 // the suffix after the final `:` is entirely ASCII digits.
1204 let host_only = match authority.rsplit_once(':') {
1205 Some((host, port_part))
1206 if !port_part.is_empty() && port_part.bytes().all(|b| b.is_ascii_digit()) =>
1207 {
1208 host
1209 }
1210 _ => authority,
1211 };
1212 let port_suffix = match port {
1213 Some(80) if scheme == "http" => String::new(),
1214 Some(443) if scheme == "https" => String::new(),
1215 Some(p) => format!(":{p}"),
1216 None => String::new(),
1217 };
1218 format!("{scheme}://{host_only}{port_suffix}{path}")
1219}
1220
1221/// Exact-match test between an HTTP `:authority` / `Host` value and a TLS SNI.
1222///
1223/// Matching rules:
1224/// * The authority is stripped of its optional `:port` suffix. RFC 6066 §3
1225/// forbids a port in the SNI extension, so the SNI is compared against
1226/// the host component only.
1227/// * The comparison is case-insensitive (RFC 9110 §4.2.3 — hosts are
1228/// case-insensitive). The SNI is assumed to be already lowercased by
1229/// the caller (see `https.rs::upgrade_handshake`); only the authority
1230/// side needs on-the-fly `to_ascii_lowercase`.
1231/// * No wildcard logic: if the operator serves a wildcard certificate,
1232/// the SNI negotiated by the client is still the specific name that
1233/// client sent, and the request `:authority` must equal that specific
1234/// name exactly. This is the tightest possible TLS trust boundary.
1235///
1236/// The `:port` suffix is only stripped when the suffix is non-empty and
1237/// entirely ASCII digits. This keeps bracketed IPv6 literals like `[::1]`
1238/// intact: `rsplit_once(':')` would otherwise mis-split them.
1239pub(crate) fn authority_matches_sni(authority: &str, sni_lowercased: &str) -> bool {
1240 let host = strip_authority_port(authority);
1241 if host.len() != sni_lowercased.len() {
1242 return false;
1243 }
1244 host.as_bytes()
1245 .iter()
1246 .zip(sni_lowercased.as_bytes())
1247 .all(|(a, b)| a.to_ascii_lowercase() == *b)
1248}
1249
1250/// Strip the optional `:port` suffix from an authority value. Bracketed
1251/// IPv6 literals (`[::1]`, `[::1]:8443`) keep their inner colons intact:
1252/// the suffix is only stripped when the tail after the last `:` is
1253/// non-empty and entirely ASCII digits.
1254fn strip_authority_port(authority: &str) -> &str {
1255 match authority.rsplit_once(':') {
1256 Some((h, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => h,
1257 _ => authority,
1258 }
1259}
1260
1261/// RFC 6125 §6.4.3 wildcard-aware match of `:authority` against a SAN set
1262/// snapshot taken at TLS handshake.
1263///
1264/// Returns the matched SAN entry on success so the caller can log it.
1265///
1266/// Matching rules:
1267/// * Port suffix on the authority is stripped (same logic as
1268/// [`authority_matches_sni`], IPv6-bracket safe).
1269/// * Compare is ASCII case-insensitive (`:authority` is ASCII per
1270/// RFC 9113 §8.3.1; SAN entries are stored pre-lowercased by
1271/// `https.rs::upgrade_handshake`).
1272/// * `*.suffix` matches exactly one DNS label at the leftmost position
1273/// and only when that label is non-empty: it does NOT match the apex,
1274/// does NOT cross dots, and embedded wildcards (`foo.*.example.com`,
1275/// `*foo.example.com`) are forbidden.
1276/// * Empty `names` ⇒ `None` (default-cert path — Sōzu is not
1277/// authoritative for any name).
1278pub(crate) fn authority_matched_cert_name<'a>(
1279 authority: &str,
1280 names: &'a [String],
1281) -> Option<&'a str> {
1282 let mut host = strip_authority_port(authority);
1283 // RFC 1034 §3.1 absolute-form: `example.com.` and `example.com` name
1284 // the same host. The SAN snapshot already strips trailing dots at
1285 // `https.rs::upgrade_handshake`, and the SNI side strips them at the
1286 // same site; strip on the authority side so a client emitting
1287 // absolute-form `:authority` (or H1 `Host`) does not get a false 421.
1288 // Only one trailing dot is removed because RFC 1034 forbids multiple
1289 // trailing dots on a domain literal.
1290 if let Some(trimmed) = host.strip_suffix('.') {
1291 host = trimmed;
1292 }
1293 if host.is_empty() {
1294 return None;
1295 }
1296 for entry in names {
1297 if let Some(suffix) = entry.strip_prefix("*.") {
1298 // RFC 6125 §6.4.3: the wildcard label is the *entire* left-most
1299 // label. Embedded wildcards (`f*.example.com`, `*f.example.com`)
1300 // are rejected because we reach this branch only when the entry
1301 // starts with the exact two bytes `*.`. We still must reject
1302 // wildcards anywhere else in the entry by requiring no further
1303 // `*` in `suffix`.
1304 if suffix.contains('*') {
1305 continue;
1306 }
1307 // Authority has the form `<left-most-label>.<rest>`; the
1308 // wildcard substitutes for exactly that left-most label, which
1309 // must be non-empty and contain no dot.
1310 let Some((leftmost, rest)) = host.split_once('.') else {
1311 continue;
1312 };
1313 if leftmost.is_empty() {
1314 continue;
1315 }
1316 if rest.eq_ignore_ascii_case(suffix) {
1317 return Some(entry);
1318 }
1319 continue;
1320 }
1321 if entry.contains('*') {
1322 // Internal wildcards (`foo.*.example.com`) are not RFC 6125-
1323 // valid. Skip rather than mis-match.
1324 continue;
1325 }
1326 if host.eq_ignore_ascii_case(entry) {
1327 return Some(entry);
1328 }
1329 }
1330 None
1331}
1332
1333#[cfg(test)]
1334mod tests {
1335 use super::{authority_matches_sni, log_coalescing_accepted, log_sni_authority_mismatch};
1336 use crate::protocol::http::editor::HttpContext;
1337
1338 #[test]
1339 fn routing_runtime_logs_bound_method_authority_sni_and_certificate_names() {
1340 const METHOD_SECRET: &str = "MUX_ROUTE_METHOD_SECRET_SENTINEL";
1341 const AUTHORITY_SECRET: &str = "MUX_ROUTE_AUTHORITY_SECRET_SENTINEL";
1342 const SNI_SECRET: &str = "MUX_ROUTE_SNI_SECRET_SENTINEL";
1343 const SAN_SECRET: &str = "MUX_ROUTE_SAN_SECRET_SENTINEL";
1344
1345 let long_value = |marker: &str| format!("{marker}{}", "x".repeat(4096));
1346 let method = long_value(METHOD_SECRET);
1347 let authority = long_value(AUTHORITY_SECRET);
1348 let sni = long_value(SNI_SECRET);
1349 let san = long_value(SAN_SECRET);
1350 let method_len = method.len();
1351 let authority_len = authority.len();
1352 let sni_len = sni.len();
1353 let san_len = san.len();
1354
1355 let output = crate::capture_test_logs_at_level("debug", move || {
1356 let mut context = HttpContext::new(
1357 rusty_ulid::Ulid::generate(),
1358 rusty_ulid::Ulid::generate(),
1359 crate::Protocol::HTTPS,
1360 "127.0.0.1:443"
1361 .parse()
1362 .expect("test public address must parse"),
1363 Some(
1364 "127.0.0.1:12345"
1365 .parse()
1366 .expect("test session address must parse"),
1367 ),
1368 String::new(),
1369 String::new(),
1370 false,
1371 false,
1372 );
1373 context.method = Some(crate::protocol::http::parser::Method::Custom(method));
1374 context.authority = Some(authority.clone());
1375 context.tls_cert_names = Some(std::sync::Arc::new(vec![san.clone()]));
1376
1377 log_coalescing_accepted(&context, &authority, &sni, &san);
1378 log_sni_authority_mismatch(
1379 &context,
1380 &authority,
1381 &sni,
1382 context.tls_cert_names.as_deref().map(Vec::as_slice),
1383 );
1384 });
1385
1386 for secret in [METHOD_SECRET, AUTHORITY_SECRET, SNI_SECRET, SAN_SECRET] {
1387 assert!(
1388 !output.contains(secret),
1389 "mux routing runtime log leaked {secret}: {output}"
1390 );
1391 }
1392 for metadata in [
1393 format!("bytes={method_len}"),
1394 format!("authority_bytes=Some({authority_len})"),
1395 format!("authority_bytes={authority_len}"),
1396 format!("sni_bytes={sni_len}"),
1397 format!("matched_san_bytes={san_len}"),
1398 "certificate_sans_count=1".to_owned(),
1399 format!("certificate_sans_bytes={san_len}"),
1400 ] {
1401 assert!(
1402 output.contains(&metadata),
1403 "mux routing runtime log omitted {metadata}: {output}"
1404 );
1405 }
1406 assert!(
1407 output.len() <= 2048,
1408 "mux routing runtime capture is not bounded: {} bytes",
1409 output.len()
1410 );
1411 }
1412
1413 #[test]
1414 fn match_exact() {
1415 assert!(authority_matches_sni("example.com", "example.com"));
1416 }
1417
1418 #[test]
1419 fn match_different_case() {
1420 assert!(authority_matches_sni("Example.COM", "example.com"));
1421 }
1422
1423 #[test]
1424 fn match_authority_with_port() {
1425 assert!(authority_matches_sni("example.com:8443", "example.com"));
1426 }
1427
1428 #[test]
1429 fn reject_different_host() {
1430 assert!(!authority_matches_sni(
1431 "tenant-b.example.com",
1432 "tenant-a.example.com"
1433 ));
1434 }
1435
1436 #[test]
1437 fn reject_substring_attack() {
1438 // Length check guards against an authority that is a prefix or
1439 // suffix of the SNI (or vice versa).
1440 assert!(!authority_matches_sni("example.co", "example.com"));
1441 assert!(!authority_matches_sni("example.commons", "example.com"));
1442 }
1443
1444 #[test]
1445 fn reject_wildcard_not_expanded() {
1446 // Wildcard cert selection happens at the cert-resolver layer; the SNI
1447 // we see here is the concrete name the client sent. Do not silently
1448 // accept `*.example.com` as matching `foo.example.com`.
1449 assert!(!authority_matches_sni("foo.example.com", "*.example.com"));
1450 }
1451
1452 #[test]
1453 fn ipv6_bracketed_literal_with_port() {
1454 // `[::1]:8443` must still match the SNI `[::1]`; only the trailing
1455 // `:8443` is a port (all digits → stripped).
1456 assert!(authority_matches_sni("[::1]:8443", "[::1]"));
1457 }
1458
1459 #[test]
1460 fn ipv6_bracketed_without_port() {
1461 // The `:` characters inside the brackets must not be mistaken for a
1462 // port separator: the tail after the last `:` is `1]`, not all
1463 // digits, so it is NOT stripped and the whole string compares.
1464 assert!(authority_matches_sni("[::1]", "[::1]"));
1465 }
1466}
1467
1468#[cfg(test)]
1469mod authority_matched_cert_name_tests {
1470 use super::authority_matched_cert_name;
1471
1472 #[test]
1473 fn cert_name_match_exact_single_san() {
1474 let names = vec!["example.com".to_owned()];
1475 assert_eq!(
1476 authority_matched_cert_name("example.com", &names),
1477 Some("example.com"),
1478 );
1479 }
1480
1481 #[test]
1482 fn cert_name_match_wildcard_left_most() {
1483 let names = vec!["*.cleverapps.io".to_owned()];
1484 assert_eq!(
1485 authority_matched_cert_name("staging-3.cleverapps.io", &names),
1486 Some("*.cleverapps.io"),
1487 );
1488 }
1489
1490 #[test]
1491 fn cert_name_reject_wildcard_apex() {
1492 // RFC 6125 §6.4.3: `*.example.com` does NOT cover the apex
1493 // `example.com` — the wildcard label must consume exactly one
1494 // non-empty label.
1495 let names = vec!["*.example.com".to_owned()];
1496 assert_eq!(authority_matched_cert_name("example.com", &names), None);
1497 }
1498
1499 #[test]
1500 fn cert_name_reject_wildcard_two_labels() {
1501 // `*.example.com` cannot cross dots: `a.b.example.com` has two
1502 // labels before `example.com` and must be rejected.
1503 let names = vec!["*.example.com".to_owned()];
1504 assert_eq!(authority_matched_cert_name("a.b.example.com", &names), None,);
1505 }
1506
1507 #[test]
1508 fn cert_name_reject_wildcard_not_left_most() {
1509 // Embedded wildcards (`foo.*.example.com`) are not RFC 6125-valid
1510 // and must be skipped, not mis-matched.
1511 let names = vec!["foo.*.example.com".to_owned()];
1512 assert_eq!(
1513 authority_matched_cert_name("foo.bar.example.com", &names),
1514 None,
1515 );
1516 }
1517
1518 #[test]
1519 fn cert_name_match_case_insensitive() {
1520 // ASCII case folding only — `:authority` is ASCII per RFC 9113
1521 // §8.3.1 and the snapshot is pre-lowercased at handshake.
1522 let names = vec!["EXAMPLE.com".to_owned()];
1523 assert!(authority_matched_cert_name("Example.COM", &names).is_some());
1524 }
1525
1526 #[test]
1527 fn cert_name_match_with_port() {
1528 // The port suffix on `:authority` must be stripped before the
1529 // SAN compare.
1530 let names = vec!["example.com".to_owned()];
1531 assert!(authority_matched_cert_name("example.com:8443", &names).is_some());
1532 }
1533
1534 #[test]
1535 fn cert_name_match_absolute_form_trailing_dot() {
1536 // RFC 1034 §3.1: an absolute-form domain literal carries one
1537 // trailing dot (`example.com.`) and resolves to the same host as
1538 // the relative form. The SAN snapshot stores the relative form
1539 // (https.rs strips the trailing dot at handshake), so the matcher
1540 // must strip it on the authority side too — otherwise a client
1541 // emitting an absolute-form `:authority` gets a false 421.
1542 let names = vec!["example.com".to_owned()];
1543 assert!(authority_matched_cert_name("example.com.", &names).is_some());
1544 // And with both port and trailing dot.
1545 assert!(authority_matched_cert_name("example.com.:8443", &names).is_some());
1546 // The wildcard branch must also accept the absolute form.
1547 let wildcard = vec!["*.example.com".to_owned()];
1548 assert!(authority_matched_cert_name("foo.example.com.", &wildcard).is_some());
1549 }
1550
1551 #[test]
1552 fn cert_name_match_idn_a_label() {
1553 // IDNA A-labels (xn--…) are ASCII and compare byte-for-byte once
1554 // the snapshot is lowercased.
1555 let names = vec!["xn--bcher-kva.example.com".to_owned()];
1556 assert!(authority_matched_cert_name("xn--bcher-kva.example.com", &names).is_some());
1557 }
1558
1559 #[test]
1560 fn cert_name_reject_empty_names() {
1561 // Empty snapshot = default cert served = Sōzu is not
1562 // authoritative for any name; every authority must miss.
1563 assert_eq!(authority_matched_cert_name("example.com", &[]), None);
1564 }
1565
1566 #[test]
1567 fn cert_name_match_multi_san_one_hit() {
1568 let names = vec!["foo.com".to_owned(), "*.example.org".to_owned()];
1569 assert_eq!(
1570 authority_matched_cert_name("bar.example.org", &names),
1571 Some("*.example.org"),
1572 );
1573 }
1574
1575 #[test]
1576 fn cert_name_reject_substring_attack() {
1577 // `*.example.com` must not match `example.commons` — the suffix
1578 // after the first label is `commons`, not `example.com`.
1579 let names = vec!["*.example.com".to_owned()];
1580 assert_eq!(authority_matched_cert_name("example.commons", &names), None,);
1581 }
1582
1583 #[test]
1584 fn cert_name_ipv6_bracketed_literal_with_port() {
1585 // The `:` characters inside the brackets must not be mistaken for
1586 // a port separator: only the trailing `:8443` is stripped, and
1587 // `[::1]` compares equal to `[::1]`.
1588 let names = vec!["[::1]".to_owned()];
1589 assert!(authority_matched_cert_name("[::1]:8443", &names).is_some());
1590 }
1591}