Skip to main content

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