Skip to main content

featherbit/stream/
sni.rs

1//! SNI-based routing for TCP TLS passthrough.
2//!
3//! [`extract_sni`] peeks the server name out of a TLS ClientHello **without
4//! terminating TLS**, so the [`SniRouter`] can pick a backend pool by hostname
5//! and the raw bytes are relayed on to it. The parser operates on a possibly
6//! partial, fully untrusted buffer: it is bounds-checked at every step and
7//! **never panics** — a short buffer yields [`SniResult::Incomplete`] (read
8//! more), and anything malformed or non-ClientHello yields
9//! [`SniResult::NotPresent`] (fall back to the default pool).
10
11use std::sync::Arc;
12
13use crate::balancer::Balancer;
14
15/// Outcome of parsing a (possibly partial) TLS record for the SNI hostname.
16#[derive(Debug, PartialEq)]
17pub enum SniResult {
18    /// SNI hostname found (lowercased).
19    Found(String),
20    /// The ClientHello parsed fully but carried no SNI, or the bytes are not a
21    /// TLS ClientHello at all — route to the default pool.
22    NotPresent,
23    /// The buffer is shorter than a declared length — read more and retry.
24    Incomplete,
25}
26
27/// Big-endian `u16` at `buf[p..p+2]`, or `None` if out of bounds.
28fn be16(buf: &[u8], p: usize) -> Option<usize> {
29    let hi = *buf.get(p)? as usize;
30    let lo = *buf.get(p + 1)? as usize;
31    Some((hi << 8) | lo)
32}
33
34/// Big-endian `u24` at `buf[p..p+3]`, or `None` if out of bounds.
35fn be24(buf: &[u8], p: usize) -> Option<usize> {
36    let a = *buf.get(p)? as usize;
37    let b = *buf.get(p + 1)? as usize;
38    let c = *buf.get(p + 2)? as usize;
39    Some((a << 16) | (b << 8) | c)
40}
41
42/// Max TLS record length per RFC (2^14 + 256 headroom); a larger declared
43/// length is treated as malformed.
44const MAX_RECORD_LEN: usize = 16_640;
45
46/// Extracts the SNI hostname from the start of a TLS stream. Bounds-safe and
47/// panic-free; see the module docs for the [`SniResult`] contract.
48pub fn extract_sni(buf: &[u8]) -> SniResult {
49    // --- TLS record header (5 bytes) ---
50    if buf.len() < 5 {
51        return SniResult::Incomplete;
52    }
53    if buf[0] != 0x16 {
54        return SniResult::NotPresent; // not a handshake record
55    }
56    if buf[1] != 0x03 {
57        return SniResult::NotPresent; // not TLS 1.x
58    }
59    let record_len = match be16(buf, 3) {
60        Some(n) if n <= MAX_RECORD_LEN => n,
61        Some(_) => return SniResult::NotPresent, // absurd length → malformed
62        None => return SniResult::Incomplete,
63    };
64    let record_end = (5 + record_len).min(buf.len());
65
66    // --- Handshake header (4 bytes) ---
67    if record_end < 9 {
68        return SniResult::Incomplete;
69    }
70    if buf[5] != 0x01 {
71        return SniResult::NotPresent; // not a ClientHello
72    }
73    let hs_len = match be24(buf, 6) {
74        Some(n) => n,
75        None => return SniResult::Incomplete,
76    };
77    let hs_end = (9 + hs_len).min(record_end);
78
79    // --- ClientHello body ---
80    let mut p = 9;
81    // legacy_version (2) + random (32)
82    p = match advance(p, 2 + 32, hs_end) {
83        Some(p) => p,
84        None => return SniResult::Incomplete,
85    };
86    // session_id: 1-byte length + data
87    p = match skip_vec(buf, p, 1, hs_end) {
88        Skip::Ok(p) => p,
89        Skip::Short => return SniResult::Incomplete,
90    };
91    // cipher_suites: 2-byte length + data
92    p = match skip_vec(buf, p, 2, hs_end) {
93        Skip::Ok(p) => p,
94        Skip::Short => return SniResult::Incomplete,
95    };
96    // compression_methods: 1-byte length + data
97    p = match skip_vec(buf, p, 1, hs_end) {
98        Skip::Ok(p) => p,
99        Skip::Short => return SniResult::Incomplete,
100    };
101
102    // --- extensions block: 2-byte total length ---
103    if p == hs_end {
104        return SniResult::NotPresent; // ClientHello with no extensions
105    }
106    let ext_total = match be16(buf, p) {
107        Some(n) => n,
108        None => return SniResult::Incomplete,
109    };
110    p += 2;
111    let ext_end = (p + ext_total).min(hs_end);
112
113    // --- iterate extensions ---
114    while p + 4 <= ext_end {
115        let ext_type = match be16(buf, p) {
116            Some(n) => n,
117            None => return SniResult::Incomplete,
118        };
119        let ext_len = match be16(buf, p + 2) {
120            Some(n) => n,
121            None => return SniResult::Incomplete,
122        };
123        let body_start = p + 4;
124        let body_end = body_start + ext_len;
125        if body_end > buf.len() {
126            return SniResult::Incomplete; // extension body not fully arrived
127        }
128        if body_end > ext_end {
129            return SniResult::NotPresent; // declared length overruns the block
130        }
131        if ext_type == 0x0000 {
132            return parse_server_name(buf, body_start, body_end);
133        }
134        p = body_end;
135    }
136
137    SniResult::NotPresent
138}
139
140/// Parses the `server_name` extension body (`buf[start..end]`).
141fn parse_server_name(buf: &[u8], start: usize, end: usize) -> SniResult {
142    // server_name_list: 2-byte length
143    let list_len = match be16(buf, start) {
144        Some(n) => n,
145        None => return SniResult::Incomplete,
146    };
147    let mut q = start + 2;
148    let list_end = q + list_len;
149    if list_end > buf.len() {
150        return SniResult::Incomplete;
151    }
152    if list_end > end {
153        return SniResult::NotPresent;
154    }
155
156    // Scan entries for the first host_name (type 0x00).
157    while q + 3 <= list_end {
158        let name_type = buf[q];
159        let name_len = match be16(buf, q + 1) {
160            Some(n) => n,
161            None => return SniResult::Incomplete,
162        };
163        q += 3;
164        let name_end = q + name_len;
165        if name_end > buf.len() {
166            return SniResult::Incomplete;
167        }
168        if name_end > list_end {
169            return SniResult::NotPresent;
170        }
171        if name_type == 0x00 {
172            return match std::str::from_utf8(&buf[q..name_end]) {
173                Ok(s) if !s.is_empty() => SniResult::Found(s.to_ascii_lowercase()),
174                _ => SniResult::NotPresent,
175            };
176        }
177        q = name_end; // skip non-host_name entry
178    }
179
180    SniResult::NotPresent
181}
182
183/// Advances `p` by `n`, or `None` if that would exceed `ceiling`.
184fn advance(p: usize, n: usize, ceiling: usize) -> Option<usize> {
185    let next = p + n;
186    (next <= ceiling).then_some(next)
187}
188
189enum Skip {
190    Ok(usize),
191    Short,
192}
193
194/// Skips a length-prefixed vector: reads a `len_bytes`-wide (1 or 2) big-endian
195/// length at `p`, then skips that many bytes, all bounded by `ceiling`.
196fn skip_vec(buf: &[u8], p: usize, len_bytes: usize, ceiling: usize) -> Skip {
197    let len = match len_bytes {
198        1 => match buf.get(p) {
199            Some(&b) if p < ceiling => b as usize,
200            _ => return Skip::Short,
201        },
202        _ => match be16(buf, p) {
203            Some(n) if p + 2 <= ceiling => n,
204            _ => return Skip::Short,
205        },
206    };
207    match advance(p + len_bytes, len, ceiling) {
208        Some(next) => Skip::Ok(next),
209        None => Skip::Short,
210    }
211}
212
213/// An SNI match pattern: exact hostname or a single-label wildcard. Shared by
214/// the L4 stream router and the TLS multi-cert resolver.
215#[derive(Debug)]
216pub(crate) enum SniPattern {
217    Exact(String),
218    /// The suffix after `*.`; matches exactly one leading label.
219    Wildcard(String),
220}
221
222impl SniPattern {
223    pub(crate) fn parse(s: &str) -> Self {
224        let s = s.to_ascii_lowercase();
225        match s.strip_prefix("*.") {
226            Some(rest) => SniPattern::Wildcard(rest.to_string()),
227            None => SniPattern::Exact(s),
228        }
229    }
230
231    pub(crate) fn matches(&self, host: &str) -> bool {
232        let host = host.to_ascii_lowercase();
233        match self {
234            SniPattern::Exact(e) => *e == host,
235            SniPattern::Wildcard(base) => match host.strip_suffix(base.as_str()) {
236                // Exactly one leading label: the prefix ends with '.', is more
237                // than just ".", and has no interior dot.
238                Some(prefix) => {
239                    prefix.ends_with('.')
240                        && prefix.len() > 1
241                        && !prefix[..prefix.len() - 1].contains('.')
242                }
243                None => false,
244            },
245        }
246    }
247}
248
249/// Routes a TCP connection to a backend pool by its ClientHello SNI hostname,
250/// falling back to a default pool.
251pub struct SniRouter {
252    routes: Vec<(SniPattern, Arc<Balancer>)>,
253    default: Arc<Balancer>,
254}
255
256impl SniRouter {
257    /// Builds a router from `(server_name, pool)` pairs plus a default pool.
258    pub fn new(routes: Vec<(String, Arc<Balancer>)>, default: Arc<Balancer>) -> Self {
259        Self {
260            routes: routes
261                .into_iter()
262                .map(|(name, bal)| (SniPattern::parse(&name), bal))
263                .collect(),
264            default,
265        }
266    }
267
268    /// Whether any SNI routes are configured (if not, callers skip the peek).
269    pub fn has_sni_routes(&self) -> bool {
270        !self.routes.is_empty()
271    }
272
273    /// Selects the pool for `sni` (first matching route, else the default).
274    pub fn select(&self, sni: Option<&str>) -> &Arc<Balancer> {
275        if let Some(host) = sni {
276            for (pattern, balancer) in &self.routes {
277                if pattern.matches(host) {
278                    return balancer;
279                }
280            }
281        }
282        &self.default
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::balancer::{Strategy, Target};
290
291    /// Builds a minimal but well-formed TLS ClientHello record carrying `sni`.
292    fn build_client_hello(sni: &str) -> Vec<u8> {
293        build_client_hello_ext(Some(sni))
294    }
295
296    /// Builds a ClientHello with an optional SNI extension.
297    fn build_client_hello_ext(sni: Option<&str>) -> Vec<u8> {
298        let mut ext = Vec::new();
299        if let Some(sni) = sni {
300            let name = sni.as_bytes();
301            let mut sni_body = Vec::new();
302            let entry_len = 1 + 2 + name.len();
303            sni_body.extend_from_slice(&(entry_len as u16).to_be_bytes()); // list len
304            sni_body.push(0x00); // host_name
305            sni_body.extend_from_slice(&(name.len() as u16).to_be_bytes());
306            sni_body.extend_from_slice(name);
307            ext.extend_from_slice(&0x0000u16.to_be_bytes()); // type: server_name
308            ext.extend_from_slice(&(sni_body.len() as u16).to_be_bytes());
309            ext.extend_from_slice(&sni_body);
310        }
311
312        let mut body = Vec::new();
313        body.extend_from_slice(&[0x03, 0x03]); // legacy_version
314        body.extend_from_slice(&[0u8; 32]); // random
315        body.push(0x00); // session_id len 0
316        body.extend_from_slice(&2u16.to_be_bytes()); // cipher_suites len
317        body.extend_from_slice(&[0x00, 0x2f]); // one suite
318        body.push(0x01); // compression len
319        body.push(0x00); // null compression
320        body.extend_from_slice(&(ext.len() as u16).to_be_bytes()); // extensions len
321        body.extend_from_slice(&ext);
322
323        let mut hs = vec![0x01]; // ClientHello
324        hs.extend_from_slice(&(body.len() as u32).to_be_bytes()[1..]); // 3-byte len
325        hs.extend_from_slice(&body);
326
327        let mut rec = vec![0x16, 0x03, 0x01];
328        rec.extend_from_slice(&(hs.len() as u16).to_be_bytes());
329        rec.extend_from_slice(&hs);
330        rec
331    }
332
333    #[test]
334    fn test_extract_sni_found() {
335        let hello = build_client_hello("example.com");
336        assert_eq!(extract_sni(&hello), SniResult::Found("example.com".into()));
337    }
338
339    #[test]
340    fn test_extract_sni_lowercases() {
341        let hello = build_client_hello("Example.COM");
342        assert_eq!(extract_sni(&hello), SniResult::Found("example.com".into()));
343    }
344
345    #[test]
346    fn test_extract_sni_no_extension() {
347        let hello = build_client_hello_ext(None);
348        assert_eq!(extract_sni(&hello), SniResult::NotPresent);
349    }
350
351    #[test]
352    fn test_extract_sni_not_tls() {
353        assert_eq!(
354            extract_sni(&[0x17, 0x03, 0x01, 0x00, 0x05]),
355            SniResult::NotPresent
356        );
357        assert_eq!(extract_sni(&[0xff; 20]), SniResult::NotPresent);
358    }
359
360    #[test]
361    fn test_extract_sni_empty_and_truncated() {
362        assert_eq!(extract_sni(&[]), SniResult::Incomplete);
363        assert_eq!(extract_sni(&[0x16, 0x03]), SniResult::Incomplete);
364        let hello = build_client_hello("example.com");
365        // Cut off the last 5 bytes (mid server name).
366        assert_eq!(
367            extract_sni(&hello[..hello.len() - 5]),
368            SniResult::Incomplete
369        );
370    }
371
372    #[test]
373    fn test_extract_sni_never_panics_on_any_truncation() {
374        // Fuzz: every prefix length must return a variant without panicking.
375        let hello = build_client_hello("api.internal.example.com");
376        for n in 0..=hello.len() {
377            let _ = extract_sni(&hello[..n]);
378        }
379        // And full length still resolves to the SNI.
380        assert_eq!(
381            extract_sni(&hello),
382            SniResult::Found("api.internal.example.com".into())
383        );
384    }
385
386    #[test]
387    fn test_extract_sni_absurd_record_len() {
388        // Handshake byte + record length claiming 60000 bytes.
389        let mut buf = vec![0x16, 0x03, 0x01];
390        buf.extend_from_slice(&60000u16.to_be_bytes());
391        buf.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]);
392        assert_eq!(extract_sni(&buf), SniResult::NotPresent);
393    }
394
395    #[test]
396    fn test_pattern_exact() {
397        let p = SniPattern::parse("api.example.com");
398        assert!(p.matches("api.example.com"));
399        assert!(p.matches("API.EXAMPLE.COM"));
400        assert!(!p.matches("x.example.com"));
401        assert!(!p.matches("example.com"));
402    }
403
404    #[test]
405    fn test_pattern_wildcard_one_label() {
406        let p = SniPattern::parse("*.example.com");
407        assert!(p.matches("a.example.com"));
408        assert!(p.matches("A.Example.com"));
409        assert!(!p.matches("example.com")); // needs a leading label
410        assert!(!p.matches("a.b.example.com")); // exactly one label
411        assert!(!p.matches("xexample.com")); // not a label boundary
412    }
413
414    fn balancer(host: &str) -> Arc<Balancer> {
415        Arc::new(
416            Balancer::new(
417                vec![Target {
418                    host: host.into(),
419                    port: 443,
420                }],
421                Strategy::RoundRobin,
422            )
423            .unwrap(),
424        )
425    }
426
427    #[test]
428    fn test_router_select() {
429        let router = SniRouter::new(
430            vec![
431                ("a.example.com".into(), balancer("a")),
432                ("*.b.example.com".into(), balancer("b")),
433            ],
434            balancer("default"),
435        );
436        assert_eq!(router.select(Some("a.example.com")).target(0).host, "a");
437        assert_eq!(router.select(Some("x.b.example.com")).target(0).host, "b");
438        assert_eq!(
439            router.select(Some("unmatched.com")).target(0).host,
440            "default"
441        );
442        assert_eq!(router.select(None).target(0).host, "default");
443        assert!(router.has_sni_routes());
444    }
445}