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