Skip to main content

microsandbox_network/tcp/
proxy.rs

1//! Bidirectional TCP proxy: smoltcp socket ↔ channels ↔ tokio socket.
2//!
3//! Each outbound guest TCP connection gets a proxy task that opens a real
4//! TCP connection to the destination via tokio and relays data between the
5//! channel pair (connected to the smoltcp socket in the poll loop) and the
6//! real server.
7
8use std::borrow::Cow;
9use std::io;
10use std::net::{IpAddr, SocketAddr};
11use std::sync::Arc;
12use std::time::Duration;
13
14use bytes::Bytes;
15use tokio::io::{AsyncReadExt, AsyncWriteExt};
16use tokio::net::TcpStream;
17use tokio::sync::mpsc;
18
19use super::connection::ProxyConnectState;
20use super::upstream::UpstreamTcpTarget;
21use crate::netstack::shared::SharedState;
22use crate::policy::{EgressEvaluation, HostnameSource, NetworkPolicy, Protocol};
23use crate::secrets::config::{SecretsConfig, SecretsConfigExt, ViolationAction};
24use crate::secrets::handler::{
25    SecretsHandler, first_line_is_not_http_request, looks_like_http_request_prefix,
26};
27use crate::tls::proxy::TlsProxy;
28use crate::tls::sni;
29use crate::tls::state::TlsState;
30
31//--------------------------------------------------------------------------------------------------
32// Constants
33//--------------------------------------------------------------------------------------------------
34
35/// Buffer size for reading from the real server.
36const SERVER_READ_BUF_SIZE: usize = 16384;
37
38/// Max bytes buffered while reading the proxy's CONNECT response headers.
39const CONNECT_RESP_LIMIT: usize = 8192;
40
41/// Max bytes to buffer while peeking for the ClientHello's SNI.
42const PEEK_BUF_SIZE: usize = 16384;
43
44/// Upper bound on time spent buffering the first flight before
45/// falling back to a cache-only egress decision.
46const PEEK_BUDGET: Duration = Duration::from_secs(5);
47
48//--------------------------------------------------------------------------------------------------
49// Types
50//--------------------------------------------------------------------------------------------------
51
52#[derive(Debug)]
53struct ConnectRequest {
54    bytes: Vec<u8>,
55    header_end: usize,
56    target: ConnectTarget,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60struct ConnectTarget {
61    host: String,
62    port: u16,
63    expected_sni: Option<String>,
64}
65
66/// Per-connection TCP proxy task and the state it owns.
67pub(crate) struct TcpProxy {
68    guest_dst: SocketAddr,
69    connect_target: UpstreamTcpTarget,
70    from_smoltcp: mpsc::Receiver<Bytes>,
71    to_smoltcp: mpsc::Sender<Bytes>,
72    shared: Arc<SharedState>,
73    network_policy: Arc<NetworkPolicy>,
74    secrets: Arc<SecretsConfig>,
75    tls_state: Option<Arc<TlsState>>,
76    proxy_connect: Arc<ProxyConnectState>,
77}
78
79//--------------------------------------------------------------------------------------------------
80// Methods
81//--------------------------------------------------------------------------------------------------
82
83impl ConnectRequest {
84    fn header_bytes(&self) -> &[u8] {
85        &self.bytes[..self.header_end]
86    }
87
88    fn post_header_bytes(&self) -> &[u8] {
89        &self.bytes[self.header_end..]
90    }
91}
92
93impl ConnectTarget {
94    fn is_intercepted(&self, tls_state: &TlsState) -> bool {
95        tls_state.config.intercepted_ports.contains(&self.port)
96    }
97
98    fn guest_dst(&self, fallback: SocketAddr, shared: &SharedState) -> SocketAddr {
99        if let Ok(ip) = self.host.parse::<IpAddr>() {
100            return SocketAddr::new(ip, self.port);
101        }
102
103        if self.host.eq_ignore_ascii_case(crate::HOST_ALIAS) {
104            match fallback.ip() {
105                IpAddr::V4(_) => {
106                    if let Some(ip) = shared.gateway_ipv4() {
107                        return SocketAddr::new(IpAddr::V4(ip), self.port);
108                    }
109                }
110                IpAddr::V6(_) => {
111                    if let Some(ip) = shared.gateway_ipv6() {
112                        return SocketAddr::new(IpAddr::V6(ip), self.port);
113                    }
114                }
115            }
116            if let Some(ip) = shared.gateway_ipv4() {
117                return SocketAddr::new(IpAddr::V4(ip), self.port);
118            }
119            if let Some(ip) = shared.gateway_ipv6() {
120                return SocketAddr::new(IpAddr::V6(ip), self.port);
121            }
122        }
123
124        SocketAddr::new(fallback.ip(), self.port)
125    }
126}
127
128impl TcpProxy {
129    /// Build a proxy for a newly established guest TCP connection.
130    #[allow(clippy::too_many_arguments)]
131    pub(crate) fn new(
132        guest_dst: SocketAddr,
133        connect_target: UpstreamTcpTarget,
134        from_smoltcp: mpsc::Receiver<Bytes>,
135        to_smoltcp: mpsc::Sender<Bytes>,
136        shared: Arc<SharedState>,
137        network_policy: Arc<NetworkPolicy>,
138        secrets: Arc<SecretsConfig>,
139        tls_state: Option<Arc<TlsState>>,
140        proxy_connect: Arc<ProxyConnectState>,
141    ) -> Self {
142        Self {
143            guest_dst,
144            connect_target,
145            from_smoltcp,
146            to_smoltcp,
147            shared,
148            network_policy,
149            secrets,
150            tls_state,
151            proxy_connect,
152        }
153    }
154
155    /// Run the TCP proxy task to completion.
156    pub(crate) async fn run(self) {
157        let guest_dst = self.guest_dst;
158        let connect_dst = self.connect_target.primary();
159
160        if let Err(error) = self.try_run().await {
161            tracing::debug!(
162                dst = %connect_dst,
163                %guest_dst,
164                %error,
165                "TCP proxy task ended",
166            );
167        }
168    }
169
170    /// Drive the TCP proxy to completion, returning operational failures.
171    async fn try_run(self) -> io::Result<()> {
172        let Self {
173            guest_dst,
174            connect_target,
175            mut from_smoltcp,
176            to_smoltcp,
177            shared,
178            network_policy,
179            secrets,
180            tls_state,
181            proxy_connect,
182        } = self;
183
184        // Pre-connect peek is only for domain policy: the hostname has to be known
185        // before we dial upstream so a Deny never opens a connection. Secrets do
186        // *not* gate the connect, so they no longer force a peek here — that work is
187        // deferred to `classify_first_flight` after the socket is open, where it can
188        // run without stalling server-first protocols (see below).
189        let (mut initial_buf, sni) = if network_policy.has_domain_rules() {
190            peek_for_sni(&mut from_smoltcp, PEEK_BUF_SIZE, PEEK_BUDGET).await
191        } else {
192            (Vec::new(), None)
193        };
194
195        // Re-evaluate egress against the *guest* dst — the address the
196        // guest dialed, not the post-rewrite host-side address. SNI
197        // refines over-allow when the cache matched a shared CDN IP;
198        // CacheOnly is the non-TLS fallback path so Domain rules still
199        // gate plain HTTP / SSH / etc.
200        if network_policy.has_domain_rules() {
201            let source = match sni.as_deref() {
202                Some(name) => HostnameSource::Sni(name),
203                None => HostnameSource::CacheOnly,
204            };
205            match network_policy.evaluate_egress_with_source(
206                guest_dst,
207                Protocol::Tcp,
208                &shared,
209                source,
210            ) {
211                EgressEvaluation::Allow => {}
212                EgressEvaluation::Deny => {
213                    tracing::debug!(
214                        dst = %guest_dst,
215                        source = source.label(),
216                        "TCP egress denied by domain policy",
217                    );
218                    proxy_connect.mark_policy_denied();
219                    shared.proxy_wake.wake();
220                    return Ok(());
221                }
222                EgressEvaluation::DeferUntilHostname => {
223                    debug_assert!(false, "DeferUntilHostname leaked into TCP proxy task");
224                    proxy_connect.mark_policy_denied();
225                    shared.proxy_wake.wake();
226                    return Ok(());
227                }
228            }
229        }
230
231        // Peek for HTTP CONNECT before dialing upstream; hand off if detected.
232        if let Some(tls_state) = tls_state.clone() {
233            if initial_buf.is_empty() {
234                let (peeked, _) = peek_for_sni(&mut from_smoltcp, PEEK_BUF_SIZE, PEEK_BUDGET).await;
235                initial_buf = peeked;
236            }
237            if could_be_connect_request(&initial_buf) {
238                return handle_connect_tunnel(
239                    guest_dst,
240                    connect_target,
241                    initial_buf,
242                    from_smoltcp,
243                    to_smoltcp,
244                    shared,
245                    network_policy,
246                    tls_state,
247                    proxy_connect,
248                    None,
249                )
250                .await;
251            }
252        }
253
254        // Connect upstream *before* finishing the secrets-side classification. A
255        // server-first protocol (SSH, SMTP, a database) sends nothing until it has
256        // seen the server's banner; with the socket already open we can relay that
257        // banner while we wait, instead of burning the peek budget pre-connect.
258        let stream = connect_target.connect(&proxy_connect, &shared).await?;
259        let connect_dst = stream.peer_addr().unwrap_or(connect_target.primary());
260        let (mut server_rx, mut server_tx) = stream.into_split();
261
262        // Finish classifying the first flight (TLS vs plain HTTP) and, for
263        // plain-HTTP candidates, gather a full header block — without blocking the
264        // server→guest direction. When domain rules already peeked, `initial_buf`
265        // is reused and this is cheap; with no secrets it is skipped entirely
266        // (`is_tls` only matters for deciding whether to build the handler).
267        let want_headers = secrets.has_plain_http_candidates() || secrets.has_host_scoped_secrets();
268        let (initial_buf, is_tls) = if !secrets.secrets.is_empty() {
269            classify_first_flight(
270                initial_buf,
271                &mut from_smoltcp,
272                &mut server_rx,
273                &to_smoltcp,
274                &shared,
275                want_headers,
276                PEEK_BUF_SIZE,
277                PEEK_BUDGET,
278            )
279            .await?
280        } else {
281            (initial_buf, false)
282        };
283
284        if let Some(tls_state) = tls_state.clone()
285            && could_be_connect_request(&initial_buf)
286        {
287            // The pre-connect CONNECT peek can miss a client whose first bytes arrive
288            // after we dial upstream. Once classify_first_flight has captured that
289            // request, rejoin the already-open proxy socket and use the CONNECT path
290            // so intercepted tunnels still get TLS substitution and policy checks.
291            let proxy_stream = server_rx
292                .reunite(server_tx)
293                .map_err(|_| io::Error::other("failed to reunite proxy stream halves"))?;
294            return handle_connect_tunnel(
295                guest_dst,
296                connect_target,
297                initial_buf,
298                from_smoltcp,
299                to_smoltcp,
300                shared,
301                network_policy,
302                tls_state,
303                proxy_connect,
304                Some(proxy_stream),
305            )
306            .await;
307        }
308
309        let mut late_connect_state = tls_state;
310        let mut secrets_handler: Option<SecretsHandler> = if !secrets.secrets.is_empty() && !is_tls
311        {
312            Some(match extract_http_host(&initial_buf) {
313                Some(host) => {
314                    SecretsHandler::new_plain_http(&secrets, &host, guest_dst.ip(), &shared)
315                }
316                None => SecretsHandler::new_plain_http_invalid_host(&secrets),
317            })
318        } else {
319            None
320        };
321
322        // Replay the buffered first flight — run through secrets handler first.
323        if !initial_buf.is_empty() {
324            let out: Cow<[u8]> = match secrets_handler.as_mut() {
325                Some(h) => match h.substitute(&initial_buf) {
326                    // Borrow the input when nothing was substituted; only a chunk
327                    // that actually carries a placeholder is reallocated.
328                    Ok(cow) => cow,
329                    Err(action) => {
330                        tracing::warn!(dst = %connect_dst, violation = ?action, "secret violation in first flight");
331                        if matches!(action, ViolationAction::BlockAndTerminate) {
332                            shared.trigger_termination();
333                        }
334                        return Ok(());
335                    }
336                },
337                None => Cow::Borrowed(&initial_buf),
338            };
339            if !out.is_empty() {
340                if let Err(e) = server_tx.write_all(&out).await {
341                    tracing::debug!(dst = %connect_dst, error = %e, "replay of buffered first flight failed");
342                    return Ok(());
343                }
344                if let Err(e) = server_tx.flush().await {
345                    tracing::debug!(dst = %connect_dst, error = %e, "flush after first flight failed");
346                    return Ok(());
347                }
348            }
349        }
350
351        let mut server_buf = vec![0u8; SERVER_READ_BUF_SIZE];
352
353        // Bidirectional relay using tokio::select!.
354        //
355        // guest → server: receive from channel, write to server socket.
356        // server → guest: read from server socket, send via channel + wake poll.
357        let mut guest_eof = false;
358        loop {
359            tokio::select! {
360                // Guest → server: substitute placeholders before forwarding.
361                data = from_smoltcp.recv(), if !guest_eof => {
362                    match data {
363                        Some(bytes) => {
364                            if let Some(tls_state) = late_connect_state.take()
365                                && could_be_connect_request(&bytes)
366                            {
367                                // The first guest bytes can arrive after both peek
368                                // windows have completed. Nothing has been written
369                                // to the proxy socket yet, so this is still a valid
370                                // point to switch into CONNECT tunnel handling.
371                                let proxy_stream = server_rx
372                                    .reunite(server_tx)
373                                    .map_err(|_| io::Error::other("failed to reunite proxy stream halves"))?;
374                                return handle_connect_tunnel(
375                                    guest_dst,
376                                    connect_target,
377                                    bytes.to_vec(),
378                                    from_smoltcp,
379                                    to_smoltcp,
380                                    shared,
381                                    network_policy,
382                                    tls_state,
383                                    proxy_connect,
384                                    Some(proxy_stream),
385                                )
386                                .await;
387                            }
388                            // No handler (no secrets / TLS) is the common path: forward
389                            // the chunk borrowed, with no per-chunk allocation or copy.
390                            let out: Cow<[u8]> = match secrets_handler.as_mut() {
391                                Some(h) => match h.substitute(&bytes) {
392                                    Ok(cow) => cow,
393                                    Err(action) => {
394                                        tracing::warn!(dst = %connect_dst, violation = ?action, "secret violation");
395                                        if matches!(action, ViolationAction::BlockAndTerminate) {
396                                            shared.trigger_termination();
397                                        }
398                                        break;
399                                    }
400                                },
401                                None => Cow::Borrowed(&bytes),
402                            };
403                            if !out.is_empty() {
404                                if let Err(e) = server_tx.write_all(&out).await {
405                                    tracing::debug!(dst = %connect_dst, error = %e, "write to server failed");
406                                    break;
407                                }
408                                if let Err(e) = server_tx.flush().await {
409                                    tracing::debug!(dst = %connect_dst, error = %e, "flush to server failed");
410                                    break;
411                                }
412                            }
413                        }
414                        // Channel closed — the guest half-closed (FIN) or the
415                        // connection was torn down. Propagate the half-close:
416                        // stop sending upstream but keep relaying server →
417                        // guest until the server closes.
418                        None => {
419                            guest_eof = true;
420                            if server_tx.shutdown().await.is_err() {
421                                break;
422                            }
423                        }
424                    }
425                }
426
427                // Server → guest: no substitution — server never sends placeholders.
428                result = server_rx.read(&mut server_buf) => {
429                    match result {
430                        Ok(0) => break, // Server closed connection.
431                        Ok(n) => {
432                            // A server-first byte means this is not an HTTP CONNECT
433                            // tunnel to a proxy. Keep relaying normally afterward.
434                            late_connect_state = None;
435                            let data = Bytes::copy_from_slice(&server_buf[..n]);
436                            if to_smoltcp.send(data).await.is_err() {
437                                // Channel closed — poll loop dropped the receiver.
438                                break;
439                            }
440                            // Wake the poll thread so it writes data to the
441                            // smoltcp socket.
442                            shared.proxy_wake.wake();
443                        }
444                        Err(e) => {
445                            tracing::debug!(dst = %connect_dst, error = %e, "read from server failed");
446                            break;
447                        }
448                    }
449                }
450            }
451        }
452
453        Ok(())
454    }
455}
456
457//--------------------------------------------------------------------------------------------------
458// Functions
459//--------------------------------------------------------------------------------------------------
460
461/// Spawn a TCP proxy task for a newly established connection.
462///
463/// `guest_dst` is what the guest dialed — the address policy rules match
464/// against. `connect_dst` is the host-side address tokio actually dials.
465///
466/// `proxy_connect` is updated before the task exits so the connection
467/// tracker can decide between FIN (clean close) and RST (upstream
468/// connect failure).
469#[allow(clippy::too_many_arguments)]
470pub fn spawn_tcp_proxy(
471    handle: &tokio::runtime::Handle,
472    guest_dst: SocketAddr,
473    connect_dst: SocketAddr,
474    from_smoltcp: mpsc::Receiver<Bytes>,
475    to_smoltcp: mpsc::Sender<Bytes>,
476    shared: Arc<SharedState>,
477    network_policy: Arc<NetworkPolicy>,
478    secrets: Arc<SecretsConfig>,
479    tls_state: Option<Arc<TlsState>>,
480    proxy_connect: Arc<ProxyConnectState>,
481) {
482    let proxy = TcpProxy::new(
483        guest_dst,
484        UpstreamTcpTarget::direct(connect_dst),
485        from_smoltcp,
486        to_smoltcp,
487        shared,
488        network_policy,
489        secrets,
490        tls_state,
491        proxy_connect,
492    );
493
494    handle.spawn(proxy.run());
495}
496
497/// Forward an HTTP CONNECT tunnel: dial the proxy, splice the handshake,
498/// then hand the established stream to [`TlsProxy`] for TLS MITM.
499///
500/// `guest_dst` is what the guest dialed; `proxy_target` contains the rewritten
501/// loopback address the gateway actually connects to and its optional fallback.
502#[allow(clippy::too_many_arguments)]
503async fn handle_connect_tunnel(
504    guest_dst: SocketAddr,
505    proxy_target: UpstreamTcpTarget,
506    initial_buf: Vec<u8>,
507    mut from_smoltcp: mpsc::Receiver<Bytes>,
508    to_smoltcp: mpsc::Sender<Bytes>,
509    shared: Arc<SharedState>,
510    network_policy: Arc<NetworkPolicy>,
511    tls_state: Arc<TlsState>,
512    proxy_connect: Arc<ProxyConnectState>,
513    preconnected_proxy: Option<TcpStream>,
514) -> io::Result<()> {
515    let proxy_dst = proxy_target.primary();
516    let connect_req =
517        parse_connect_request(buffer_connect_request(initial_buf, &mut from_smoltcp).await?)?;
518
519    let connect_headers = match sanitize_connect_headers(
520        connect_req.header_bytes(),
521        &tls_state.secrets.load(),
522    ) {
523        Ok(headers) => headers,
524        Err(action) => {
525            tracing::warn!(dst = %proxy_dst, violation = ?action, "secret violation in CONNECT headers");
526            if matches!(action, ViolationAction::BlockAndTerminate) {
527                shared.trigger_termination();
528            }
529            return Ok(());
530        }
531    };
532
533    // Dial the proxy and forward the CONNECT request so it opens the tunnel.
534    let mut proxy_stream = match preconnected_proxy {
535        Some(stream) => stream,
536        None => proxy_target.connect(&proxy_connect, &shared).await?,
537    };
538
539    if !connect_req.target.is_intercepted(&tls_state) {
540        proxy_stream.write_all(&connect_headers).await?;
541        proxy_stream.flush().await?;
542        let (proxy_resp, header_end) = read_connect_response_headers(&mut proxy_stream).await?;
543        if to_smoltcp
544            .send(Bytes::copy_from_slice(&proxy_resp[..header_end]))
545            .await
546            .is_err()
547        {
548            return Ok(());
549        }
550        if !proxy_resp[header_end..].is_empty()
551            && to_smoltcp
552                .send(Bytes::copy_from_slice(&proxy_resp[header_end..]))
553                .await
554                .is_err()
555        {
556            return Ok(());
557        }
558        shared.proxy_wake.wake();
559        if !connect_response_is_success(&proxy_resp[..header_end]) {
560            proxy_connect.mark_connected();
561            return Ok(());
562        }
563        if !connect_req.post_header_bytes().is_empty() {
564            proxy_stream
565                .write_all(connect_req.post_header_bytes())
566                .await?;
567        }
568        proxy_stream.flush().await?;
569        proxy_connect.mark_connected();
570        return relay_connected_stream(proxy_stream, from_smoltcp, to_smoltcp, shared).await;
571    }
572
573    proxy_stream.write_all(&connect_headers).await?;
574    proxy_stream.flush().await?;
575
576    let (proxy_resp, header_end) = read_connect_response_headers(&mut proxy_stream).await?;
577    if !connect_response_is_success(&proxy_resp[..header_end]) {
578        return Err(io::Error::new(
579            io::ErrorKind::ConnectionRefused,
580            "proxy rejected CONNECT",
581        ));
582    }
583    if !proxy_resp[header_end..].is_empty() {
584        return Err(io::Error::new(
585            io::ErrorKind::InvalidData,
586            "proxy sent unexpected bytes after CONNECT response headers",
587        ));
588    }
589    proxy_connect.mark_connected();
590
591    if to_smoltcp
592        .send(Bytes::copy_from_slice(&proxy_resp[..header_end]))
593        .await
594        .is_err()
595    {
596        return Ok(());
597    }
598    shared.proxy_wake.wake();
599
600    let tls_seed = connect_req.post_header_bytes().to_vec();
601    let tls_guest_dst = connect_req.target.guest_dst(guest_dst, &shared);
602    let expected_sni = connect_req.target.expected_sni.clone();
603
604    TlsProxy::new(
605        tls_guest_dst,
606        proxy_target,
607        from_smoltcp,
608        to_smoltcp,
609        shared,
610        tls_state,
611        network_policy,
612        proxy_connect,
613    )
614    .with_upstream(proxy_stream)
615    .with_expected_sni(expected_sni)
616    .with_initial_buf(tls_seed)
617    .try_run()
618    .await
619}
620
621/// Relay an established TCP stream without inspecting or substituting bytes.
622async fn relay_connected_stream(
623    stream: TcpStream,
624    mut from_smoltcp: mpsc::Receiver<Bytes>,
625    to_smoltcp: mpsc::Sender<Bytes>,
626    shared: Arc<SharedState>,
627) -> io::Result<()> {
628    let (mut server_rx, mut server_tx) = stream.into_split();
629    let mut server_buf = vec![0u8; SERVER_READ_BUF_SIZE];
630
631    let mut guest_eof = false;
632    loop {
633        tokio::select! {
634            data = from_smoltcp.recv(), if !guest_eof => {
635                match data {
636                    Some(bytes) => {
637                        server_tx.write_all(&bytes).await?;
638                        server_tx.flush().await?;
639                    }
640                    // Guest half-closed (FIN): stop sending upstream but
641                    // keep relaying server → guest until the server closes.
642                    None => {
643                        guest_eof = true;
644                        if server_tx.shutdown().await.is_err() {
645                            break;
646                        }
647                    }
648                }
649            }
650            result = server_rx.read(&mut server_buf) => {
651                match result {
652                    Ok(0) => break,
653                    Ok(n) => {
654                        if to_smoltcp
655                            .send(Bytes::copy_from_slice(&server_buf[..n]))
656                            .await
657                            .is_err()
658                        {
659                            break;
660                        }
661                        shared.proxy_wake.wake();
662                    }
663                    Err(e) => return Err(e),
664                }
665            }
666        }
667    }
668
669    Ok(())
670}
671
672async fn buffer_connect_request(
673    mut buf: Vec<u8>,
674    from_smoltcp: &mut mpsc::Receiver<Bytes>,
675) -> io::Result<Vec<u8>> {
676    let timeout_fut = tokio::time::sleep(PEEK_BUDGET);
677    tokio::pin!(timeout_fut);
678
679    loop {
680        if !could_be_connect_request(&buf) {
681            return Err(io::Error::new(
682                io::ErrorKind::InvalidData,
683                "malformed CONNECT request prefix",
684            ));
685        }
686        if headers_end(&buf).is_some() {
687            return Ok(buf);
688        }
689        if buf.len() >= PEEK_BUF_SIZE {
690            return Err(io::Error::new(
691                io::ErrorKind::InvalidData,
692                "CONNECT request headers too large",
693            ));
694        }
695
696        tokio::select! {
697            biased;
698            _ = &mut timeout_fut => {
699                return Err(io::Error::new(
700                    io::ErrorKind::TimedOut,
701                    "timed out waiting for complete CONNECT request headers",
702                ));
703            }
704            data = from_smoltcp.recv() => match data {
705                Some(bytes) => {
706                    buf.extend_from_slice(&bytes);
707                }
708                None => {
709                    return Err(io::Error::new(
710                        io::ErrorKind::UnexpectedEof,
711                        "channel closed before complete CONNECT request headers",
712                    ));
713                }
714            }
715        }
716    }
717}
718
719async fn read_connect_response_headers(stream: &mut TcpStream) -> io::Result<(Vec<u8>, usize)> {
720    tokio::time::timeout(PEEK_BUDGET, async {
721        let mut proxy_resp = Vec::with_capacity(256);
722        let mut buf = [0u8; 4096];
723        loop {
724            let n = stream.read(&mut buf).await?;
725            if n == 0 {
726                return Err(io::Error::new(
727                    io::ErrorKind::UnexpectedEof,
728                    "proxy closed before sending CONNECT response",
729                ));
730            }
731            proxy_resp.extend_from_slice(&buf[..n]);
732            if let Some(end) = headers_end(&proxy_resp) {
733                return Ok((proxy_resp, end));
734            }
735            if proxy_resp.len() > CONNECT_RESP_LIMIT {
736                return Err(io::Error::new(
737                    io::ErrorKind::InvalidData,
738                    "proxy CONNECT response too large",
739                ));
740            }
741        }
742    })
743    .await
744    .map_err(|_| {
745        io::Error::new(
746            io::ErrorKind::TimedOut,
747            "timed out waiting for proxy CONNECT response",
748        )
749    })?
750}
751
752fn sanitize_connect_headers<'a>(
753    header_bytes: &'a [u8],
754    secrets: &SecretsConfig,
755) -> Result<Cow<'a, [u8]>, ViolationAction> {
756    if secrets.secrets.is_empty() {
757        return Ok(Cow::Borrowed(header_bytes));
758    }
759
760    let mut handler = SecretsHandler::new_plain_http_untrusted_metadata(secrets);
761    handler.substitute(header_bytes)
762}
763
764/// Returns the byte offset just past the `\r\n\r\n` header terminator, or `None`.
765fn headers_end(buf: &[u8]) -> Option<usize> {
766    buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
767}
768
769fn could_be_connect_request(buf: &[u8]) -> bool {
770    const PREFIX: &[u8] = b"CONNECT ";
771    if buf.is_empty() {
772        return false;
773    }
774    let n = buf.len().min(PREFIX.len());
775    buf[..n].eq_ignore_ascii_case(&PREFIX[..n])
776}
777
778fn parse_connect_request(bytes: Vec<u8>) -> io::Result<ConnectRequest> {
779    let header_end = headers_end(&bytes).ok_or_else(|| {
780        io::Error::new(
781            io::ErrorKind::InvalidData,
782            "incomplete CONNECT request headers",
783        )
784    })?;
785    let target = {
786        let request_line = bytes[..header_end]
787            .split(|&b| b == b'\n')
788            .next()
789            .unwrap_or(&[]);
790        let request_line = std::str::from_utf8(request_line)
791            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "CONNECT line is not UTF-8"))?
792            .trim_end_matches('\r');
793        let mut parts = request_line.split_ascii_whitespace();
794        let method = parts.next().unwrap_or_default();
795        let authority = parts.next().unwrap_or_default();
796        let version = parts.next().unwrap_or_default();
797        if !method.eq_ignore_ascii_case("CONNECT")
798            || authority.is_empty()
799            || !is_http_version(version)
800            || parts.next().is_some()
801        {
802            return Err(io::Error::new(
803                io::ErrorKind::InvalidData,
804                "malformed CONNECT request line",
805            ));
806        }
807        parse_connect_target(authority)?
808    };
809
810    Ok(ConnectRequest {
811        bytes,
812        header_end,
813        target,
814    })
815}
816
817fn parse_connect_target(authority: &str) -> io::Result<ConnectTarget> {
818    let authority = authority.trim();
819    let (host, port) = if let Some(rest) = authority.strip_prefix('[') {
820        let (host, rest) = rest.split_once(']').ok_or_else(|| {
821            io::Error::new(
822                io::ErrorKind::InvalidData,
823                "malformed CONNECT IPv6 authority",
824            )
825        })?;
826        let port = rest.strip_prefix(':').ok_or_else(|| {
827            io::Error::new(io::ErrorKind::InvalidData, "CONNECT authority missing port")
828        })?;
829        (host, port)
830    } else {
831        let (host, port) = authority.rsplit_once(':').ok_or_else(|| {
832            io::Error::new(io::ErrorKind::InvalidData, "CONNECT authority missing port")
833        })?;
834        if host.contains(':') {
835            return Err(io::Error::new(
836                io::ErrorKind::InvalidData,
837                "CONNECT IPv6 authority must be bracketed",
838            ));
839        }
840        (host, port)
841    };
842    let host = host.trim().trim_end_matches('.');
843    if host.is_empty() {
844        return Err(io::Error::new(
845            io::ErrorKind::InvalidData,
846            "CONNECT authority missing host",
847        ));
848    }
849    let port = port
850        .parse::<u16>()
851        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid CONNECT port"))?;
852    let expected_sni = host
853        .parse::<IpAddr>()
854        .is_err()
855        .then(|| host.to_ascii_lowercase());
856
857    Ok(ConnectTarget {
858        host: host.to_ascii_lowercase(),
859        port,
860        expected_sni,
861    })
862}
863
864fn is_http_version(version: &str) -> bool {
865    let Some(version) = version.strip_prefix("HTTP/") else {
866        return false;
867    };
868    let Some((major, minor)) = version.split_once('.') else {
869        return false;
870    };
871    !major.is_empty()
872        && !minor.is_empty()
873        && major.bytes().all(|b| b.is_ascii_digit())
874        && minor.bytes().all(|b| b.is_ascii_digit())
875}
876
877fn connect_response_is_success(headers: &[u8]) -> bool {
878    let Some(status_line) = headers.split(|&b| b == b'\n').next() else {
879        return false;
880    };
881    let Ok(status_line) = std::str::from_utf8(status_line) else {
882        return false;
883    };
884    let mut parts = status_line.trim_end_matches('\r').split_ascii_whitespace();
885    let version = parts.next().unwrap_or_default();
886    let status = parts.next().unwrap_or_default();
887    is_http_version(version)
888        && status.len() == 3
889        && status
890            .parse::<u16>()
891            .is_ok_and(|code| (200..300).contains(&code))
892}
893
894/// Extract the `Host:` header value from an already-buffered HTTP header block.
895///
896/// Returns `None` if:
897/// - The first byte is `0x16` (TLS — not HTTP)
898/// - The buffer does not yet contain `\r\n\r\n` (headers incomplete)
899/// - No `Host:` header is present
900///
901/// Strips port suffix, lowercases, and trims whitespace. Result is
902/// ready for byte-equal matching against `SecretEntry::allowed_hosts`.
903fn extract_http_host(buf: &[u8]) -> Option<String> {
904    if buf.first() == Some(&0x16) {
905        return None;
906    }
907    // Size the header pool to the buffer rather than a fixed array: a header
908    // line is at least four bytes (`a:\r\n`), so `len / 4` always covers the
909    // real header count, and `httparse` never reports `TooManyHeaders` (which
910    // would make a request with many headers look hostless). The first flight
911    // is capped at PEEK_BUF_SIZE, so this stays bounded.
912    let mut headers = vec![httparse::EMPTY_HEADER; (buf.len() / 4).max(16)];
913    let mut req = httparse::Request::new(&mut headers);
914    req.parse(buf).ok()?;
915    req.headers
916        .iter()
917        .find(|h| h.name.eq_ignore_ascii_case("host"))
918        .and_then(|h| std::str::from_utf8(h.value).ok())
919        .map(|v| {
920            let host = v.trim();
921            // Strip port suffix.
922            host.rsplit_once(':')
923                .map(|(h, _)| h)
924                .unwrap_or(host)
925                .to_ascii_lowercase()
926        })
927        .filter(|h| !h.is_empty())
928}
929
930/// Finish classifying the guest's first flight after the upstream socket is
931/// open, returning the (possibly extended) first-flight buffer and whether it
932/// is a TLS record.
933///
934/// `buf` carries whatever a pre-connect domain-rule peek already captured; when
935/// it is non-empty the TLS/plain decision is already settled and only header
936/// top-up runs. `want_headers` is set when at least one secret can be
937/// substituted over plain HTTP (`SecretsConfig::has_plain_http_candidates`); it
938/// makes the peek keep reading a non-TLS flight until `\r\n\r\n` so
939/// [`extract_http_host`] sees a complete header block.
940///
941/// Crucially, this relays server→guest while it waits. Server-first protocols
942/// (SSH, SMTP, databases) send nothing until they have seen the server's
943/// banner; draining the server side here lets the banner reach the guest
944/// immediately, so the guest's eventual first flight — not a 5s timeout — is
945/// what ends the peek.
946#[allow(clippy::too_many_arguments)]
947async fn classify_first_flight(
948    mut buf: Vec<u8>,
949    from_smoltcp: &mut mpsc::Receiver<Bytes>,
950    server_rx: &mut tokio::net::tcp::OwnedReadHalf,
951    to_smoltcp: &mpsc::Sender<Bytes>,
952    shared: &SharedState,
953    want_headers: bool,
954    max: usize,
955    budget: Duration,
956) -> io::Result<(Vec<u8>, bool)> {
957    let mut server_buf = vec![0u8; SERVER_READ_BUF_SIZE];
958    let timeout_fut = tokio::time::sleep(budget);
959    tokio::pin!(timeout_fut);
960
961    loop {
962        // Stop as soon as the protocol class is known and — for plain-HTTP
963        // candidates — a full header block has arrived. Bail the moment a
964        // non-TLS flight stops looking like an HTTP request so non-HTTP
965        // protocols (SSH, Postgres) aren't withheld from upstream for the
966        // whole budget while we wait for a `\r\n\r\n` that never comes.
967        if !buf.is_empty() {
968            let is_tls = buf.first() == Some(&0x16);
969            let not_http = !is_tls
970                && (!looks_like_http_request_prefix(&buf) || first_line_is_not_http_request(&buf));
971            let done = !want_headers
972                || is_tls
973                || not_http
974                || buf.len() >= max
975                || buf.windows(4).any(|w| w == b"\r\n\r\n");
976            if done {
977                return Ok((buf, is_tls));
978            }
979        }
980
981        tokio::select! {
982            biased;
983            _ = &mut timeout_fut => {
984                let is_tls = buf.first() == Some(&0x16);
985                return Ok((buf, is_tls));
986            }
987            // Guest → buffer (not forwarded here; the caller replays it once the
988            // handler is built, so substitution applies to the first flight too).
989            guest = from_smoltcp.recv() => match guest {
990                Some(bytes) => buf.extend_from_slice(&bytes),
991                None => {
992                    let is_tls = buf.first() == Some(&0x16);
993                    return Ok((buf, is_tls));
994                }
995            },
996            // Server → guest: relay immediately so a server-first banner is never
997            // held hostage by the peek.
998            server = server_rx.read(&mut server_buf) => match server {
999                Ok(0) => {
1000                    let is_tls = buf.first() == Some(&0x16);
1001                    return Ok((buf, is_tls));
1002                }
1003                Ok(n) => {
1004                    let data = Bytes::copy_from_slice(&server_buf[..n]);
1005                    if to_smoltcp.send(data).await.is_err() {
1006                        let is_tls = buf.first() == Some(&0x16);
1007                        return Ok((buf, is_tls));
1008                    }
1009                    shared.proxy_wake.wake();
1010                }
1011                Err(e) => return Err(e),
1012            },
1013        }
1014    }
1015}
1016
1017/// Buffer the first flight until SNI can be extracted, or until one
1018/// of the bail-out conditions hits (channel close, buffer cap,
1019/// timeout). Never errors; non-TLS / slow / malformed input all
1020/// fall through to `None`.
1021///
1022/// On hit, the SNI is canonicalized (lowercase + trim trailing dot)
1023/// for byte-equal matching against rule destinations. The returned
1024/// buffer must be replayed verbatim to upstream before the caller
1025/// starts its relay loop.
1026async fn peek_for_sni(
1027    rx: &mut mpsc::Receiver<Bytes>,
1028    max: usize,
1029    budget: Duration,
1030) -> (Vec<u8>, Option<String>) {
1031    let mut buf = Vec::with_capacity(PEEK_BUF_SIZE.min(8192));
1032    let timeout_fut = tokio::time::sleep(budget);
1033    tokio::pin!(timeout_fut);
1034
1035    let raw_sni = loop {
1036        tokio::select! {
1037            biased;
1038            _ = &mut timeout_fut => break None,
1039            data = rx.recv() => {
1040                match data {
1041                    Some(bytes) => {
1042                        buf.extend_from_slice(&bytes);
1043                        // First byte of a TLS record is the ContentType;
1044                        // 0x16 is handshake. Anything else can't be a
1045                        // ClientHello, so don't burn the full budget on
1046                        // plain HTTP / SSH / etc.
1047                        if buf.first() != Some(&0x16) {
1048                            break None;
1049                        }
1050                        if let Some(name) = sni::extract_sni(&buf) {
1051                            break Some(name);
1052                        }
1053                        if buf.len() >= max {
1054                            break None;
1055                        }
1056                    }
1057                    None => break None,
1058                }
1059            }
1060        }
1061    };
1062
1063    let canonical = raw_sni.map(|s| s.trim_end_matches('.').to_ascii_lowercase());
1064    (buf, canonical)
1065}
1066
1067//--------------------------------------------------------------------------------------------------
1068// Tests
1069//--------------------------------------------------------------------------------------------------
1070
1071#[cfg(test)]
1072mod tests {
1073    use super::*;
1074
1075    /// Synthetic TLS ClientHello carrying SNI `example.com`. Bytes
1076    /// borrowed from `tls::sni` test fixtures so the parser sees a
1077    /// well-formed record.
1078    fn synthetic_client_hello(sni: &str) -> Vec<u8> {
1079        // Minimal but valid TLS 1.2 ClientHello with one SNI entry.
1080        // Layout: record header (5) + handshake header (4) + body.
1081        let host_bytes = sni.as_bytes();
1082        let host_len = host_bytes.len() as u16;
1083        let server_name_list_len = 3 + host_len; // type(1) + len(2) + host
1084        let extension_data_len = 2 + server_name_list_len; // list-len(2) + list
1085        let extensions_total = 4 + extension_data_len; // type(2) + len(2) + data
1086
1087        let mut body = Vec::new();
1088        // Client version
1089        body.extend_from_slice(&[0x03, 0x03]);
1090        // Random (32 bytes)
1091        body.extend_from_slice(&[0u8; 32]);
1092        // Session id length + (empty)
1093        body.push(0);
1094        // Cipher suites length + one cipher
1095        body.extend_from_slice(&[0x00, 0x02, 0x00, 0x2f]);
1096        // Compression methods length + null
1097        body.extend_from_slice(&[0x01, 0x00]);
1098        // Extensions length
1099        body.extend_from_slice(&extensions_total.to_be_bytes());
1100        // SNI extension: type 0x0000
1101        body.extend_from_slice(&[0x00, 0x00]);
1102        body.extend_from_slice(&extension_data_len.to_be_bytes());
1103        body.extend_from_slice(&server_name_list_len.to_be_bytes());
1104        body.push(0x00); // host_name type
1105        body.extend_from_slice(&host_len.to_be_bytes());
1106        body.extend_from_slice(host_bytes);
1107
1108        let handshake_len = body.len() as u32;
1109        let mut hs = Vec::new();
1110        hs.push(0x01); // ClientHello
1111        hs.extend_from_slice(&handshake_len.to_be_bytes()[1..]); // 24-bit length
1112        hs.extend_from_slice(&body);
1113
1114        let record_len = hs.len() as u16;
1115        let mut record = Vec::new();
1116        record.extend_from_slice(&[0x16, 0x03, 0x01]); // Handshake, TLS 1.0
1117        record.extend_from_slice(&record_len.to_be_bytes());
1118        record.extend_from_slice(&hs);
1119
1120        record
1121    }
1122
1123    #[test]
1124    fn could_be_connect_request_matches_split_prefixes_only() {
1125        assert!(could_be_connect_request(b"C"));
1126        assert!(could_be_connect_request(b"connect "));
1127        assert!(could_be_connect_request(b"CONNECT example.com:443"));
1128        assert!(!could_be_connect_request(b"CLIENT"));
1129        assert!(!could_be_connect_request(b"GET / HTTP/1.1\r\n"));
1130    }
1131
1132    #[tokio::test]
1133    async fn buffer_connect_request_reads_split_headers() {
1134        let (tx, mut rx) = mpsc::channel(4);
1135        tx.send(Bytes::from_static(b"NECT example.com:443 HTTP/1.1\r\n"))
1136            .await
1137            .unwrap();
1138        tx.send(Bytes::from_static(b"Host: example.com\r\n\r\n"))
1139            .await
1140            .unwrap();
1141        drop(tx);
1142
1143        let buffered = buffer_connect_request(b"CON".to_vec(), &mut rx)
1144            .await
1145            .unwrap();
1146        let parsed = parse_connect_request(buffered).unwrap();
1147
1148        assert_eq!(parsed.target.host, "example.com");
1149        assert_eq!(parsed.target.port, 443);
1150        assert_eq!(parsed.target.expected_sni.as_deref(), Some("example.com"));
1151        assert!(parsed.post_header_bytes().is_empty());
1152    }
1153
1154    #[test]
1155    fn parse_connect_request_preserves_post_header_tls_seed() {
1156        let mut request = b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com\r\n\r\n".to_vec();
1157        request.extend_from_slice(b"\x16\x03\x01client-hello");
1158
1159        let parsed = parse_connect_request(request).unwrap();
1160
1161        assert_eq!(
1162            parsed.header_bytes(),
1163            b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com\r\n\r\n"
1164        );
1165        assert_eq!(parsed.post_header_bytes(), b"\x16\x03\x01client-hello");
1166    }
1167
1168    #[test]
1169    fn parse_connect_target_requires_authority_port() {
1170        assert!(parse_connect_target("example.com").is_err());
1171        assert!(parse_connect_target("2001:db8::1:443").is_err());
1172
1173        let target = parse_connect_target("[2001:db8::1]:8443").unwrap();
1174        assert_eq!(target.host, "2001:db8::1");
1175        assert_eq!(target.port, 8443);
1176        assert_eq!(target.expected_sni, None);
1177    }
1178
1179    #[test]
1180    fn connect_response_success_requires_exact_2xx_status_code() {
1181        assert!(connect_response_is_success(
1182            b"HTTP/1.1 200 Connection Established\r\n\r\n"
1183        ));
1184        assert!(connect_response_is_success(
1185            b"HTTP/1.1 204 Connection Established\r\n\r\n"
1186        ));
1187        assert!(!connect_response_is_success(b"HTTP/1.1 2000 Weird\r\n\r\n"));
1188        assert!(!connect_response_is_success(b"HTTP/1.1 199 Nope\r\n\r\n"));
1189        assert!(!connect_response_is_success(b"NOTHTTP 200 OK\r\n\r\n"));
1190    }
1191
1192    #[tokio::test]
1193    async fn peek_for_sni_extracts_and_canonicalizes() {
1194        let (tx, mut rx) = mpsc::channel(4);
1195        let hello = synthetic_client_hello("Example.COM");
1196        tx.send(Bytes::from(hello.clone())).await.unwrap();
1197        drop(tx); // close so peek returns even if SNI didn't satisfy
1198
1199        let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1200        assert_eq!(sni.as_deref(), Some("example.com"));
1201        assert_eq!(buf, hello);
1202    }
1203
1204    #[tokio::test]
1205    async fn peek_for_sni_returns_none_on_channel_close_without_data() {
1206        let (tx, mut rx) = mpsc::channel::<Bytes>(1);
1207        drop(tx);
1208        let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1209        assert!(buf.is_empty());
1210        assert_eq!(sni, None);
1211    }
1212
1213    #[tokio::test]
1214    async fn peek_for_sni_returns_none_on_non_tls_data() {
1215        let (tx, mut rx) = mpsc::channel(4);
1216        // Plaintext HTTP request; not a TLS record so extract_sni returns None.
1217        tx.send(Bytes::from_static(
1218            b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
1219        ))
1220        .await
1221        .unwrap();
1222        drop(tx);
1223        let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1224        assert!(
1225            !buf.is_empty(),
1226            "buffered bytes must be returned for replay"
1227        );
1228        assert_eq!(sni, None);
1229    }
1230
1231    #[tokio::test]
1232    async fn peek_for_sni_falls_back_on_timeout() {
1233        let (tx, mut rx) = mpsc::channel::<Bytes>(1);
1234        // Hold the sender open but send nothing — peek must time out.
1235        let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, Duration::from_millis(50)).await;
1236        drop(tx);
1237        assert!(buf.is_empty());
1238        assert_eq!(sni, None);
1239    }
1240
1241    #[tokio::test]
1242    async fn peek_for_sni_caps_at_max_bytes() {
1243        let (tx, mut rx) = mpsc::channel(4);
1244        // First byte 0x16 keeps the peek collecting past the early
1245        // non-TLS bail. Padding bytes are zero so the SNI parser never
1246        // matches and the loop drives to the size cap.
1247        let mut first = vec![0u8; 8192];
1248        first[0] = 0x16;
1249        tx.send(Bytes::from(first)).await.unwrap();
1250        tx.send(Bytes::from(vec![0u8; 8192])).await.unwrap();
1251        tx.send(Bytes::from(vec![0u8; 8192])).await.unwrap();
1252        drop(tx);
1253
1254        let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1255        assert_eq!(sni, None, "no SNI in non-TLS data");
1256        assert!(
1257            buf.len() >= PEEK_BUF_SIZE,
1258            "buffer must hit the cap before bail-out: got {}",
1259            buf.len()
1260        );
1261    }
1262
1263    #[tokio::test]
1264    async fn peek_for_sni_bails_immediately_on_non_tls_first_byte() {
1265        let (tx, mut rx) = mpsc::channel(4);
1266        // Plain HTTP request: first byte 'G' (0x47) — clearly not TLS.
1267        tx.send(Bytes::from_static(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n"))
1268            .await
1269            .unwrap();
1270        drop(tx);
1271
1272        // 5-second nominal budget; assert we returned in well under
1273        // that — the early-bail must not wait for the full window.
1274        let started = std::time::Instant::now();
1275        let (buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1276        let elapsed = started.elapsed();
1277        assert_eq!(sni, None);
1278        assert!(buf.starts_with(b"GET"));
1279        assert!(
1280            elapsed < Duration::from_millis(500),
1281            "non-TLS bail must be fast: took {elapsed:?}"
1282        );
1283    }
1284
1285    //----------------------------------------------------------------------------------------------
1286    // peek_for_sni × evaluate_egress_with_source — combined integration tests
1287    //----------------------------------------------------------------------------------------------
1288
1289    use std::net::IpAddr;
1290    use std::time::Duration as StdDuration;
1291
1292    use crate::netstack::shared::{ResolvedHostnameFamily, SharedState};
1293    use crate::policy::{Action, Destination, NetworkPolicy, PortRange, Rule};
1294
1295    const SHARED_FASTLY_IP: &str = "151.101.0.223";
1296
1297    fn shared_with(host: &str, ip: &str) -> SharedState {
1298        let shared = SharedState::new(4);
1299        shared.cache_resolved_hostname(
1300            host,
1301            ResolvedHostnameFamily::Ipv4,
1302            [ip.parse::<IpAddr>().unwrap()],
1303            StdDuration::from_secs(60),
1304        );
1305        shared
1306    }
1307
1308    fn allow_https(domain: &str) -> Rule {
1309        Rule {
1310            direction: crate::policy::Direction::Egress,
1311            destination: Destination::Domain(domain.parse().unwrap()),
1312            protocols: vec![Protocol::Tcp],
1313            ports: vec![PortRange::single(443)],
1314            action: Action::Allow,
1315        }
1316    }
1317
1318    /// Over-allow case: cache says IP X is `pypi.org` (allowed); SNI
1319    /// is `evil.com`. SNI must override the cache and deny.
1320    #[tokio::test]
1321    async fn integration_sni_overrides_cache_for_over_allow() {
1322        let shared = shared_with("pypi.org", SHARED_FASTLY_IP);
1323        let policy = NetworkPolicy {
1324            default_egress: Action::Deny,
1325            default_ingress: Action::Allow,
1326            rules: vec![allow_https("pypi.org")],
1327        };
1328        let dst = SocketAddr::new(SHARED_FASTLY_IP.parse().unwrap(), 443);
1329
1330        let (tx, mut rx) = mpsc::channel(4);
1331        tx.send(Bytes::from(synthetic_client_hello("evil.com")))
1332            .await
1333            .unwrap();
1334        drop(tx);
1335
1336        let (initial_buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1337        assert_eq!(sni.as_deref(), Some("evil.com"));
1338        assert!(!initial_buf.is_empty());
1339
1340        let source = sni
1341            .as_deref()
1342            .map(HostnameSource::Sni)
1343            .unwrap_or(HostnameSource::CacheOnly);
1344        let eval = policy.evaluate_egress_with_source(dst, Protocol::Tcp, &shared, source);
1345        assert_eq!(
1346            eval,
1347            EgressEvaluation::Deny,
1348            "SNI=evil.com must not piggy-back on the cached pypi.org match",
1349        );
1350    }
1351
1352    /// Over-block case: cache says IP X is `ads.example.com` (denied);
1353    /// SNI is `api.example.com`. SNI must override the cache and allow.
1354    #[tokio::test]
1355    async fn integration_sni_overrides_cache_for_over_block() {
1356        let shared = shared_with("ads.example.com", SHARED_FASTLY_IP);
1357        let policy = NetworkPolicy {
1358            default_egress: Action::Allow,
1359            default_ingress: Action::Allow,
1360            rules: vec![Rule::deny_egress(Destination::Domain(
1361                "ads.example.com".parse().unwrap(),
1362            ))],
1363        };
1364        let dst = SocketAddr::new(SHARED_FASTLY_IP.parse().unwrap(), 443);
1365
1366        let (tx, mut rx) = mpsc::channel(4);
1367        tx.send(Bytes::from(synthetic_client_hello("api.example.com")))
1368            .await
1369            .unwrap();
1370        drop(tx);
1371
1372        let (_initial_buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1373        assert_eq!(sni.as_deref(), Some("api.example.com"));
1374
1375        let source = sni
1376            .as_deref()
1377            .map(HostnameSource::Sni)
1378            .unwrap_or(HostnameSource::CacheOnly);
1379        let eval = policy.evaluate_egress_with_source(dst, Protocol::Tcp, &shared, source);
1380        assert_eq!(
1381            eval,
1382            EgressEvaluation::Allow,
1383            "SNI=api.example.com must not be caught by the deny on ads.example.com",
1384        );
1385    }
1386
1387    /// Non-TLS first-flight falls back to `CacheOnly`; the cache
1388    /// match decides.
1389    #[tokio::test]
1390    async fn integration_non_tls_falls_back_to_cache() {
1391        let shared = shared_with("pypi.org", SHARED_FASTLY_IP);
1392        let policy = NetworkPolicy {
1393            default_egress: Action::Deny,
1394            default_ingress: Action::Allow,
1395            rules: vec![allow_https("pypi.org")],
1396        };
1397        let dst = SocketAddr::new(SHARED_FASTLY_IP.parse().unwrap(), 443);
1398
1399        let (tx, mut rx) = mpsc::channel(4);
1400        // Plain HTTP request; not a TLS record.
1401        tx.send(Bytes::from_static(
1402            b"GET / HTTP/1.1\r\nHost: pypi.org\r\n\r\n",
1403        ))
1404        .await
1405        .unwrap();
1406        drop(tx);
1407
1408        let (initial_buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1409        assert_eq!(sni, None, "non-TLS data → no SNI");
1410        assert!(
1411            !initial_buf.is_empty(),
1412            "buffered bytes must survive for replay"
1413        );
1414
1415        let source = sni
1416            .as_deref()
1417            .map(HostnameSource::Sni)
1418            .unwrap_or(HostnameSource::CacheOnly);
1419        let eval = policy.evaluate_egress_with_source(dst, Protocol::Tcp, &shared, source);
1420        assert_eq!(
1421            eval,
1422            EgressEvaluation::Allow,
1423            "cache-only fallback must still allow the cached hostname's IP",
1424        );
1425    }
1426
1427    /// SNI matches a `DomainSuffix` rule with a cache binding for the
1428    /// claimed name. Genuine pre-resolved traffic passes.
1429    #[tokio::test]
1430    async fn integration_sni_matches_domain_suffix_with_cache_binding() {
1431        let shared = shared_with("files.pythonhosted.org", SHARED_FASTLY_IP);
1432        let policy = NetworkPolicy {
1433            default_egress: Action::Deny,
1434            default_ingress: Action::Allow,
1435            rules: vec![Rule {
1436                direction: crate::policy::Direction::Egress,
1437                destination: Destination::DomainSuffix(".pythonhosted.org".parse().unwrap()),
1438                protocols: vec![Protocol::Tcp],
1439                ports: vec![PortRange::single(443)],
1440                action: Action::Allow,
1441            }],
1442        };
1443        let dst = SocketAddr::new(SHARED_FASTLY_IP.parse().unwrap(), 443);
1444
1445        let (tx, mut rx) = mpsc::channel(4);
1446        tx.send(Bytes::from(synthetic_client_hello(
1447            "files.pythonhosted.org",
1448        )))
1449        .await
1450        .unwrap();
1451        drop(tx);
1452
1453        let (_buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1454        let source = sni
1455            .as_deref()
1456            .map(HostnameSource::Sni)
1457            .unwrap_or(HostnameSource::CacheOnly);
1458        let eval = policy.evaluate_egress_with_source(dst, Protocol::Tcp, &shared, source);
1459        assert_eq!(eval, EgressEvaluation::Allow);
1460    }
1461
1462    /// Spoofed SNI on an IP with no cache binding for any matching
1463    /// name: byte-equality with the suffix passes, but no DNS lookup
1464    /// ever tied a `*.pythonhosted.org` name to the destination, so
1465    /// the AND-check fails and the connection is denied.
1466    #[tokio::test]
1467    async fn integration_sni_denies_domain_suffix_without_cache_binding() {
1468        let shared = SharedState::new(4); // empty cache
1469        let policy = NetworkPolicy {
1470            default_egress: Action::Deny,
1471            default_ingress: Action::Allow,
1472            rules: vec![Rule {
1473                direction: crate::policy::Direction::Egress,
1474                destination: Destination::DomainSuffix(".pythonhosted.org".parse().unwrap()),
1475                protocols: vec![Protocol::Tcp],
1476                ports: vec![PortRange::single(443)],
1477                action: Action::Allow,
1478            }],
1479        };
1480        let dst = SocketAddr::new(SHARED_FASTLY_IP.parse().unwrap(), 443);
1481
1482        let (tx, mut rx) = mpsc::channel(4);
1483        tx.send(Bytes::from(synthetic_client_hello(
1484            "files.pythonhosted.org",
1485        )))
1486        .await
1487        .unwrap();
1488        drop(tx);
1489
1490        let (_buf, sni) = peek_for_sni(&mut rx, PEEK_BUF_SIZE, PEEK_BUDGET).await;
1491        let source = sni
1492            .as_deref()
1493            .map(HostnameSource::Sni)
1494            .unwrap_or(HostnameSource::CacheOnly);
1495        let eval = policy.evaluate_egress_with_source(dst, Protocol::Tcp, &shared, source);
1496        assert_eq!(eval, EgressEvaluation::Deny);
1497    }
1498
1499    // ── extract_http_host ──────────────────────────────────────────────────────
1500
1501    #[test]
1502    fn extract_http_host_basic() {
1503        let buf = b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n";
1504        assert_eq!(extract_http_host(buf), Some("example.com".into()));
1505    }
1506
1507    #[test]
1508    fn extract_http_host_strips_port() {
1509        let buf = b"POST /api HTTP/1.1\r\nHost: api.company.com:8080\r\n\r\n";
1510        assert_eq!(extract_http_host(buf), Some("api.company.com".into()));
1511    }
1512
1513    #[test]
1514    fn extract_http_host_case_insensitive_lowercased() {
1515        let buf = b"GET / HTTP/1.1\r\nhost: Example.COM\r\n\r\n";
1516        assert_eq!(extract_http_host(buf), Some("example.com".into()));
1517    }
1518
1519    #[test]
1520    fn extract_http_host_no_host_header() {
1521        let buf = b"GET / HTTP/1.1\r\nX-Other: foo\r\n\r\n";
1522        assert_eq!(extract_http_host(buf), None);
1523    }
1524
1525    #[test]
1526    fn extract_http_host_incomplete_headers() {
1527        let buf = b"GET / HTTP/1.1\r\nHost: x";
1528        assert_eq!(extract_http_host(buf), None);
1529    }
1530
1531    #[test]
1532    fn extract_http_host_tls_first_byte() {
1533        let buf = [0x16u8, 0x03, 0x01, 0x00, 0x01];
1534        assert_eq!(extract_http_host(&buf), None);
1535    }
1536
1537    #[test]
1538    fn extract_http_host_with_many_headers() {
1539        // Far more headers than a small fixed parse array would hold: the Host
1540        // must still be found rather than the request looking hostless.
1541        let mut req = Vec::from(&b"GET / HTTP/1.1\r\n"[..]);
1542        for i in 0..100 {
1543            req.extend_from_slice(format!("X-Pad-{i}: v\r\n").as_bytes());
1544        }
1545        req.extend_from_slice(b"Host: example.com\r\n\r\n");
1546        assert_eq!(extract_http_host(&req), Some("example.com".into()));
1547    }
1548
1549    // ── plain-HTTP secret substitution ────────────────────────────────────────
1550
1551    use std::sync::Arc;
1552    use tokio::io::AsyncReadExt;
1553    use tokio::net::TcpListener;
1554    use tokio::task::JoinHandle;
1555
1556    use crate::secrets::config::{HostPattern, SecretEntry, SecretInjection, SecretsConfig};
1557
1558    fn make_plain_http_secret(placeholder: &str, value: &str, require_tls: bool) -> SecretsConfig {
1559        SecretsConfig {
1560            secrets: vec![SecretEntry {
1561                env_var: "API_KEY".into(),
1562                value: zeroize::Zeroizing::new(value.into()),
1563                source: None,
1564                placeholder: placeholder.into(),
1565                allowed_hosts: vec![HostPattern::Any],
1566                injection: SecretInjection {
1567                    headers: true,
1568                    basic_auth: false,
1569                    query_params: false,
1570                    body: false,
1571                },
1572                on_violation: None,
1573                require_tls_identity: require_tls,
1574            }],
1575            ..Default::default()
1576        }
1577    }
1578
1579    fn make_host_bound_secret(placeholder: &str, value: &str, host: &str) -> SecretsConfig {
1580        SecretsConfig {
1581            secrets: vec![SecretEntry {
1582                env_var: "API_KEY".into(),
1583                value: zeroize::Zeroizing::new(value.into()),
1584                source: None,
1585                placeholder: placeholder.into(),
1586                allowed_hosts: vec![HostPattern::Exact(host.into())],
1587                injection: SecretInjection::default(),
1588                on_violation: None,
1589                require_tls_identity: true,
1590            }],
1591            ..Default::default()
1592        }
1593    }
1594
1595    #[test]
1596    fn sanitize_connect_headers_blocks_placeholder_metadata_header_by_default() {
1597        let secrets = make_host_bound_secret("$MSB_KEY", "real-secret-value", "example.com");
1598        let headers = b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\nProxy-Authorization: Bearer $MSB_KEY\r\nUser-Agent: curl\r\n\r\n";
1599
1600        assert_eq!(
1601            sanitize_connect_headers(headers, &secrets),
1602            Err(ViolationAction::BlockAndLog)
1603        );
1604    }
1605
1606    #[test]
1607    fn sanitize_connect_headers_respects_block_and_terminate() {
1608        let mut secrets = make_host_bound_secret("$MSB_KEY", "real-secret-value", "example.com");
1609        secrets.on_violation = ViolationAction::BlockAndTerminate;
1610        let headers = b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\nProxy-Authorization: Bearer $MSB_KEY\r\n\r\n";
1611
1612        assert_eq!(
1613            sanitize_connect_headers(headers, &secrets),
1614            Err(ViolationAction::BlockAndTerminate)
1615        );
1616    }
1617
1618    #[test]
1619    fn sanitize_connect_headers_respects_explicit_passthrough() {
1620        let mut secrets = make_host_bound_secret("$MSB_KEY", "real-secret-value", "example.com");
1621        secrets.on_violation = ViolationAction::Passthrough(vec![HostPattern::Any]);
1622        let headers = b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\nProxy-Authorization: Bearer $MSB_KEY\r\n\r\n";
1623
1624        let sanitized = sanitize_connect_headers(headers, &secrets).unwrap();
1625
1626        assert_eq!(sanitized.as_ref(), headers);
1627        assert!(
1628            !String::from_utf8_lossy(sanitized.as_ref()).contains("real-secret-value"),
1629            "passthrough must never substitute real secrets into CONNECT metadata"
1630        );
1631    }
1632
1633    #[test]
1634    fn sanitize_connect_headers_keeps_safe_metadata_headers() {
1635        let secrets = make_host_bound_secret("$MSB_KEY", "real-secret-value", "example.com");
1636        let headers =
1637            b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\nUser-Agent: curl\r\n\r\n";
1638
1639        let sanitized = sanitize_connect_headers(headers, &secrets).unwrap();
1640
1641        assert_eq!(sanitized.as_ref(), headers);
1642    }
1643
1644    #[test]
1645    fn sanitize_connect_headers_blocks_placeholder_in_request_line() {
1646        let secrets = make_host_bound_secret("$MSB_KEY", "real-secret-value", "example.com");
1647        let headers = b"CONNECT $MSB_KEY:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n";
1648
1649        assert_eq!(
1650            sanitize_connect_headers(headers, &secrets),
1651            Err(ViolationAction::BlockAndLog)
1652        );
1653    }
1654
1655    async fn spawn_sink() -> (SocketAddr, JoinHandle<Vec<u8>>) {
1656        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1657        let addr = listener.local_addr().unwrap();
1658        let handle = tokio::spawn(async move {
1659            let (mut stream, _) = listener.accept().await.unwrap();
1660            let mut received = Vec::new();
1661            let mut buf = vec![0u8; 4096];
1662            loop {
1663                match stream.read(&mut buf).await {
1664                    Ok(0) | Err(_) => break,
1665                    Ok(n) => received.extend_from_slice(&buf[..n]),
1666                }
1667            }
1668            received
1669        });
1670        (addr, handle)
1671    }
1672
1673    async fn relay_through_proxy(
1674        request: Vec<u8>,
1675        secrets: SecretsConfig,
1676        handle: JoinHandle<Vec<u8>>,
1677        server_addr: SocketAddr,
1678    ) -> Vec<u8> {
1679        let (from_tx, from_rx) = mpsc::channel::<Bytes>(8);
1680        let (to_tx, _to_rx) = mpsc::channel::<Bytes>(8);
1681        let shared = SharedState::new(4);
1682        let policy = Arc::new(NetworkPolicy::default());
1683        let secrets = Arc::new(secrets);
1684        let proxy_connect = Arc::new(ProxyConnectState::new());
1685
1686        from_tx.send(Bytes::from(request)).await.unwrap();
1687        drop(from_tx);
1688
1689        TcpProxy::new(
1690            server_addr,
1691            UpstreamTcpTarget::direct(server_addr),
1692            from_rx,
1693            to_tx,
1694            Arc::new(shared),
1695            policy,
1696            secrets,
1697            None,
1698            proxy_connect,
1699        )
1700        .try_run()
1701        .await
1702        .unwrap();
1703
1704        handle.await.unwrap()
1705    }
1706
1707    #[tokio::test]
1708    async fn plain_http_substitutes_placeholder_when_host_arrives_in_second_segment() {
1709        // Host header split across TCP segments — classify_first_flight must keep
1710        // reading until \r\n\r\n before extract_http_host is called.
1711        let (addr, sink) = spawn_sink().await;
1712        let secrets = make_plain_http_secret("$MSB_KEY", "real-secret-value", false);
1713
1714        let (from_tx, from_rx) = mpsc::channel::<Bytes>(8);
1715        let (to_tx, _to_rx) = mpsc::channel::<Bytes>(8);
1716        let proxy_connect = Arc::new(ProxyConnectState::new());
1717
1718        from_tx
1719            .send(Bytes::from_static(b"GET /api HTTP/1.1\r\n"))
1720            .await
1721            .unwrap();
1722        from_tx
1723            .send(Bytes::from_static(
1724                b"Host: example.com\r\nAuthorization: Bearer $MSB_KEY\r\n\r\n",
1725            ))
1726            .await
1727            .unwrap();
1728        drop(from_tx);
1729
1730        TcpProxy::new(
1731            addr,
1732            UpstreamTcpTarget::direct(addr),
1733            from_rx,
1734            to_tx,
1735            Arc::new(SharedState::new(4)),
1736            Arc::new(NetworkPolicy::default()),
1737            Arc::new(secrets),
1738            None,
1739            proxy_connect,
1740        )
1741        .try_run()
1742        .await
1743        .unwrap();
1744
1745        let wire = String::from_utf8(sink.await.unwrap()).unwrap();
1746        assert!(wire.contains("real-secret-value"), "got: {wire:?}");
1747        assert!(!wire.contains("$MSB_KEY"), "got: {wire:?}");
1748    }
1749
1750    #[tokio::test]
1751    async fn plain_http_forwards_placeholder_to_allowed_host_with_split_headers() {
1752        // A default (require_tls_identity = true) host-bound secret is never
1753        // substituted over plain HTTP, but a request to its allowed host must
1754        // have the placeholder forwarded unchanged — not blocked as a violation
1755        // — even when the Host arrives in a later segment than the request line.
1756        let (addr, sink) = spawn_sink().await;
1757
1758        let shared = SharedState::new(4);
1759        shared.cache_resolved_hostname(
1760            "example.com",
1761            ResolvedHostnameFamily::Ipv4,
1762            ["127.0.0.1".parse::<IpAddr>().unwrap()],
1763            StdDuration::from_secs(60),
1764        );
1765
1766        let secrets = SecretsConfig {
1767            secrets: vec![SecretEntry {
1768                env_var: "API_KEY".into(),
1769                value: zeroize::Zeroizing::new("real-secret-value".into()),
1770                source: None,
1771                placeholder: "$MSB_KEY".into(),
1772                allowed_hosts: vec![HostPattern::Exact("example.com".into())],
1773                injection: SecretInjection {
1774                    headers: true,
1775                    basic_auth: false,
1776                    query_params: false,
1777                    body: false,
1778                },
1779                on_violation: None,
1780                require_tls_identity: true,
1781            }],
1782            ..Default::default()
1783        };
1784
1785        let (from_tx, from_rx) = mpsc::channel::<Bytes>(8);
1786        let (to_tx, _to_rx) = mpsc::channel::<Bytes>(8);
1787        let proxy_connect = Arc::new(ProxyConnectState::new());
1788
1789        from_tx
1790            .send(Bytes::from_static(b"GET /api HTTP/1.1\r\n"))
1791            .await
1792            .unwrap();
1793        from_tx
1794            .send(Bytes::from_static(
1795                b"Host: example.com\r\nAuthorization: Bearer $MSB_KEY\r\n\r\n",
1796            ))
1797            .await
1798            .unwrap();
1799        drop(from_tx);
1800
1801        TcpProxy::new(
1802            addr,
1803            UpstreamTcpTarget::direct(addr),
1804            from_rx,
1805            to_tx,
1806            Arc::new(shared),
1807            Arc::new(NetworkPolicy::default()),
1808            Arc::new(secrets),
1809            None,
1810            proxy_connect,
1811        )
1812        .try_run()
1813        .await
1814        .unwrap();
1815
1816        let wire = String::from_utf8(sink.await.unwrap()).unwrap();
1817        assert!(
1818            wire.contains("Host: example.com"),
1819            "request must reach the allowed host, got: {wire:?}"
1820        );
1821        assert!(
1822            wire.contains("$MSB_KEY"),
1823            "placeholder must be forwarded unchanged for a require_tls_identity secret, got: {wire:?}"
1824        );
1825        assert!(
1826            !wire.contains("real-secret-value"),
1827            "secret must never be substituted over plain HTTP, got: {wire:?}"
1828        );
1829    }
1830
1831    #[tokio::test]
1832    async fn plain_http_substitutes_placeholder_in_first_flight() {
1833        let (addr, sink) = spawn_sink().await;
1834
1835        let request =
1836            b"GET /api HTTP/1.1\r\nHost: example.com\r\nAuthorization: Bearer $MSB_KEY\r\n\r\n"
1837                .to_vec();
1838        let secrets = make_plain_http_secret("$MSB_KEY", "real-secret-value", false);
1839
1840        let wire =
1841            String::from_utf8(relay_through_proxy(request, secrets, sink, addr).await).unwrap();
1842        assert!(
1843            wire.contains("real-secret-value"),
1844            "real value must reach server, got: {wire:?}"
1845        );
1846        assert!(
1847            !wire.contains("$MSB_KEY"),
1848            "placeholder must not reach server, got: {wire:?}"
1849        );
1850    }
1851
1852    #[tokio::test]
1853    async fn plain_http_no_substitution_when_require_tls_identity_true() {
1854        let (addr, sink) = spawn_sink().await;
1855
1856        let request =
1857            b"GET /api HTTP/1.1\r\nHost: example.com\r\nAuthorization: Bearer $MSB_KEY\r\n\r\n"
1858                .to_vec();
1859        let secrets = make_plain_http_secret("$MSB_KEY", "real-secret-value", true);
1860
1861        let wire =
1862            String::from_utf8_lossy(&relay_through_proxy(request, secrets, sink, addr).await)
1863                .into_owned();
1864        assert!(
1865            wire.contains("$MSB_KEY"),
1866            "placeholder must be forwarded unchanged when require_tls_identity=true, got: {wire:?}"
1867        );
1868        assert!(
1869            !wire.contains("real-secret-value"),
1870            "real value must not leak when require_tls_identity=true, got: {wire:?}"
1871        );
1872    }
1873
1874    #[tokio::test]
1875    async fn plain_http_large_body_forwarded_verbatim_in_relay_loop() {
1876        // Body arrives in a separate segment after headers — flows through the relay
1877        // loop, not the peek path. Ensures no bytes are dropped and header substitution
1878        // still happens.
1879        let (addr, sink) = spawn_sink().await;
1880        let secrets = make_plain_http_secret("$MSB_KEY", "real-value", false);
1881
1882        let body = "x".repeat(32_000);
1883        let header = format!(
1884            "POST /upload HTTP/1.1\r\nHost: example.com\r\nAuthorization: Bearer $MSB_KEY\r\nContent-Length: {}\r\n\r\n",
1885            body.len()
1886        );
1887
1888        let (from_tx, from_rx) = mpsc::channel::<Bytes>(8);
1889        let (to_tx, _to_rx) = mpsc::channel::<Bytes>(8);
1890        let proxy_connect = Arc::new(ProxyConnectState::new());
1891
1892        from_tx
1893            .send(Bytes::from(header.into_bytes()))
1894            .await
1895            .unwrap();
1896        from_tx
1897            .send(Bytes::from(body.clone().into_bytes()))
1898            .await
1899            .unwrap();
1900        drop(from_tx);
1901
1902        TcpProxy::new(
1903            addr,
1904            UpstreamTcpTarget::direct(addr),
1905            from_rx,
1906            to_tx,
1907            Arc::new(SharedState::new(4)),
1908            Arc::new(NetworkPolicy::default()),
1909            Arc::new(secrets),
1910            None,
1911            proxy_connect,
1912        )
1913        .try_run()
1914        .await
1915        .unwrap();
1916
1917        let wire = String::from_utf8_lossy(&sink.await.unwrap()).into_owned();
1918        assert!(wire.contains(&body), "got {} bytes", wire.len());
1919        assert!(!wire.contains("$MSB_KEY"), "got: {wire:?}");
1920    }
1921}