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}