Skip to main content

sozu_lib/protocol/tcp_preread/
mod.rs

1//! Pure sans-io TCP passthrough SNI-preread core (issue #1279).
2//!
3//! [`SniPrereadCore`] decides, from the raw bytes of an inbound TLS
4//! ClientHello, which cluster a TCP passthrough connection routes to --
5//! WITHOUT terminating TLS. It performs **no I/O**: there is no socket, no
6//! `Instant::now()`, no `rand`, and no `Arc<Mutex>`. Time is injected as
7//! `now: Instant` parameters ([`Input::Bytes`] / [`Input::Timeout`]); routing
8//! config is injected borrowed ([`PrereadConfig`]). It never mutates,
9//! consumes, or re-serializes the caller's buffer -- byte-for-byte replay is
10//! the whole point: once routed, the shell forwards the SAME bytes verbatim
11//! to the backend, starting at [`Output::Routed::content_offset`].
12//!
13//! Mirrors the split already established by [`crate::protocol::udp`]: a
14//! near-stateless core (`decided` latch + `deadline`, see
15//! [`SniPrereadCore`]) driven by [`Input`] / [`Output`] through
16//! [`SniPrereadCore::handle_input`], with the accumulating byte buffer owned
17//! by the I/O shell ([`shell::SniPreread`], wired into the session lifecycle
18//! by `lib/src/tcp.rs`) rather than by the core itself -- every
19//! [`Input::Bytes`] carries the FULL accumulated window from wire offset 0,
20//! not a delta.
21//!
22//! `parser` owns the nom-based wire format (TLS record layer, ClientHello,
23//! and extensions); this module owns the PROXY-v2 stripping, the SNI/ALPN
24//! routing decision against [`crate::router::pattern_trie::TrieNode`], and
25//! the decided/deadline state machine.
26
27mod parser;
28pub mod shell;
29
30use std::{
31    collections::BTreeSet,
32    net::SocketAddr,
33    time::{Duration, Instant},
34};
35
36use nom::Err as NomErr;
37use sozu_command::state::ClusterId;
38
39use crate::{protocol::proxy_protocol::parser::parse_v2_header, router::pattern_trie::TrieNode};
40
41/// How a route entry's cluster accepts the client's ALPN offer.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum AlpnMatcher {
44    /// Matches regardless of what the client offered (including a client
45    /// that sent no `alpn` extension at all). The catch-all: fires only
46    /// once no `OneOf` entry matched any client-offered protocol.
47    Any,
48    /// Matches when at least one of the client's offered protocols is a
49    /// member of this set.
50    OneOf(BTreeSet<Vec<u8>>),
51}
52
53/// Routing input, borrowed for the lifetime of one [`SniPrereadCore::handle_input`]
54/// call. `'t` is the lifetime of the route table itself (owned by the
55/// listener config, well outside any single preread attempt).
56#[derive(Debug, Clone, Copy)]
57pub struct PrereadConfig<'t> {
58    /// SNI -> `(AlpnMatcher, ClusterId)` route table, reusing
59    /// [`crate::router::pattern_trie::TrieNode`] (exact hosts, `*.`
60    /// wildcards, regex segments -- same trie the HTTP/HTTPS router uses).
61    pub routes: &'t TrieNode<Vec<(AlpnMatcher, ClusterId)>>,
62    /// Whether a PROXY-v2 header precedes the ClientHello on the wire.
63    pub inbound_proxy: bool,
64    /// Effective byte cap for the preread window, enforced only while the
65    /// decision is still pending: a window that reaches `max_bytes` without
66    /// a complete PROXY header + ClientHello rejects `TooLarge`; a COMPLETE
67    /// hello routes regardless of trailing post-hello bytes. The caller
68    /// guarantees this is `<=` the accumulator buffer's capacity.
69    pub max_bytes: usize,
70    /// Preread deadline, armed once from the first `Input::Bytes`.
71    pub timeout: Duration,
72    /// Whether a `*.` wildcard route may satisfy a lookup (mirrors the
73    /// HTTP router's `accept_wildcard` knob on
74    /// [`TrieNode::domain_lookup`]).
75    pub accept_wildcard: bool,
76}
77
78/// One input fed into [`SniPrereadCore::handle_input`].
79#[derive(Debug)]
80pub enum Input<'a> {
81    /// The FULL accumulated preread window, from wire offset 0, re-fed on
82    /// every call (never a delta). `now` drives the one-time deadline arm.
83    Bytes { buf: &'a [u8], now: Instant },
84    /// The preread deadline fired without a decision.
85    Timeout { now: Instant },
86    /// The frontend socket closed before a decision was reached.
87    FrontClosed,
88}
89
90/// One outcome of [`SniPrereadCore::handle_input`].
91#[derive(Debug, Clone, PartialEq)]
92pub enum Output {
93    /// Not enough bytes yet; `deadline` is the same value for the whole
94    /// lifetime of one [`SniPrereadCore`] (armed once, on the first
95    /// `Bytes` input).
96    NeedMore { deadline: Instant },
97    /// A cluster was chosen. `content_offset` is how many leading bytes of
98    /// the ORIGINAL buffer are the (already-parsed) PROXY-v2 header -- the
99    /// shell must skip exactly that many bytes before forwarding the
100    /// UNTOUCHED remainder to the backend verbatim.
101    Routed {
102        cluster: ClusterId,
103        content_offset: usize,
104        proxy_source: Option<SocketAddr>,
105        sni: String,
106        alpn: Vec<Vec<u8>>,
107        /// The trie KEY that matched -- the route's configured pattern, not
108        /// the client's concrete SNI: for a wildcard route this is
109        /// `*.example.com` even when `sni` is `a.example.com`. Carries the
110        /// matched route's identity so the shell/session can key
111        /// per-frontend state (e.g. access-log tags) without re-running the
112        /// lookup.
113        matched_sni_pattern: String,
114        /// Clone of the winning route entry's [`AlpnMatcher`] -- the other
115        /// half of the matched route's identity.
116        matched_alpn: AlpnMatcher,
117    },
118    /// Terminal, non-routable verdict. See [`RejectReason`].
119    Reject(RejectReason),
120}
121
122/// Why a preread attempt was terminally rejected.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum RejectReason {
125    /// The first TLS record's `ContentType` was not `handshake` (22).
126    NotTls,
127    /// A TLS record's framing is invalid (bad length, or a non-`handshake`
128    /// record interrupting an in-progress ClientHello reassembly).
129    MalformedRecord,
130    /// The known-complete ClientHello body fails ANY of the following
131    /// (`parser.rs`'s single catch-all for the inner body, since none of
132    /// them can be `NeedMore` once the outer handshake framing has already
133    /// proven the body fully present):
134    /// - the handshake message is not a ClientHello (wrong `msg_type`);
135    /// - a length-prefixed field inside the body lies about the bytes
136    ///   available (`session_id`/`cipher_suites`/`compression_methods`/
137    ///   extensions-block lengths, or a nested extension's own declared
138    ///   length);
139    /// - the `server_name` extension's `host_name` is not valid UTF-8;
140    /// - a second `server_name` extension, or a second `alpn` extension, is
141    ///   present (RFC 8446 §4.2 forbids more than one of the same type —
142    ///   Sōzu forwards the original bytes verbatim, so silently resolving a
143    ///   duplicate differently than a tolerant backend would be a routing/
144    ///   forwarding mismatch);
145    /// - a single `server_name` extension's `server_name_list` itself
146    ///   carries more than one `host_name` (type `0`) entry (RFC 6066 §3);
147    /// - the `alpn` extension's `protocol_name_list` contains a
148    ///   zero-length protocol name, or is present but empty (RFC 7301
149    ///   §3.1 declares both `1..2^8-1`-length names and a non-empty list).
150    MalformedHandshake,
151    /// The preread deadline fired before a terminal verdict was reached.
152    Fragmented,
153    /// The accumulated window reached [`PrereadConfig::max_bytes`] while
154    /// the PROXY header / ClientHello was still incomplete -- it can never
155    /// complete within the cap. A COMPLETE hello routes regardless of total
156    /// window length (post-hello trailing bytes are normal in passthrough).
157    TooLarge,
158    /// No usable SNI: the `server_name` extension was absent, empty, or
159    /// its RFC 6066 `host_name` entry was missing.
160    NoSni,
161    /// `encrypted_client_hello` (`0xfe0d`) was present and no usable outer
162    /// `server_name` was available -- distinguished from [`Self::NoSni`]
163    /// because an ECH-hiding client is a different operational signal.
164    EchOuterAbsent,
165    /// The normalized SNI matched no route.
166    SniUnmatched,
167    /// The SNI matched a route, but no entry's [`AlpnMatcher`] accepted the
168    /// client's offered ALPN protocols (and no [`AlpnMatcher::Any`] entry
169    /// was present either).
170    AlpnUnmatched,
171    /// The PROXY-v2 header itself failed to parse.
172    ProxyHeaderInvalid,
173    /// The frontend closed before a decision was reached.
174    FrontClosed,
175}
176
177/// The pure preread core. Deliberately near-stateless: the shell owns the
178/// growing byte accumulator and re-feeds the FULL window on every
179/// [`Input::Bytes`]; this struct only remembers whether a terminal verdict
180/// has already been latched (`decided`) and when the preread deadline was
181/// armed (`deadline`).
182#[derive(Debug, Default)]
183pub struct SniPrereadCore {
184    /// Set exactly once, by [`Self::decide`], to the first terminal
185    /// [`Output::Routed`] / [`Output::Reject`]. Monotonic: once `Some`, it
186    /// never changes and every subsequent [`Self::handle_input`] call
187    /// replays it verbatim without re-parsing anything.
188    decided: Option<Output>,
189    /// Set exactly once, on the first [`Input::Bytes`], to `now +
190    /// cfg.timeout`. Never rewound.
191    deadline: Option<Instant>,
192}
193
194impl SniPrereadCore {
195    /// Construct a fresh, undecided core.
196    pub fn new() -> Self {
197        SniPrereadCore {
198            decided: None,
199            deadline: None,
200        }
201    }
202
203    /// Feed one input. Pure: `now` (inside [`Input`]) and `cfg` are
204    /// injected, never read from ambient state.
205    pub fn handle_input(&mut self, cfg: &PrereadConfig<'_>, input: Input<'_>) -> Output {
206        #[cfg(debug_assertions)]
207        let deadline_before = self.deadline;
208        #[cfg(debug_assertions)]
209        let was_decided = self.decided.is_some();
210        self.debug_assert_invariants();
211
212        let output = match input {
213            Input::Bytes { buf, now } => self.on_bytes(cfg, buf, now),
214            Input::Timeout { now } => self.on_timeout(now),
215            Input::FrontClosed => self.on_front_closed(),
216        };
217
218        // Post-conditions specific to this call: the deadline, once armed,
219        // never rewinds; a core that was already decided must still be
220        // decided (the latch never reverts to undecided).
221        #[cfg(debug_assertions)]
222        {
223            if let Some(before) = deadline_before {
224                debug_assert_eq!(
225                    self.deadline,
226                    Some(before),
227                    "preread deadline must be set at most once and never rewound"
228                );
229            }
230            debug_assert!(
231                !was_decided || self.decided.is_some(),
232                "a decided core must never become undecided (monotonic latch)"
233            );
234        }
235        self.debug_assert_invariants();
236
237        output
238    }
239
240    fn on_bytes(&mut self, cfg: &PrereadConfig<'_>, buf: &[u8], now: Instant) -> Output {
241        if self.deadline.is_none() {
242            self.deadline = Some(now + cfg.timeout);
243        }
244        let deadline = self
245            .deadline
246            .expect("deadline was just armed unconditionally above if it was None");
247
248        if let Some(decided) = &self.decided {
249            return decided.clone();
250        }
251
252        // Defensively enforce the ABSOLUTE deadline here too, not just on
253        // `Input::Timeout`: the shell (`TcpSession::readable` in
254        // `lib/src/tcp.rs`) is expected to stop re-arming the frontend
255        // timeout once undecided and instead let `Input::Timeout` fire, but
256        // `now` is injected deterministically, so the core can and must
257        // reject on its own the moment a `Bytes` input arrives at or after
258        // the deadline it latched -- same terminal verdict `on_timeout`
259        // produces, even for a fragment that happens to complete a
260        // routable ClientHello. Without this, a client trickling one byte
261        // per read forever holds the preread open on a sliding budget
262        // instead of the configured absolute one (sozu-proxy/sozu#1290).
263        if now >= deadline {
264            return self.decide(Output::Reject(RejectReason::Fragmented));
265        }
266
267        // NOTE: the `max_bytes` cap is deliberately NOT checked here. In
268        // passthrough the client keeps sending after its hello, so one read
269        // routinely delivers a complete ClientHello PLUS trailing bytes that
270        // push the window over the cap -- a complete hello must still route
271        // (regression guards: `complete_hello_with_trailing_bytes_over_cap_routes`
272        // below, plus the e2e coalesced-read test
273        // `test_tcp_sni_large_payload_coalesced_with_hello_delivered_intact`).
274        // The cap gates only the would-be-NeedMore paths below, via
275        // [`Self::need_more_or_too_large`].
276        let (content_offset, proxy_source) = if cfg.inbound_proxy {
277            match parse_v2_header(buf) {
278                Ok((rest, header)) => {
279                    let consumed = buf.len() - rest.len();
280                    debug_assert!(
281                        consumed <= buf.len(),
282                        "a parsed PROXY-v2 header cannot consume more than the buffer holds"
283                    );
284                    (consumed, header.addr.source())
285                }
286                Err(NomErr::Incomplete(_)) => {
287                    return self.need_more_or_too_large(cfg, buf, deadline);
288                }
289                Err(_) => return self.decide(Output::Reject(RejectReason::ProxyHeaderInvalid)),
290            }
291        } else {
292            (0, None)
293        };
294
295        match parser::parse_client_hello(&buf[content_offset..]) {
296            parser::ParseOutcome::NeedMore => self.need_more_or_too_large(cfg, buf, deadline),
297            parser::ParseOutcome::Reject(reason) => self.decide(Output::Reject(reason)),
298            parser::ParseOutcome::ClientHello {
299                sni,
300                alpn,
301                ech_present,
302            } => self.route(
303                cfg,
304                buf,
305                content_offset,
306                proxy_source,
307                sni,
308                alpn,
309                ech_present,
310            ),
311        }
312    }
313
314    fn on_timeout(&mut self, _now: Instant) -> Output {
315        // `now` is accepted for signature symmetry with `on_bytes`; once
316        // the deadline has fired, the fragmented-timeout verdict itself is
317        // time-independent.
318        if let Some(decided) = &self.decided {
319            return decided.clone();
320        }
321        self.decide(Output::Reject(RejectReason::Fragmented))
322    }
323
324    fn on_front_closed(&mut self) -> Output {
325        if let Some(decided) = &self.decided {
326            return decided.clone();
327        }
328        self.decide(Output::Reject(RejectReason::FrontClosed))
329    }
330
331    /// The byte cap, applied where the outcome would otherwise be
332    /// [`Output::NeedMore`]: a window that has already reached `max_bytes`
333    /// without yielding a complete PROXY header + ClientHello can never
334    /// grow into one within the cap, so waiting is pointless -- reject
335    /// `TooLarge` now. A COMPLETE hello never reaches this path and routes
336    /// regardless of total window length.
337    fn need_more_or_too_large(
338        &mut self,
339        cfg: &PrereadConfig<'_>,
340        buf: &[u8],
341        deadline: Instant,
342    ) -> Output {
343        if buf.len() >= cfg.max_bytes {
344            return self.decide(Output::Reject(RejectReason::TooLarge));
345        }
346        Output::NeedMore { deadline }
347    }
348
349    /// Normalize the extracted SNI, look it up, and resolve ALPN against
350    /// the matched route's entries.
351    #[allow(clippy::too_many_arguments)]
352    fn route(
353        &mut self,
354        cfg: &PrereadConfig<'_>,
355        buf: &[u8],
356        content_offset: usize,
357        proxy_source: Option<SocketAddr>,
358        sni_raw: Option<String>,
359        alpn: Vec<Vec<u8>>,
360        ech_present: bool,
361    ) -> Output {
362        let sni = sni_raw.map(normalize_sni);
363        let sni = match sni {
364            Some(s) if !s.is_empty() => s,
365            _ => {
366                let reason = if ech_present {
367                    RejectReason::EchOuterAbsent
368                } else {
369                    RejectReason::NoSni
370                };
371                return self.decide(Output::Reject(reason));
372            }
373        };
374
375        let Some((matched_key, entries)) = cfg
376            .routes
377            .domain_lookup(sni.as_bytes(), cfg.accept_wildcard)
378        else {
379            return self.decide(Output::Reject(RejectReason::SniUnmatched));
380        };
381
382        let Some((matched_alpn, cluster)) = resolve_alpn(entries, &alpn) else {
383            return self.decide(Output::Reject(RejectReason::AlpnUnmatched));
384        };
385
386        // Route determinism (pair): re-running the SAME lookup + ALPN
387        // resolution must yield the SAME entry -- routing is a pure
388        // function of (sni, accept_wildcard, alpn) at a given instant, so a
389        // divergence here means the trie or `resolve_alpn` has a hidden
390        // side effect or non-determinism (e.g. iterating a HashMap without
391        // a stable order).
392        #[cfg(debug_assertions)]
393        {
394            let (_, replay_entries) = cfg
395                .routes
396                .domain_lookup(sni.as_bytes(), cfg.accept_wildcard)
397                .expect("a route that just matched must match again");
398            let replay_entry = resolve_alpn(replay_entries, &alpn);
399            debug_assert_eq!(
400                replay_entry,
401                Some((matched_alpn, cluster)),
402                "route lookup + ALPN resolution must be deterministic for the same (sni, alpn)"
403            );
404        }
405        debug_assert!(
406            content_offset <= buf.len(),
407            "content_offset must never exceed the buffer it was derived from"
408        );
409
410        self.decide(Output::Routed {
411            cluster: cluster.to_owned(),
412            content_offset,
413            proxy_source,
414            sni,
415            alpn,
416            // The trie key is the route's configured pattern verbatim
417            // (lowercased at insert); `from_utf8_lossy` is defensive -- keys
418            // originate from config/IPC `String`s, never raw network bytes.
419            matched_sni_pattern: String::from_utf8_lossy(matched_key).into_owned(),
420            matched_alpn: matched_alpn.clone(),
421        })
422    }
423
424    /// Latch the first (and only) terminal verdict. Every subsequent input
425    /// replays it via the `self.decided` checks in `on_*` instead of
426    /// calling this again.
427    fn decide(&mut self, output: Output) -> Output {
428        debug_assert!(
429            self.decided.is_none(),
430            "decide() must be called at most once per lifetime -- the latch is monotonic"
431        );
432        debug_assert!(
433            !matches!(output, Output::NeedMore { .. }),
434            "only a terminal Output (Routed/Reject) may be latched via decide()"
435        );
436        self.decided = Some(output.clone());
437        // Renders the full `Output` (including, for a `Routed` verdict, the
438        // entire client-offered `alpn: Vec<Vec<u8>>`) via `Debug`,
439        // unbounded -- left as-is because `debug!` is compiled out of
440        // release builds entirely unless built with `--features
441        // logs-debug` (see `doc/configure.md`'s feature table), unlike the
442        // `info!` in `shell.rs::handle_output` (always compiled in) that
443        // this same hostile-input concern required bounding.
444        debug!(
445            "{} latched terminal preread decision: {:?}",
446            log_context!(self),
447            output
448        );
449        output
450    }
451
452    /// TigerStyle invariant sweep, run at both the head and tail of
453    /// [`Self::handle_input`] (mirrors `UdpManager::check_invariants` and
454    /// `UdpManager::debug_assert_invariants` in
455    /// `lib/src/protocol/udp/manager.rs`). Read-only; must never change
456    /// behavior.
457    #[cfg(debug_assertions)]
458    fn check_invariants(&self) {
459        // Positive: `NeedMore` is the only non-terminal `Output` variant, so
460        // the decided latch -- which only ever stores a TERMINAL verdict --
461        // must never hold one.
462        debug_assert!(
463            !matches!(self.decided, Some(Output::NeedMore { .. })),
464            "decided latch must never store NeedMore -- it is not a terminal verdict"
465        );
466        // Pair (positive + negative space): a latched SNI is always already
467        // normalized -- lowercase, no trailing dot -- because `route` never
468        // stores the raw wire-form SNI. The matched pattern is the trie KEY
469        // (lowercased at insert), and `TrieNode::domain_lookup` can return
470        // THREE shapes, not two: the concrete SNI itself (exact route), a
471        // `*.`-prefixed wildcard whose suffix the SNI ends with, and a key
472        // carrying one or more `/regex/` segments. The third was missing
473        // here, so any operator-configured SNI route with a regex segment
474        // -- `/a|b|c/.example.com` -- panicked this assertion on a debug
475        // build the first time it matched, even though routing itself was
476        // correct.
477        if let Some(Output::Routed {
478            sni,
479            matched_sni_pattern,
480            ..
481        }) = &self.decided
482        {
483            debug_assert_eq!(
484                sni,
485                &sni.to_ascii_lowercase(),
486                "latched SNI must already be lowercase-normalized"
487            );
488            debug_assert!(
489                !sni.ends_with('.'),
490                "latched SNI must not carry a trailing dot after normalization"
491            );
492            debug_assert_eq!(
493                matched_sni_pattern,
494                &matched_sni_pattern.to_ascii_lowercase(),
495                "latched matched_sni_pattern must already be lowercase (trie keys are lowercased at insert)"
496            );
497            debug_assert!(
498                matched_sni_pattern == sni
499                    || matched_sni_pattern
500                        .strip_prefix("*.")
501                        .is_some_and(|suffix| sni.ends_with(suffix))
502                    // A `/regex/` segment route. What is checked is the
503                    // LITERAL tail after the last `/`: the trie matches
504                    // right to left, so everything past the closing
505                    // delimiter of the rightmost segment is literal and the
506                    // SNI must end with it. The segments themselves are NOT
507                    // re-derived -- the trie has already matched them, and
508                    // re-running the grammar (and compiling a regex) inside
509                    // a debug assertion on the pre-read path would cost more
510                    // than it proves.
511                    || matched_sni_pattern
512                        .rfind('/')
513                        .is_some_and(|close| sni.ends_with(&matched_sni_pattern[close + 1..])),
514                "matched_sni_pattern must be the SNI itself (exact route), a wildcard the SNI \
515                 falls under, or a `/regex/` segment route whose literal tail the SNI ends with"
516            );
517        }
518    }
519
520    #[inline]
521    fn debug_assert_invariants(&self) {
522        #[cfg(debug_assertions)]
523        self.check_invariants();
524    }
525}
526
527/// Resolve a cluster from a matched route's `(AlpnMatcher, ClusterId)`
528/// entries against the client's offered ALPN protocols (in client
529/// preference order).
530///
531/// Walks the CLIENT's offer first (outer loop), so the first offered
532/// protocol that is a member of ANY entry's [`AlpnMatcher::OneOf`] wins --
533/// client preference order beats route entry order. Only once no offered
534/// protocol matched anything (including a client that offered no ALPN at
535/// all) does an [`AlpnMatcher::Any`] entry, if present, win as the
536/// catch-all.
537fn resolve_alpn<'r>(
538    entries: &'r [(AlpnMatcher, ClusterId)],
539    offered: &[Vec<u8>],
540) -> Option<(&'r AlpnMatcher, &'r ClusterId)> {
541    for protocol in offered {
542        for (matcher, cluster) in entries {
543            if let AlpnMatcher::OneOf(set) = matcher
544                && set.contains(protocol)
545            {
546                return Some((matcher, cluster));
547            }
548        }
549    }
550    entries
551        .iter()
552        .find(|(matcher, _)| matches!(matcher, AlpnMatcher::Any))
553        .map(|(matcher, cluster)| (matcher, cluster))
554}
555
556/// Normalize a wire-form SNI: ASCII-lowercase, then strip AT MOST one
557/// trailing dot (RFC 1034 §3.1 absolute-form: `example.com.` and
558/// `example.com` are the same host). Mirrors `lib/src/https.rs`'s
559/// `upgrade_handshake` SNI normalization so a TCP-preread route decision
560/// and the eventual TLS-terminating one agree on the same host.
561fn normalize_sni(mut sni: String) -> String {
562    sni.make_ascii_lowercase();
563    if sni.ends_with('.') {
564        sni.pop();
565    }
566    sni
567}
568
569/// Logging prefix for the pure TCP-preread core (tag `TCP-SNI`). Mirrors
570/// `protocol/udp/mod.rs`'s `log_context!`; honours the colored flag via
571/// [`ansi_palette`](sozu_command::logging::ansi_palette). The core has no
572/// socket of its own, so the prefix carries the decided/deadline latch
573/// state instead of a session id.
574#[allow(unused_macros)]
575macro_rules! log_context {
576    ($self:expr) => {{
577        let (open, reset, grey, gray, white) = sozu_command::logging::ansi_palette();
578        format!(
579            "[- - - -]\t{open}TCP-SNI{reset}\t{grey}Preread{reset}({gray}decided{reset}={white}{decided}{reset}, {gray}deadline_armed{reset}={white}{deadline}{reset})\t >>>",
580            open = open,
581            reset = reset,
582            grey = grey,
583            gray = gray,
584            white = white,
585            decided = $self.decided.is_some(),
586            deadline = $self.deadline.is_some(),
587        )
588    }};
589}
590
591/// Lightweight per-decision logging prefix (tag `TCP-SNI`), for call sites
592/// that only have the resolved SNI in scope, not a full `&SniPrereadCore`.
593/// Mirrors `protocol/udp/mod.rs`'s `log_context_lite!`; a pure core this
594/// small barely logs, so this macro currently has no call site of its own
595/// (kept for parity with the mirrored module and for future shell-side use).
596#[allow(unused_macros)]
597macro_rules! log_context_lite {
598    ($sni:expr) => {{
599        let (open, reset, grey, gray, white) = sozu_command::logging::ansi_palette();
600        format!(
601            "[- - - -]\t{open}TCP-SNI{reset}\t{grey}Route{reset}({gray}sni{reset}={white}{sni:?}{reset})\t >>>",
602            open = open,
603            reset = reset,
604            grey = grey,
605            gray = gray,
606            white = white,
607            sni = $sni,
608        )
609    }};
610}
611
612#[allow(unused_imports)]
613use {log_context, log_context_lite};
614
615#[cfg(test)]
616mod tests {
617    use std::net::{IpAddr, Ipv4Addr};
618
619    use super::*;
620    use crate::protocol::proxy_protocol::header::{Command, HeaderV2};
621
622    fn cfg<'t>(routes: &'t TrieNode<Vec<(AlpnMatcher, ClusterId)>>) -> PrereadConfig<'t> {
623        PrereadConfig {
624            routes,
625            inbound_proxy: false,
626            max_bytes: 16 * 1024,
627            timeout: Duration::from_secs(3),
628            accept_wildcard: true,
629        }
630    }
631
632    fn one_of(protocols: &[&[u8]]) -> AlpnMatcher {
633        AlpnMatcher::OneOf(protocols.iter().map(|p| p.to_vec()).collect())
634    }
635
636    fn hello(sni: &str, alpn: &[&[u8]]) -> Vec<u8> {
637        parser::build_client_hello_wire(&[
638            parser::encode_sni_extension(sni),
639            parser::encode_alpn_extension(alpn),
640        ])
641    }
642
643    fn hello_no_alpn(sni: &str) -> Vec<u8> {
644        parser::build_client_hello_wire(&[parser::encode_sni_extension(sni)])
645    }
646
647    fn feed(core: &mut SniPrereadCore, cfg: &PrereadConfig<'_>, buf: &[u8]) -> Output {
648        core.handle_input(
649            cfg,
650            Input::Bytes {
651                buf,
652                now: Instant::now(),
653            },
654        )
655    }
656
657    // ---- byte-replay + core-level RejectReason reachability ----------------
658
659    #[test]
660    fn byte_replay_untouched_through_the_core() {
661        let mut routes = TrieNode::root();
662        routes.domain_insert(
663            b"example.com".to_vec(),
664            vec![(AlpnMatcher::Any, "cluster-a".into())],
665        );
666        let cfg = cfg(&routes);
667        let wire = hello("example.com", &[]);
668        let before = wire.clone();
669        let mut core = SniPrereadCore::new();
670        let _ = feed(&mut core, &cfg, &wire);
671        assert_eq!(wire, before, "the core must never mutate the fed buffer");
672    }
673
674    /// The genuine `TooLarge` case: an INCOMPLETE hello whose accumulated
675    /// window has grown past the cap can never complete within it.
676    #[test]
677    fn too_large_is_reachable() {
678        let routes = TrieNode::root();
679        let mut small_cfg = cfg(&routes);
680        small_cfg.max_bytes = 8;
681        let wire = hello_no_alpn("example.com");
682        let mut core = SniPrereadCore::new();
683        // 10 bytes of a real hello: valid record header, body incomplete.
684        let out = feed(&mut core, &small_cfg, &wire[..10]);
685        assert_eq!(out, Output::Reject(RejectReason::TooLarge));
686    }
687
688    /// Cap boundary, both sides: a COMPLETE hello whose total length is
689    /// exactly `max_bytes` routes; an INCOMPLETE window that has reached
690    /// exactly `max_bytes` can never complete within the cap -- `TooLarge`.
691    #[test]
692    fn exact_cap_boundary_complete_routes_incomplete_rejects() {
693        let mut routes = TrieNode::root();
694        routes.domain_insert(
695            b"example.com".to_vec(),
696            vec![(AlpnMatcher::Any, "cluster-a".into())],
697        );
698        let wire = hello_no_alpn("example.com");
699
700        let mut exact_cfg = cfg(&routes);
701        exact_cfg.max_bytes = wire.len();
702        let mut core = SniPrereadCore::new();
703        match feed(&mut core, &exact_cfg, &wire) {
704            Output::Routed { cluster, .. } => assert_eq!(&*cluster, "cluster-a"),
705            other => panic!("expected Routed at exactly the cap, got {other:?}"),
706        }
707
708        let mut tight_cfg = cfg(&routes);
709        tight_cfg.max_bytes = wire.len() - 1;
710        let mut core = SniPrereadCore::new();
711        assert_eq!(
712            feed(&mut core, &tight_cfg, &wire[..wire.len() - 1]),
713            Output::Reject(RejectReason::TooLarge),
714            "an incomplete window at exactly the cap must reject TooLarge"
715        );
716    }
717
718    /// Regression, surfaced by the e2e coalesced-read test
719    /// (`test_tcp_sni_large_payload_coalesced_with_hello_delivered_intact`
720    /// in `e2e/src/tests/tcp_sni_tests.rs`): in TLS passthrough the client
721    /// keeps sending after its hello (rest of handshake / early data), so one
722    /// socket read routinely delivers a COMPLETE ClientHello PLUS trailing
723    /// bytes that push the window over `max_bytes`. The cap is a rejection
724    /// criterion ONLY while the hello is still incomplete -- a complete
725    /// hello must route regardless of total window length, and the trailing
726    /// bytes stay in the accumulator for byte-for-byte replay.
727    #[test]
728    fn complete_hello_with_trailing_bytes_over_cap_routes() {
729        let mut routes = TrieNode::root();
730        routes.domain_insert(
731            b"example.com".to_vec(),
732            vec![(AlpnMatcher::Any, "cluster-a".into())],
733        );
734        let wire = hello("example.com", &[b"h2"]);
735        let mut over = wire.clone();
736        over.extend_from_slice(&[0xAB; 64]); // post-hello handshake bytes
737
738        let mut small_cfg = cfg(&routes);
739        small_cfg.max_bytes = wire.len() + 16; // cap between hello and total
740        assert!(over.len() > small_cfg.max_bytes, "test setup: total > cap");
741
742        let mut core = SniPrereadCore::new();
743        match feed(&mut core, &small_cfg, &over) {
744            Output::Routed {
745                cluster,
746                content_offset,
747                sni,
748                ..
749            } => {
750                assert_eq!(&*cluster, "cluster-a");
751                assert_eq!(content_offset, 0);
752                assert_eq!(sni, "example.com");
753            }
754            other => panic!("a complete hello must route despite trailing bytes, got {other:?}"),
755        }
756    }
757
758    /// The PROXY-header-incomplete branch must respect the cap too: a
759    /// partial PROXY-v2 header whose window has already reached `max_bytes`
760    /// can never complete -- `TooLarge`, not an eternal `NeedMore`.
761    #[test]
762    fn proxy_incomplete_at_cap_is_too_large() {
763        let routes = TrieNode::root();
764        let mut proxy_cfg = cfg(&routes);
765        proxy_cfg.inbound_proxy = true;
766        proxy_cfg.max_bytes = 10;
767
768        let src = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 51234);
769        let dst = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9)), 443);
770        let proxy_header = HeaderV2::new(Command::Proxy, src, dst).into_bytes();
771
772        let mut core = SniPrereadCore::new();
773        let out = feed(&mut core, &proxy_cfg, &proxy_header[..10]);
774        assert_eq!(out, Output::Reject(RejectReason::TooLarge));
775    }
776
777    #[test]
778    fn empty_buffer_needs_more_and_sets_deadline() {
779        let routes = TrieNode::root();
780        let cfg = cfg(&routes);
781        let mut core = SniPrereadCore::new();
782        match feed(&mut core, &cfg, &[]) {
783            Output::NeedMore { .. } => {}
784            other => panic!("expected NeedMore on empty input, got {other:?}"),
785        }
786    }
787
788    #[test]
789    fn timeout_while_undecided_is_fragmented() {
790        let routes = TrieNode::root();
791        let cfg = cfg(&routes);
792        let mut core = SniPrereadCore::new();
793        // Partial ClientHello: never enough to decide.
794        let wire = hello_no_alpn("example.com");
795        let _ = feed(&mut core, &cfg, &wire[..wire.len() - 1]);
796        let out = core.handle_input(
797            &cfg,
798            Input::Timeout {
799                now: Instant::now(),
800            },
801        );
802        assert_eq!(out, Output::Reject(RejectReason::Fragmented));
803    }
804
805    #[test]
806    fn front_closed_before_decision_is_front_closed() {
807        let routes = TrieNode::root();
808        let cfg = cfg(&routes);
809        let mut core = SniPrereadCore::new();
810        let out = core.handle_input(&cfg, Input::FrontClosed);
811        assert_eq!(out, Output::Reject(RejectReason::FrontClosed));
812    }
813
814    #[test]
815    fn decided_latch_replays_the_same_terminal_forever() {
816        let mut routes = TrieNode::root();
817        routes.domain_insert(
818            b"example.com".to_vec(),
819            vec![(AlpnMatcher::Any, "cluster-a".into())],
820        );
821        let cfg = cfg(&routes);
822        let mut core = SniPrereadCore::new();
823        let wire = hello_no_alpn("example.com");
824        let first = feed(&mut core, &cfg, &wire);
825        // Feed again (even with clearly different bytes) -- must replay.
826        let second = feed(&mut core, &cfg, &[0xff; 3]);
827        assert_eq!(first, second);
828        let third = core.handle_input(
829            &cfg,
830            Input::Timeout {
831                now: Instant::now(),
832            },
833        );
834        assert_eq!(first, third);
835    }
836
837    #[test]
838    fn not_tls_is_reachable_through_the_core() {
839        let routes = TrieNode::root();
840        let cfg = cfg(&routes);
841        let mut core = SniPrereadCore::new();
842        let out = feed(&mut core, &cfg, &[0x16 + 1, 0x03, 0x03, 0x00, 0x00]);
843        assert_eq!(out, Output::Reject(RejectReason::NotTls));
844    }
845
846    #[test]
847    fn no_sni_is_reachable() {
848        let mut routes = TrieNode::root();
849        routes.domain_insert(
850            b"example.com".to_vec(),
851            vec![(AlpnMatcher::Any, "cluster-a".into())],
852        );
853        let cfg = cfg(&routes);
854        let mut core = SniPrereadCore::new();
855        let wire = parser::build_client_hello_wire(&[]);
856        assert_eq!(
857            feed(&mut core, &cfg, &wire),
858            Output::Reject(RejectReason::NoSni)
859        );
860    }
861
862    #[test]
863    fn ech_outer_absent_is_reachable() {
864        let mut routes = TrieNode::root();
865        routes.domain_insert(
866            b"example.com".to_vec(),
867            vec![(AlpnMatcher::Any, "cluster-a".into())],
868        );
869        let cfg = cfg(&routes);
870        let mut core = SniPrereadCore::new();
871        let wire = parser::build_client_hello_wire(&[parser::encode_extension(
872            0xfe0d,
873            &[0x00, 0x01, 0x02],
874        )]);
875        assert_eq!(
876            feed(&mut core, &cfg, &wire),
877            Output::Reject(RejectReason::EchOuterAbsent)
878        );
879    }
880
881    #[test]
882    fn sni_unmatched_is_reachable() {
883        let mut routes = TrieNode::root();
884        routes.domain_insert(
885            b"example.com".to_vec(),
886            vec![(AlpnMatcher::Any, "cluster-a".into())],
887        );
888        let cfg = cfg(&routes);
889        let mut core = SniPrereadCore::new();
890        let wire = hello_no_alpn("unknown.example.net");
891        assert_eq!(
892            feed(&mut core, &cfg, &wire),
893            Output::Reject(RejectReason::SniUnmatched)
894        );
895    }
896
897    /// SNI routing resolves against the same `TrieNode` the HTTP/HTTPS
898    /// router uses, so a regex hostname segment reaches TLS pre-read route
899    /// selection too — and the ungrouped `\A{s}\z` wrapper this trie carried
900    /// up to 2.2.1 left every alternation branch anchored at one end only
901    /// (sozu#1356). This is the SNI surface of that defect: `cfg.routes` is
902    /// operator-configured, so a route CAN carry a regex segment, unlike
903    /// `lib/src/tls.rs`'s certificate trie, whose keys are SAN names and
904    /// cannot contain a `/`.
905    ///
906    /// Three branches, because two cannot express the claim: with exactly
907    /// two, "first and last" is every branch there is. Ungrouped, the middle
908    /// branch keeps NEITHER anchor and matches as a bare substring anywhere
909    /// in the label.
910    ///
911    /// To SEE THIS RED: build `format!("\\A{segment}\\z")` in
912    /// `router::pattern_trie`'s `anchored_segment` and relax the
913    /// `debug_assert!` beside it. The first leak row is then `Routed` and the
914    /// assertion fails with `expected SniUnmatched for axx.example.com, got
915    /// Routed { cluster: "cluster-alt", …, matched_sni_pattern:
916    /// "/a|b|c/.example.com", … }`.
917    #[test]
918    fn an_alternating_regex_route_segment_matches_the_whole_sni_label_only() {
919        let mut routes = TrieNode::root();
920        routes.domain_insert(
921            b"/a|b|c/.example.com".to_vec(),
922            vec![(AlpnMatcher::Any, "cluster-alt".into())],
923        );
924        let cfg = cfg(&routes);
925
926        for whole in ["a.example.com", "b.example.com", "c.example.com"] {
927            let mut core = SniPrereadCore::new();
928            match feed(&mut core, &cfg, &hello_no_alpn(whole)) {
929                Output::Routed { cluster, .. } => assert_eq!(&*cluster, "cluster-alt"),
930                other => panic!("expected Routed for {whole}, got {other:?}"),
931            }
932        }
933
934        for leak in [
935            "axx.example.com",
936            "xxc.example.com",
937            "zzbzz.example.com",
938            "bzz.example.com",
939            "zzb.example.com",
940        ] {
941            let mut core = SniPrereadCore::new();
942            let out = feed(&mut core, &cfg, &hello_no_alpn(leak));
943            assert_eq!(
944                out,
945                Output::Reject(RejectReason::SniUnmatched),
946                "expected SniUnmatched for {leak}, got {out:?}",
947            );
948        }
949    }
950
951    #[test]
952    fn alpn_unmatched_is_reachable() {
953        let mut routes = TrieNode::root();
954        routes.domain_insert(
955            b"example.com".to_vec(),
956            vec![(one_of(&[b"h2"]), "cluster-a".into())],
957        );
958        let cfg = cfg(&routes);
959        let mut core = SniPrereadCore::new();
960        let wire = hello("example.com", &[b"http/1.1"]);
961        assert_eq!(
962            feed(&mut core, &cfg, &wire),
963            Output::Reject(RejectReason::AlpnUnmatched)
964        );
965    }
966
967    #[test]
968    fn proxy_header_invalid_is_reachable() {
969        let routes = TrieNode::root();
970        let mut proxy_cfg = cfg(&routes);
971        proxy_cfg.inbound_proxy = true;
972        let mut core = SniPrereadCore::new();
973        // 16 garbage bytes: long enough not to be `Incomplete` against the
974        // 12-byte signature tag, but not a valid PROXY-v2 header.
975        let out = feed(&mut core, &proxy_cfg, &[0xAAu8; 16]);
976        assert_eq!(out, Output::Reject(RejectReason::ProxyHeaderInvalid));
977    }
978
979    #[test]
980    fn malformed_record_is_reachable() {
981        let routes = TrieNode::root();
982        let cfg = cfg(&routes);
983        let mut core = SniPrereadCore::new();
984        // ContentType = handshake, declared length far above the 2^14 cap.
985        let record = [0x16, 0x03, 0x03, 0xFF, 0xFF];
986        assert_eq!(
987            feed(&mut core, &cfg, &record),
988            Output::Reject(RejectReason::MalformedRecord)
989        );
990    }
991
992    #[test]
993    fn malformed_handshake_is_reachable() {
994        let routes = TrieNode::root();
995        let cfg = cfg(&routes);
996        let mut core = SniPrereadCore::new();
997        let hs = parser::wrap_handshake(&[]); // msg_type=1, length=0 body -- fine
998        let mut bad_hs = hs;
999        bad_hs[0] = 2; // ServerHello, not ClientHello
1000        let record = parser::wrap_record(22, &bad_hs);
1001        assert_eq!(
1002            feed(&mut core, &cfg, &record),
1003            Output::Reject(RejectReason::MalformedHandshake)
1004        );
1005    }
1006
1007    // ---- fragmentation through the core ------------------------------------
1008
1009    #[test]
1010    fn drip_feed_needs_more_then_routes() {
1011        let mut routes = TrieNode::root();
1012        routes.domain_insert(
1013            b"example.com".to_vec(),
1014            vec![(AlpnMatcher::Any, "cluster-a".into())],
1015        );
1016        let cfg = cfg(&routes);
1017        let wire = hello("example.com", &[b"h2"]);
1018        let mut core = SniPrereadCore::new();
1019        for i in 0..wire.len() {
1020            match feed(&mut core, &cfg, &wire[..i]) {
1021                Output::NeedMore { .. } => {}
1022                other => panic!("prefix {i} of {} must NeedMore, got {other:?}", wire.len()),
1023            }
1024        }
1025        match feed(&mut core, &cfg, &wire) {
1026            Output::Routed { cluster, sni, .. } => {
1027                assert_eq!(&*cluster, "cluster-a");
1028                assert_eq!(sni, "example.com");
1029            }
1030            other => panic!("expected Routed at full length, got {other:?}"),
1031        }
1032    }
1033
1034    /// Regression from the sozu-proxy/sozu#1290 review: the preread
1035    /// deadline is an ABSOLUTE budget armed once on the first `Input::Bytes`
1036    /// (see [`SniPrereadCore::on_bytes`]), not a per-fragment idle timer.
1037    /// Feed valid, incomplete fragments with `now` stepping right up to
1038    /// (but never past) the latched deadline -- each must `NeedMore` with
1039    /// the SAME deadline, never a moved one. Then feed a fragment at/after
1040    /// the deadline: even though it happens to be a COMPLETE, otherwise-
1041    /// routable ClientHello, the core must reject `Fragmented` -- the exact
1042    /// same terminal verdict `Input::Timeout` produces -- rather than
1043    /// route. The latch must then keep replaying `Fragmented` forever,
1044    /// never `Routed`, for any further input.
1045    #[test]
1046    fn bytes_at_or_after_the_latched_deadline_reject_fragmented_without_moving_it() {
1047        let mut routes = TrieNode::root();
1048        routes.domain_insert(
1049            b"example.com".to_vec(),
1050            vec![(AlpnMatcher::Any, "cluster-a".into())],
1051        );
1052        let mut cfg = cfg(&routes);
1053        cfg.timeout = Duration::from_secs(5);
1054        let wire = hello_no_alpn("example.com");
1055        let incomplete = &wire[..wire.len() - 1];
1056
1057        let mut core = SniPrereadCore::new();
1058        let start = Instant::now();
1059
1060        // First fragment arms the deadline.
1061        let deadline = match core.handle_input(
1062            &cfg,
1063            Input::Bytes {
1064                buf: incomplete,
1065                now: start,
1066            },
1067        ) {
1068            Output::NeedMore { deadline } => deadline,
1069            other => panic!("expected NeedMore on the first incomplete fragment, got {other:?}"),
1070        };
1071        assert_eq!(deadline, start + cfg.timeout);
1072
1073        // A later fragment, still incomplete, still well before the
1074        // deadline: NeedMore again, deadline UNCHANGED.
1075        let almost_deadline = deadline - Duration::from_millis(1);
1076        match core.handle_input(
1077            &cfg,
1078            Input::Bytes {
1079                buf: incomplete,
1080                now: almost_deadline,
1081            },
1082        ) {
1083            Output::NeedMore { deadline: d } => {
1084                assert_eq!(d, deadline, "the latched deadline must never move")
1085            }
1086            other => panic!("expected NeedMore just before the deadline, got {other:?}"),
1087        }
1088
1089        // A COMPLETE, otherwise-routable hello arriving exactly AT the
1090        // deadline must still reject Fragmented -- the absolute deadline is
1091        // a hard cutoff, not merely advisory once bytes happen to complete.
1092        assert_eq!(
1093            core.handle_input(
1094                &cfg,
1095                Input::Bytes {
1096                    buf: &wire,
1097                    now: deadline,
1098                },
1099            ),
1100            Output::Reject(RejectReason::Fragmented),
1101            "a complete hello arriving at/after the latched deadline must reject Fragmented, not route"
1102        );
1103
1104        // The latch replays Fragmented forever, even further past the
1105        // deadline, even though `wire` remains a complete, valid,
1106        // routable hello.
1107        assert_eq!(
1108            core.handle_input(
1109                &cfg,
1110                Input::Bytes {
1111                    buf: &wire,
1112                    now: deadline + Duration::from_secs(1),
1113                },
1114            ),
1115            Output::Reject(RejectReason::Fragmented)
1116        );
1117    }
1118
1119    #[test]
1120    fn multi_record_client_hello_routes_through_the_core() {
1121        let mut routes = TrieNode::root();
1122        routes.domain_insert(
1123            b"split.example.com".to_vec(),
1124            vec![(AlpnMatcher::Any, "cluster-split".into())],
1125        );
1126        let cfg = cfg(&routes);
1127        let wire = hello("split.example.com", &[b"h2"]);
1128        let split = parser::split_into_records(&wire, 4);
1129        let mut core = SniPrereadCore::new();
1130        match feed(&mut core, &cfg, &split) {
1131            Output::Routed { cluster, .. } => assert_eq!(&*cluster, "cluster-split"),
1132            other => panic!("expected Routed for a multi-record ClientHello, got {other:?}"),
1133        }
1134    }
1135
1136    // ---- PROXY-v2 prefixed feeds --------------------------------------------
1137
1138    #[test]
1139    fn proxy_v2_prefix_yields_content_offset_and_source() {
1140        let mut routes = TrieNode::root();
1141        routes.domain_insert(
1142            b"example.com".to_vec(),
1143            vec![(AlpnMatcher::Any, "cluster-a".into())],
1144        );
1145        let mut proxy_cfg = cfg(&routes);
1146        proxy_cfg.inbound_proxy = true;
1147
1148        let src = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 51234);
1149        let dst = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9)), 443);
1150        let proxy_header = HeaderV2::new(Command::Proxy, src, dst).into_bytes();
1151        // The 28-byte IPv4 v2 header (16-byte fixed prefix + 12-byte IPv4
1152        // address block) precedes the ClientHello on the wire.
1153        assert_eq!(proxy_header.len(), 28);
1154
1155        let mut wire = proxy_header.clone();
1156        wire.extend_from_slice(&hello_no_alpn("example.com"));
1157
1158        let mut core = SniPrereadCore::new();
1159        match feed(&mut core, &proxy_cfg, &wire) {
1160            Output::Routed {
1161                cluster,
1162                content_offset,
1163                proxy_source,
1164                ..
1165            } => {
1166                assert_eq!(&*cluster, "cluster-a");
1167                assert_eq!(content_offset, proxy_header.len());
1168                assert_eq!(proxy_source, Some(src));
1169            }
1170            other => panic!("expected Routed behind a PROXY-v2 header, got {other:?}"),
1171        }
1172    }
1173
1174    /// A PROXY-v2 `LOCAL` header (ver/cmd `0x20`) describes a connection the
1175    /// upstream proxy originated itself; the HAProxy PROXY protocol
1176    /// specification §2.2 requires its address block to be discarded. Nothing
1177    /// on the wire forces `LOCAL` to be paired with `AF_UNSPEC`, so a crafted
1178    /// peer can send `LOCAL` with a fully populated `AF_INET` block -- here the
1179    /// very bytes `HeaderV2::new(Command::Local, ..)` emits (ver/cmd `0x20`,
1180    /// family `0x11`, 12 address bytes).
1181    ///
1182    /// `on_bytes` used to hand `header.addr.source()` straight to
1183    /// `Output::Routed`, and `TcpSession` takes that as the client
1184    /// (`tcp.rs`: `proxy_source.or(self.frontend_address)`), so the forged
1185    /// address reached the access logs, `X-Real-IP` and the
1186    /// `max_connections_per_ip` counters. `proxy_source` must be `None` so the
1187    /// session falls back to the real `peer_addr`.
1188    ///
1189    /// To SEE THIS RED: in `parse_v2_header`
1190    /// (`lib/src/protocol/proxy_protocol/parser.rs`), replace the
1191    /// `Command::Local => ProxyAddr::AfUnspec` arm of the `addr` binding with
1192    /// `parsed_addr` -- `proxy_source` is then `Some(203.0.113.7:51234)`.
1193    #[test]
1194    fn proxy_v2_local_command_attributes_no_source() {
1195        let mut routes = TrieNode::root();
1196        routes.domain_insert(
1197            b"example.com".to_vec(),
1198            vec![(AlpnMatcher::Any, "cluster-a".into())],
1199        );
1200        let mut proxy_cfg = cfg(&routes);
1201        proxy_cfg.inbound_proxy = true;
1202
1203        let forged_src = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 51234);
1204        let dst = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9)), 443);
1205        let proxy_header = HeaderV2::new(Command::Local, forged_src, dst).into_bytes();
1206        // Same 28 bytes on the wire as the PROXY case: only the command nibble
1207        // differs, so the framing assertions below stay meaningful.
1208        assert_eq!(proxy_header.len(), 28);
1209        assert_eq!(proxy_header[12], 0x20, "ver/cmd must be LOCAL");
1210        assert_eq!(proxy_header[13], 0x11, "family must be AF_INET over STREAM");
1211
1212        let mut wire = proxy_header.clone();
1213        wire.extend_from_slice(&hello_no_alpn("example.com"));
1214
1215        let mut core = SniPrereadCore::new();
1216        match feed(&mut core, &proxy_cfg, &wire) {
1217            Output::Routed {
1218                cluster,
1219                content_offset,
1220                proxy_source,
1221                ..
1222            } => {
1223                // Routing and framing are untouched: the header is still
1224                // consumed whole, only its address block is dropped.
1225                assert_eq!(&*cluster, "cluster-a");
1226                assert_eq!(content_offset, proxy_header.len());
1227                assert_eq!(
1228                    proxy_source, None,
1229                    "a LOCAL header must attribute no source, so TcpSession falls back to peer_addr"
1230                );
1231            }
1232            other => panic!("expected Routed behind a PROXY-v2 LOCAL header, got {other:?}"),
1233        }
1234    }
1235
1236    #[test]
1237    fn proxy_v2_prefix_drip_feed_needs_more_until_complete() {
1238        let mut routes = TrieNode::root();
1239        routes.domain_insert(
1240            b"example.com".to_vec(),
1241            vec![(AlpnMatcher::Any, "cluster-a".into())],
1242        );
1243        let mut proxy_cfg = cfg(&routes);
1244        proxy_cfg.inbound_proxy = true;
1245
1246        let src = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 51234);
1247        let dst = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9)), 443);
1248        let proxy_header = HeaderV2::new(Command::Proxy, src, dst).into_bytes();
1249        let mut wire = proxy_header.clone();
1250        wire.extend_from_slice(&hello_no_alpn("example.com"));
1251
1252        let mut core = SniPrereadCore::new();
1253        for i in 0..proxy_header.len() {
1254            match feed(&mut core, &proxy_cfg, &wire[..i]) {
1255                Output::NeedMore { .. } => {}
1256                other => panic!("PROXY-header prefix {i} must NeedMore, got {other:?}"),
1257            }
1258        }
1259    }
1260
1261    // ---- route semantics: SNI precedence + ALPN resolution ------------------
1262
1263    #[test]
1264    fn exact_sni_beats_wildcard_before_alpn() {
1265        let mut routes = TrieNode::root();
1266        routes.domain_insert(
1267            b"*.example.com".to_vec(),
1268            vec![(AlpnMatcher::Any, "wildcard-cluster".into())],
1269        );
1270        routes.domain_insert(
1271            b"a.example.com".to_vec(),
1272            vec![(one_of(&[b"h2"]), "exact-cluster".into())],
1273        );
1274        let cfg = cfg(&routes);
1275
1276        // The exact route wins even though its ALPN entry, taken alone,
1277        // would reject an `http/1.1`-only client -- exact-beats-wildcard is
1278        // resolved by the trie BEFORE ALPN is even considered.
1279        let wire = hello("a.example.com", &[b"http/1.1"]);
1280        let mut core = SniPrereadCore::new();
1281        assert_eq!(
1282            feed(&mut core, &cfg, &wire),
1283            Output::Reject(RejectReason::AlpnUnmatched)
1284        );
1285    }
1286
1287    #[test]
1288    fn wildcard_matches_one_level_not_two_not_apex() {
1289        let mut routes = TrieNode::root();
1290        routes.domain_insert(
1291            b"*.example.com".to_vec(),
1292            vec![(AlpnMatcher::Any, "wildcard-cluster".into())],
1293        );
1294        let cfg = cfg(&routes);
1295
1296        let mut core = SniPrereadCore::new();
1297        match feed(&mut core, &cfg, &hello_no_alpn("a.example.com")) {
1298            Output::Routed {
1299                cluster,
1300                sni,
1301                matched_sni_pattern,
1302                matched_alpn,
1303                ..
1304            } => {
1305                assert_eq!(&*cluster, "wildcard-cluster");
1306                // The matched-route identity is the trie KEY (the
1307                // configured wildcard pattern), NOT the client's concrete
1308                // SNI -- downstream tags keying depends on this.
1309                assert_eq!(sni, "a.example.com");
1310                assert_eq!(matched_sni_pattern, "*.example.com");
1311                assert_eq!(matched_alpn, AlpnMatcher::Any);
1312            }
1313            other => panic!("*.example.com must match a.example.com, got {other:?}"),
1314        }
1315
1316        let mut core = SniPrereadCore::new();
1317        assert_eq!(
1318            feed(&mut core, &cfg, &hello_no_alpn("a.b.example.com")),
1319            Output::Reject(RejectReason::SniUnmatched),
1320            "*.example.com must NOT match a.b.example.com"
1321        );
1322
1323        let mut core = SniPrereadCore::new();
1324        assert_eq!(
1325            feed(&mut core, &cfg, &hello_no_alpn("example.com")),
1326            Output::Reject(RejectReason::SniUnmatched),
1327            "*.example.com must NOT match the apex example.com"
1328        );
1329    }
1330
1331    #[test]
1332    fn alpn_client_preference_order_wins() {
1333        let mut routes = TrieNode::root();
1334        routes.domain_insert(
1335            b"example.com".to_vec(),
1336            vec![
1337                (one_of(&[b"http/1.1"]), "cluster-http11".into()),
1338                (one_of(&[b"h2"]), "cluster-h2".into()),
1339            ],
1340        );
1341        let cfg = cfg(&routes);
1342
1343        // Client prefers h2 first, even though the http/1.1 entry is FIRST
1344        // in the route table -- client order wins.
1345        let mut core = SniPrereadCore::new();
1346        match feed(
1347            &mut core,
1348            &cfg,
1349            &hello("example.com", &[b"h2", b"http/1.1"]),
1350        ) {
1351            Output::Routed { cluster, .. } => assert_eq!(&*cluster, "cluster-h2"),
1352            other => panic!("expected the client's first preference, got {other:?}"),
1353        }
1354
1355        let mut core = SniPrereadCore::new();
1356        match feed(
1357            &mut core,
1358            &cfg,
1359            &hello("example.com", &[b"http/1.1", b"h2"]),
1360        ) {
1361            Output::Routed { cluster, .. } => assert_eq!(&*cluster, "cluster-http11"),
1362            other => panic!("expected the client's first preference, got {other:?}"),
1363        }
1364    }
1365
1366    #[test]
1367    fn alpn_any_catch_all_and_no_alpn_client_matches_any_only() {
1368        let mut routes = TrieNode::root();
1369        routes.domain_insert(
1370            b"example.com".to_vec(),
1371            vec![
1372                (one_of(&[b"h2"]), "cluster-h2".into()),
1373                (AlpnMatcher::Any, "cluster-default".into()),
1374            ],
1375        );
1376        let cfg = cfg(&routes);
1377
1378        // Offers something nobody claims via OneOf -> falls through to Any.
1379        let mut core = SniPrereadCore::new();
1380        match feed(&mut core, &cfg, &hello("example.com", &[b"spdy/1"])) {
1381            Output::Routed { cluster, .. } => assert_eq!(&*cluster, "cluster-default"),
1382            other => panic!("expected the Any catch-all, got {other:?}"),
1383        }
1384
1385        // A client offering NO alpn extension at all matches only Any.
1386        let mut core = SniPrereadCore::new();
1387        match feed(&mut core, &cfg, &hello_no_alpn("example.com")) {
1388            Output::Routed { cluster, alpn, .. } => {
1389                assert_eq!(&*cluster, "cluster-default");
1390                assert!(alpn.is_empty());
1391            }
1392            other => panic!("expected the Any catch-all for a no-ALPN client, got {other:?}"),
1393        }
1394    }
1395
1396    #[test]
1397    fn no_any_and_no_matching_one_of_is_alpn_unmatched() {
1398        let mut routes = TrieNode::root();
1399        routes.domain_insert(
1400            b"example.com".to_vec(),
1401            vec![(one_of(&[b"h2"]), "cluster-h2".into())],
1402        );
1403        let cfg = cfg(&routes);
1404        let mut core = SniPrereadCore::new();
1405        assert_eq!(
1406            feed(&mut core, &cfg, &hello_no_alpn("example.com")),
1407            Output::Reject(RejectReason::AlpnUnmatched),
1408            "a no-ALPN client with no Any entry must be unmatched, not silently routed"
1409        );
1410    }
1411}