Skip to main content

sozu_lib/protocol/tcp_preread/
shell.rs

1//! I/O shell wiring the sans-io [`SniPrereadCore`] into the TCP session
2//! lifecycle (issue #1279).
3//!
4//! [`SniPreread`] is the socket-owning half of the split described in the
5//! module doc one level up: it accumulates bytes from the raw frontend
6//! socket into the session's own [`Checkout`] (NEVER `consume()`d while
7//! undecided -- byte-for-byte replay to the backend is the whole point),
8//! re-feeds the full accumulated window to [`SniPrereadCore::handle_input`]
9//! on every read, and -- once routed -- stays parked in this state through
10//! backend connect. This mirrors `RelayProxyProtocol`, not `Pipe`:
11//! `Pipe::set_back_socket` does not re-arm inherited buffer writes, so a
12//! premature switch to `Pipe` before the backend socket exists would strand
13//! the connect handshake.
14//!
15//! This module intentionally emits its OWN `tcp.sni_preread.*` decision
16//! metrics (routed / rejected.<reason>) and log lines, but does **not** own
17//! the `tcp.sni_preread.active` gauge lifecycle or the eventual state
18//! transition out of `SniPreread` -- both live in `lib/src/tcp.rs` (gauge:
19//! +1 on entry in `TcpSession::new_sni_preread`, -1 in
20//! `TcpSession::upgrade_sni_preread` on the upgrade exit or in
21//! `TcpSession::close`'s `StateMarker::SniPreread` arm on the
22//! reject/teardown exits; transition: `TcpSession::upgrade_sni_preread`),
23//! which alone has the `TcpProxy`/`TcpListener` context (per-cluster
24//! `proxy_protocol`, buffers, listener) needed to pick the right next state
25//! and guarantee the gauge is adjusted exactly once per session, on exactly
26//! one of its three exits (reject / upgrade / teardown).
27
28use std::{net::SocketAddr, time::Instant};
29
30use mio::{Token, net::TcpStream};
31use rusty_ulid::Ulid;
32
33use super::{AlpnMatcher, Input, Output, PrereadConfig, SniPrereadCore};
34use crate::{
35    Readiness, SessionMetrics, SessionResult,
36    metrics::names,
37    pool::Checkout,
38    socket::{SocketHandler, SocketResult},
39    sozu_command::{ready::Ready, state::ClusterId},
40};
41
42/// Per-session prefix for log lines emitted with a [`SniPreread`] in scope.
43/// Renders the canonical `TCP-SNI\tSession(...)\t >>>` envelope, reusing
44/// the `TCP-SNI` tag already established by the core's own `log_context!`
45/// (`tcp_preread/mod.rs`) so operators can grep core decisions and shell
46/// orchestration together.
47macro_rules! log_context {
48    ($self:expr) => {{
49        let (open, reset, grey, gray, white) = sozu_command::logging::ansi_palette();
50        format!(
51            "{open}TCP-SNI{reset}\t{grey}Session{reset}({gray}frontend{reset}={white}{frontend}{reset})\t >>>",
52            open = open,
53            reset = reset,
54            grey = grey,
55            gray = gray,
56            white = white,
57            frontend = $self.frontend_token.0,
58        )
59    }};
60}
61
62/// Maximum number of client-offered ALPN protocols the routed info-log
63/// (`handle_output`'s `Output::Routed` arm) renders individually. The wire
64/// parser (`tcp_preread::parser`) already bounds a ClientHello's ALPN offer
65/// to roughly 32K entries within a 64 KiB extension -- far fewer under the
66/// default 16 KiB preread cap -- but this `info!` is always compiled in
67/// (unlike `debug!`/`trace!`), so a hostile hello offering thousands of
68/// tiny protocol names could still make one log line allocate/render
69/// thousands of entries.
70const LOGGED_ALPN_LIMIT: usize = 8;
71
72/// Render at most [`LOGGED_ALPN_LIMIT`] client-offered ALPN protocols as a
73/// debug-list, appending a `"(+N more)"` suffix when the offer was
74/// truncated.
75fn render_alpn_for_log(alpn: &[Vec<u8>]) -> String {
76    let shown: Vec<String> = alpn
77        .iter()
78        .take(LOGGED_ALPN_LIMIT)
79        .map(|p| String::from_utf8_lossy(p).into_owned())
80        .collect();
81    if alpn.len() > LOGGED_ALPN_LIMIT {
82        format!("{shown:?} (+{} more)", alpn.len() - LOGGED_ALPN_LIMIT)
83    } else {
84        format!("{shown:?}")
85    }
86}
87
88/// Captured exactly once, when [`Output::Routed`] first fires. The core's
89/// own `decided` latch guarantees the SAME terminal verdict replays on every
90/// subsequent `handle_input` call, so [`SniPreread::outcome`] never changes
91/// once `Some`.
92#[derive(Debug, Clone)]
93pub struct RoutedOutcome {
94    pub cluster: ClusterId,
95    pub content_offset: usize,
96    pub proxy_source: Option<SocketAddr>,
97    pub sni: String,
98    pub alpn: Vec<Vec<u8>>,
99    /// The matched route's configured pattern (trie key) -- `*.example.com`
100    /// for a wildcard route, not the client's concrete SNI. Together with
101    /// `matched_alpn` this is the matched FRONTEND's identity, which
102    /// `TcpSession::upgrade_sni_preread` uses to rebuild the per-frontend
103    /// access-log tags key (`sni_tags_key` in `lib/src/tcp.rs`).
104    pub matched_sni_pattern: String,
105    /// Clone of the winning route entry's [`AlpnMatcher`].
106    pub matched_alpn: AlpnMatcher,
107}
108
109/// TCP session state that owns the frontend socket and the accumulating
110/// [`Checkout`] while [`SniPrereadCore`] decides a route. See the module doc
111/// for the full lifecycle and the exit-accounting contract for
112/// `tcp.sni_preread.active`.
113pub struct SniPreread<Front: SocketHandler> {
114    pub frontend: Front,
115    pub frontend_token: Token,
116    pub frontend_readiness: Readiness,
117    pub backend_readiness: Readiness,
118    pub backend: Option<TcpStream>,
119    pub backend_token: Option<Token>,
120    pub request_id: Ulid,
121    /// The session's own frontend accumulator, growing from wire offset 0.
122    /// NEVER `consume()`d while undecided.
123    pub frontend_buffer: Checkout,
124    /// The listener's `sni_preread_max_bytes` knob (default 16 384) clamped
125    /// to `frontend_buffer.capacity()` and floored at the 5-byte TLS
126    /// record-header minimum -- see `effective_sni_preread_max_bytes` in
127    /// `lib/src/tcp.rs`. Captured once at construction: the buffer's
128    /// capacity is fixed for its lifetime, so this never needs re-deriving.
129    effective_max_bytes: usize,
130    core: SniPrereadCore,
131    outcome: Option<RoutedOutcome>,
132    started_at: Instant,
133}
134
135impl<Front: SocketHandler> SniPreread<Front> {
136    /// Instantiate a new SniPreread SessionState with:
137    /// - frontend_interest: READABLE | HUP | ERROR
138    /// - backend_interest: HUP | ERROR (WRITABLE armed once routed)
139    pub fn new(
140        frontend: Front,
141        frontend_token: Token,
142        request_id: Ulid,
143        frontend_buffer: Checkout,
144        effective_max_bytes: usize,
145    ) -> Self {
146        SniPreread {
147            frontend,
148            frontend_token,
149            frontend_readiness: Readiness {
150                interest: Ready::READABLE | Ready::HUP | Ready::ERROR,
151                event: Ready::EMPTY,
152            },
153            backend_readiness: Readiness {
154                interest: Ready::HUP | Ready::ERROR,
155                event: Ready::EMPTY,
156            },
157            backend: None,
158            backend_token: None,
159            request_id,
160            frontend_buffer,
161            effective_max_bytes,
162            core: SniPrereadCore::new(),
163            outcome: None,
164            started_at: Instant::now(),
165        }
166    }
167
168    pub fn effective_max_bytes(&self) -> usize {
169        self.effective_max_bytes
170    }
171
172    pub fn outcome(&self) -> Option<&RoutedOutcome> {
173        self.outcome.as_ref()
174    }
175
176    pub fn is_routed(&self) -> bool {
177        self.outcome.is_some()
178    }
179
180    pub fn started_at(&self) -> Instant {
181        self.started_at
182    }
183
184    /// Bytes already accumulated from the frontend. Distinguishes a genuine
185    /// mid-preread abort (bytes were seen) from a bare TCP health-check
186    /// connect/FIN with no bytes at all (cf. `expect.rs`'s identical guard).
187    pub fn has_received_bytes(&self) -> bool {
188        self.frontend_buffer.available_data() > 0
189    }
190
191    pub fn front_socket(&self) -> &TcpStream {
192        self.frontend.socket_ref()
193    }
194
195    pub fn back_socket_mut(&mut self) -> Option<&mut TcpStream> {
196        self.backend.as_mut()
197    }
198
199    pub fn set_back_socket(&mut self, socket: TcpStream) {
200        self.backend = Some(socket);
201    }
202
203    pub fn set_back_token(&mut self, token: Token) {
204        self.backend_token = Some(token);
205    }
206
207    /// Read available bytes and feed them to the core. Emits the
208    /// `tcp.sni_preread.routed` / `tcp.sni_preread.rejected.<reason>`
209    /// metrics and log lines itself; the caller (`TcpSession::readable` in
210    /// `lib/src/tcp.rs`) only needs to react to the returned
211    /// [`SessionResult`] and, on a fresh route, sync `TcpSession::cluster_id`
212    /// from [`Self::outcome`].
213    pub fn readable(
214        &mut self,
215        metrics: &mut SessionMetrics,
216        cfg: &PrereadConfig<'_>,
217    ) -> SessionResult {
218        if self.outcome.is_some() {
219            // Already decided -- and deliberately not reading: bytes the
220            // client sends past the routed window must stay in the kernel
221            // socket buffer for the post-upgrade state to consume. Frontend
222            // READABLE interest stays armed until the upgrade swaps states
223            // (nothing quiesces it mid-connect), so a re-dispatch here is
224            // normal, not an error.
225            return SessionResult::Continue;
226        }
227
228        // Enforce `effective_max_bytes` as a HARD read bound, not merely an
229        // advisory the core applies on its NeedMore path. The frontend
230        // `Checkout` is sized to the pool buffer (`buffer_size`, 16 393
231        // bytes by default), far larger than a tight
232        // `sni_preread_max_bytes`; draining all of it would read an
233        // oversized-but-COMPLETE ClientHello in full, which the core then
234        // routes (it caps only would-be-NeedMore windows -- see
235        // `mod.rs::need_more_or_too_large`). Capping the read makes an
236        // over-cap hello reach the core INCOMPLETE with `buf.len() ==
237        // max_bytes`, the exact `TooLarge` reject condition. A hello that
238        // fits within the cap still routes, and any coalesced bytes past
239        // the cap stay in the kernel socket buffer for the `Pipe` to
240        // replay byte-for-byte.
241        let cap_remaining = self
242            .effective_max_bytes
243            .saturating_sub(self.frontend_buffer.available_data());
244        let space_before = self.frontend_buffer.available_space();
245        let read_len = space_before.min(cap_remaining);
246        let (sz, socket_result) = self
247            .frontend
248            .socket_read(&mut self.frontend_buffer.space()[..read_len]);
249        // The socket can only write into the free space it was handed.
250        debug_assert!(
251            sz <= read_len,
252            "socket_read cannot return more bytes than the capped space it was handed"
253        );
254
255        if sz > 0 {
256            let data_before = self.frontend_buffer.available_data();
257            self.frontend_buffer.fill(sz);
258            debug_assert_eq!(
259                self.frontend_buffer.available_data(),
260                data_before + sz,
261                "fill must expose exactly the bytes just read"
262            );
263
264            count!(names::backend::BYTES_IN, sz as i64);
265            metrics.bin += sz;
266
267            if socket_result == SocketResult::Error {
268                return self.front_gone(cfg);
269            }
270            if socket_result == SocketResult::WouldBlock {
271                self.frontend_readiness.event.remove(Ready::READABLE);
272            }
273
274            let output = self.core.handle_input(
275                cfg,
276                Input::Bytes {
277                    buf: self.frontend_buffer.data(),
278                    now: Instant::now(),
279                },
280            );
281            return self.handle_output(output);
282        }
283
284        match socket_result {
285            SocketResult::Error | SocketResult::Closed => self.front_gone(cfg),
286            SocketResult::WouldBlock => {
287                self.frontend_readiness.event.remove(Ready::READABLE);
288                SessionResult::Continue
289            }
290            SocketResult::Continue => SessionResult::Continue,
291        }
292    }
293
294    /// Feed the preread deadline firing. Always terminal: a fired deadline
295    /// resolves to `Reject(Fragmented)` (or replays an already-latched
296    /// verdict in the vanishingly unlikely race where routing and the
297    /// deadline land in the same tick -- the core's `decided` latch makes
298    /// that safe either way).
299    pub fn on_timeout(&mut self, cfg: &PrereadConfig<'_>) {
300        let output = self.core.handle_input(
301            cfg,
302            Input::Timeout {
303                now: Instant::now(),
304            },
305        );
306        // The frontend timeout always closes the session regardless; this
307        // call exists purely for its metric/log side effect.
308        let _ = self.handle_output(output);
309    }
310
311    /// Feed a frontend close observed before a routing decision (e.g. HUP
312    /// racing ahead of `readable`'s own 0-byte detection, dispatched from
313    /// `TcpSession::front_hup`). Mirrors `readable`'s
314    /// `SocketResult::Closed` handling: silent when no bytes were ever
315    /// received (the bare TCP health-check pattern), metered otherwise.
316    pub fn on_front_closed(&mut self, cfg: &PrereadConfig<'_>) {
317        let _ = self.front_gone(cfg);
318    }
319
320    /// Shared "the frontend is gone" handling for both the read-path
321    /// (`Ok(0)` / socket error) and the HUP-path (`on_front_closed`).
322    fn front_gone(&mut self, cfg: &PrereadConfig<'_>) -> SessionResult {
323        if self.has_received_bytes() {
324            let output = self.core.handle_input(cfg, Input::FrontClosed);
325            self.handle_output(output)
326        } else {
327            // Bare TCP health-check pattern (SYN/ACK/FIN, no bytes ever
328            // sent): silent, no rejection metric -- cf. `expect.rs`'s
329            // identical guard.
330            trace!(
331                "{} front socket closed with 0 bytes during SNI preread",
332                log_context!(self)
333            );
334            self.frontend_readiness.reset();
335            SessionResult::Close
336        }
337    }
338
339    fn handle_output(&mut self, output: Output) -> SessionResult {
340        match output {
341            Output::NeedMore { .. } => SessionResult::Continue,
342            Output::Routed {
343                cluster,
344                content_offset,
345                proxy_source,
346                sni,
347                alpn,
348                matched_sni_pattern,
349                matched_alpn,
350            } => {
351                debug_assert!(
352                    self.outcome.is_none(),
353                    "a route must be captured at most once per SniPreread lifetime"
354                );
355                info!(
356                    "{} SNI preread routed to cluster {} (sni={:?}, alpn={})",
357                    log_context!(self),
358                    cluster,
359                    sni,
360                    render_alpn_for_log(&alpn)
361                );
362                incr!(names::tcp::sni_preread::ROUTED);
363                // Arm backend-writable interest now so the FIRST backend
364                // connect-writable event (whenever `connect_to_backend`
365                // completes) immediately dispatches into `back_writable`
366                // without waiting for a second, unrelated readiness event --
367                // mirrors `RelayProxyProtocol::readable`'s identical arm
368                // once its own header has been parsed.
369                self.backend_readiness.interest.insert(Ready::WRITABLE);
370                self.outcome = Some(RoutedOutcome {
371                    cluster,
372                    content_offset,
373                    proxy_source,
374                    sni,
375                    alpn,
376                    matched_sni_pattern,
377                    matched_alpn,
378                });
379                SessionResult::Continue
380            }
381            Output::Reject(reason) => {
382                debug!("{} SNI preread rejected: {:?}", log_context!(self), reason);
383                incr!(names::tcp::sni_preread::rejected_name(reason));
384                self.frontend_readiness.reset();
385                self.backend_readiness.reset();
386                SessionResult::Close
387            }
388        }
389    }
390
391    /// The `back_writable` dispatch point: only ever reachable once routed
392    /// -- the not-yet-routed guard in
393    /// `TcpSession::attempt_backend_connect_if_needed` (`lib/src/tcp.rs`,
394    /// both call sites inside `ready_inner`) guarantees
395    /// `connect_to_backend` (and therefore any backend-writable event)
396    /// never runs before a route decision. The actual per-`ProxyProtocolConfig`
397    /// dispatch lives in `TcpSession::upgrade_sni_preread` (it needs the
398    /// `TcpProxy`/listener context this struct deliberately does not hold),
399    /// so this just signals the transition.
400    pub fn back_writable(&self) -> SessionResult {
401        debug_assert!(
402            self.outcome.is_some(),
403            "back_writable on SniPreread must only fire after a route decision"
404        );
405        SessionResult::Upgrade
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use std::time::Duration;
412
413    use super::*;
414    use crate::protocol::tcp_preread::{AlpnMatcher, RejectReason};
415    use crate::router::pattern_trie::TrieNode;
416
417    /// `names::tcp::sni_preread::rejected_name` must be a TOTAL function
418    /// over every `RejectReason` variant, each mapping to a DISTINCT dotted
419    /// name -- a new variant added to the core without a matching arm fails
420    /// to compile (see the `match` in `metrics/names.rs`), and this test
421    /// additionally guards against two variants silently sharing one name.
422    #[test]
423    fn every_reject_reason_has_a_distinct_metric_name() {
424        let reasons = [
425            RejectReason::NotTls,
426            RejectReason::MalformedRecord,
427            RejectReason::MalformedHandshake,
428            RejectReason::Fragmented,
429            RejectReason::TooLarge,
430            RejectReason::NoSni,
431            RejectReason::EchOuterAbsent,
432            RejectReason::SniUnmatched,
433            RejectReason::AlpnUnmatched,
434            RejectReason::ProxyHeaderInvalid,
435            RejectReason::FrontClosed,
436        ];
437        let mut names_seen = std::collections::HashSet::new();
438        for reason in reasons {
439            let name = names::tcp::sni_preread::rejected_name(reason);
440            assert!(
441                name.starts_with("tcp.sni_preread.rejected."),
442                "unexpected metric name shape for {reason:?}: {name}"
443            );
444            assert!(
445                names_seen.insert(name),
446                "two RejectReason variants mapped to the same metric name: {name}"
447            );
448        }
449    }
450
451    fn cfg(routes: &TrieNode<Vec<(AlpnMatcher, ClusterId)>>) -> PrereadConfig<'_> {
452        PrereadConfig {
453            routes,
454            inbound_proxy: false,
455            max_bytes: 16 * 1024,
456            timeout: Duration::from_secs(3),
457            accept_wildcard: true,
458        }
459    }
460
461    // ---- routed info-log ALPN rendering bound (sozu-proxy/sozu#1290) ----
462
463    #[test]
464    fn render_alpn_for_log_shows_everything_within_the_limit() {
465        let alpn: Vec<Vec<u8>> = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
466        assert_eq!(render_alpn_for_log(&alpn), r#"["h2", "http/1.1"]"#);
467    }
468
469    #[test]
470    fn render_alpn_for_log_truncates_past_the_limit_with_a_count_suffix() {
471        let alpn: Vec<Vec<u8>> = (0..(LOGGED_ALPN_LIMIT + 5))
472            .map(|i| i.to_string().into_bytes())
473            .collect();
474        let rendered = render_alpn_for_log(&alpn);
475        assert!(
476            rendered.ends_with("(+5 more)"),
477            "expected a truncation suffix for 5 entries past the limit, got {rendered:?}"
478        );
479        // Exactly `LOGGED_ALPN_LIMIT` entries rendered individually before
480        // the suffix -- not the full (limit + 5).
481        let shown_count = alpn
482            .iter()
483            .take(LOGGED_ALPN_LIMIT)
484            .filter(|p| {
485                let s = String::from_utf8_lossy(p).into_owned();
486                rendered.contains(&format!("{s:?}"))
487            })
488            .count();
489        assert_eq!(shown_count, LOGGED_ALPN_LIMIT);
490    }
491
492    #[test]
493    fn preread_config_helper_shape_is_sane() {
494        // Not a behavioral test of the core (already covered exhaustively
495        // in `tcp_preread/mod.rs`) -- just pins the `PrereadConfig` field
496        // shape this shell constructs against, so a field rename there is
497        // caught here too.
498        let routes = TrieNode::root();
499        let cfg = cfg(&routes);
500        assert!(!cfg.inbound_proxy);
501        assert_eq!(cfg.max_bytes, 16 * 1024);
502        assert!(cfg.accept_wildcard);
503    }
504
505    /// Regression guard for the SNI-preread read cap (sozu-proxy/sozu#1279):
506    /// `readable` must never pull more than
507    /// `effective_max_bytes` into the accumulator in a single read, even
508    /// though the backing buffer is far larger and the socket has far more
509    /// queued. Without the cap the shell drains the whole buffer, so an
510    /// oversized-but-COMPLETE ClientHello is read in full and ROUTED (the core
511    /// caps only would-be-NeedMore windows) instead of rejected `TooLarge` and
512    /// the connection closed.
513    #[test]
514    fn readable_caps_the_accumulator_at_effective_max_bytes() {
515        use std::io::Write as _;
516        use std::net::{TcpListener as StdTcpListener, TcpStream as StdTcpStream};
517
518        use mio::net::TcpStream as MioTcpStream;
519
520        use crate::pool::Pool;
521
522        let listener = StdTcpListener::bind("127.0.0.1:0").expect("bind test listener");
523        let addr = listener.local_addr().expect("listener local addr");
524        let mut client = StdTcpStream::connect(addr).expect("connect test client");
525        let (server, _) = listener.accept().expect("accept test server");
526        server.set_nonblocking(true).expect("server nonblocking");
527
528        // Flood far more than the tight cap, into a buffer far larger than it.
529        let flood = vec![0x16u8; 4096];
530        client.write_all(&flood).expect("write flood");
531        client.flush().ok();
532
533        let mut pool = Pool::with_capacity(1, 1, 16 * 1024);
534        let frontend_buffer = pool.checkout().expect("frontend buffer");
535        assert!(
536            frontend_buffer.available_space() > 4096,
537            "buffer must dwarf the cap for this test to be meaningful"
538        );
539
540        let effective_max_bytes = 16usize;
541        let mut preread = SniPreread::new(
542            MioTcpStream::from_std(server),
543            Token(0),
544            Ulid::generate(),
545            frontend_buffer,
546            effective_max_bytes,
547        );
548
549        // Keep the core cap consistent with the shell cap, exactly as
550        // `TcpListener::preread_config` does (both = effective_max_bytes).
551        let routes = TrieNode::root();
552        let cfg = PrereadConfig {
553            routes: &routes,
554            inbound_proxy: false,
555            max_bytes: effective_max_bytes,
556            timeout: Duration::from_secs(3),
557            accept_wildcard: true,
558        };
559        let mut metrics = SessionMetrics::new(Some(Duration::ZERO));
560
561        let result = preread.readable(&mut metrics, &cfg);
562        assert!(
563            preread.frontend_buffer.available_data() <= effective_max_bytes,
564            "readable accumulated {} bytes, past the {}-byte cap",
565            preread.frontend_buffer.available_data(),
566            effective_max_bytes
567        );
568        // A window that reached the cap without a complete hello rejects and
569        // closes -- it must never sit routed or spin needing more.
570        assert_eq!(
571            result,
572            SessionResult::Close,
573            "an over-cap preread window must reject-and-close"
574        );
575
576        drop(client);
577    }
578}