Skip to main content

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}