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}