Skip to main content

microsandbox_network/engine/netstack/
poll.rs

1//! smoltcp interface setup, frame classification, and poll loop.
2//!
3//! This module contains the core networking event loop that runs on a
4//! dedicated OS thread. It bridges guest ethernet frames (via
5//! [`SmoltcpDevice`]) to smoltcp's TCP/IP stack and services connections
6//! through tokio proxy tasks.
7
8use std::collections::HashSet;
9use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
10use std::num::NonZeroUsize;
11use std::sync::Arc;
12use std::sync::atomic::Ordering;
13
14#[cfg(windows)]
15use msb_krun_utils::event::{EventSet, EventSource, WaitContext, WaitEvent};
16use smoltcp::iface::{Config, Interface, SocketSet};
17use smoltcp::time::Instant;
18
19use smoltcp::wire::{
20    EthernetAddress, EthernetFrame, EthernetProtocol, HardwareAddress, Icmpv4Packet, Icmpv4Repr,
21    Icmpv6Packet, Icmpv6Repr, IpAddress, IpCidr, IpProtocol, Ipv4Packet, Ipv4Repr, Ipv6Packet,
22    Ipv6Repr, TcpPacket, UdpPacket,
23};
24
25use crate::config::{ConnectionLimit, DnsConfig, PublishedPort};
26use crate::engine::dns::common::ports::DnsPortType;
27use crate::engine::dns::{
28    interceptor::DnsInterceptor,
29    proxies::{dot::DotProxy, tcp::DnsTcpProxy},
30};
31use crate::engine::tls::{proxy::TlsProxy, state::TlsState};
32use crate::icmp::relay::IcmpRelay;
33use crate::policy::{EgressEvaluation, HostnameSource, NetworkPolicy, Protocol};
34use crate::ports::PortPublisher;
35use crate::proxy::ResolvedOutboundProxy;
36use crate::secrets::handle::SecretsHandle;
37use crate::tcp::{connection::TcpConnectionTracker, proxy::TcpProxy, upstream::UpstreamTcpTarget};
38use crate::udp::fragments::{
39    Ipv4UdpFragmentReassembler, Ipv6UdpFragmentReassembler, ReassembledUdpDatagram,
40    is_ipv4_udp_fragment, is_ipv6_fragment, is_ipv6_udp_fragment,
41};
42use crate::udp::relay::UdpRelay;
43
44use super::{device::SmoltcpDevice, shared::SharedState};
45
46//--------------------------------------------------------------------------------------------------
47// Constants
48//--------------------------------------------------------------------------------------------------
49
50#[cfg(windows)]
51const TX_WAKE_TOKEN: u64 = 1;
52
53#[cfg(windows)]
54const PROXY_WAKE_TOKEN: u64 = 2;
55
56//--------------------------------------------------------------------------------------------------
57// Types
58//--------------------------------------------------------------------------------------------------
59
60/// Result of classifying a guest ethernet frame before smoltcp processes it.
61///
62/// Pre-inspection allows the poll loop to:
63/// - Create TCP sockets before smoltcp sees a SYN (preventing auto-RST).
64/// - Handle non-DNS UDP outside smoltcp (smoltcp lacks wildcard port binding).
65/// - Route DNS queries to the interception handler.
66pub enum FrameAction {
67    /// TCP SYN to a new destination — create a smoltcp socket before
68    /// letting smoltcp process the frame.
69    TcpSyn { src: SocketAddr, dst: SocketAddr },
70
71    /// Non-DNS UDP datagram — handle entirely outside smoltcp via the UDP
72    /// relay.
73    UdpRelay { src: SocketAddr, dst: SocketAddr },
74
75    /// DNS query (UDP to port 53) — let smoltcp's bound UDP socket handle it.
76    Dns,
77
78    /// IPv4 UDP fragment — reassemble before the UDP relay sees it.
79    Ipv4UdpFragment,
80
81    /// IPv6 UDP fragment — reassemble before the UDP relay sees it.
82    Ipv6UdpFragment,
83
84    /// IPv6 fragment for a protocol this relay cannot safely classify.
85    Ipv6UnsupportedFragment,
86
87    /// Everything else (ARP, NDP, ICMP, TCP data/ACK/FIN, etc.) — let
88    /// smoltcp process normally.
89    Passthrough,
90}
91
92/// Local ICMP echo reply plus the destination policy input it answers.
93struct GatewayIcmpReply {
94    dst: IpAddr,
95    protocol: Protocol,
96    frame: Vec<u8>,
97}
98
99/// Resolved network parameters for the poll loop. Created by
100/// `SmoltcpNetwork::new()` from a resolved network configuration and sandbox slot.
101pub struct PollLoopConfig {
102    /// Gateway MAC address (smoltcp's identity on the virtual LAN).
103    pub gateway_mac: [u8; 6],
104    /// Guest MAC address.
105    pub guest_mac: [u8; 6],
106    /// Gateway addresses owned by the smoltcp virtual stack. Each family
107    /// is `Some` when that family is active for this sandbox (host has a
108    /// route, or the user supplied an explicit address).
109    pub gateway: GatewayIps,
110    /// Guest IPv4 address. `None` when IPv4 is inactive for this sandbox.
111    pub guest_ipv4: Option<Ipv4Addr>,
112    /// Guest IPv6 address. `None` when IPv6 is inactive for this sandbox.
113    pub guest_ipv6: Option<Ipv6Addr>,
114    /// IP-level MTU (e.g. 1500).
115    pub mtu: usize,
116}
117
118/// Per-sandbox gateway addresses owned by the smoltcp virtual stack.
119///
120/// Each family is `Some` when active for this sandbox and `None` otherwise.
121/// `resolve_host_dst` rewrites gateway-bound connections to loopback at dial time.
122#[derive(Debug, Clone, Copy)]
123pub struct GatewayIps {
124    /// Gateway IPv4.
125    pub ipv4: Option<Ipv4Addr>,
126    /// Gateway IPv6.
127    pub ipv6: Option<Ipv6Addr>,
128}
129
130//--------------------------------------------------------------------------------------------------
131// Functions
132//--------------------------------------------------------------------------------------------------
133
134/// Classify a raw ethernet frame for pre-inspection.
135///
136/// Uses smoltcp's wire module for zero-copy parsing. Returns
137/// [`FrameAction::Passthrough`] for any frame that cannot be parsed or
138/// doesn't match a special case.
139pub fn classify_frame(frame: &[u8]) -> FrameAction {
140    let Ok(eth) = EthernetFrame::new_checked(frame) else {
141        return FrameAction::Passthrough;
142    };
143
144    match eth.ethertype() {
145        EthernetProtocol::Ipv4 => classify_ipv4(eth.payload()),
146        EthernetProtocol::Ipv6 => classify_ipv6(eth.payload()),
147        _ => FrameAction::Passthrough, // ARP, etc.
148    }
149}
150
151/// Create and configure the smoltcp [`Interface`].
152///
153/// The interface is configured as the **gateway**: it owns the gateway IP
154/// addresses and responds to ARP/NDP for them. `any_ip` mode is enabled so
155/// smoltcp accepts traffic destined for arbitrary remote IPs (not just the
156/// gateway), combined with default routes.
157pub fn create_interface(device: &mut SmoltcpDevice, config: &PollLoopConfig) -> Interface {
158    let hw_addr = HardwareAddress::Ethernet(EthernetAddress(config.gateway_mac));
159    let iface_config = Config::new(hw_addr);
160    let mut iface = Interface::new(iface_config, device, smoltcp_now());
161
162    // Configure gateway IP addresses for the active families.
163    iface.update_ip_addrs(|addrs| {
164        if let Some(ipv4) = config.gateway.ipv4 {
165            addrs
166                .push(IpCidr::new(IpAddress::Ipv4(ipv4), 30)) // 30 subnet: gateway + guest.
167                .expect("failed to add gateway IPv4 address");
168        }
169        if let Some(ipv6) = config.gateway.ipv6 {
170            addrs
171                .push(IpCidr::new(IpAddress::Ipv6(ipv6), 64))
172                .expect("failed to add gateway IPv6 address");
173        }
174    });
175
176    // Default routes so smoltcp accepts traffic for all destinations.
177    if let Some(ipv4) = config.gateway.ipv4 {
178        iface
179            .routes_mut()
180            .add_default_ipv4_route(ipv4)
181            .expect("failed to add default IPv4 route");
182    }
183    if let Some(ipv6) = config.gateway.ipv6 {
184        iface
185            .routes_mut()
186            .add_default_ipv6_route(ipv6)
187            .expect("failed to add default IPv6 route");
188    }
189
190    // Accept traffic destined for any IP, not just gateway addresses.
191    iface.set_any_ip(true);
192
193    iface
194}
195
196/// Main smoltcp poll loop. Runs on a dedicated OS thread.
197///
198/// Processes guest frames with pre-inspection, drives smoltcp's TCP/IP stack,
199/// and sleeps via `poll(2)` between events.
200///
201/// # Phases per iteration
202///
203/// 1. **Drain guest frames** — pop from `tx_ring`, classify, pre-inspect.
204/// 2. **smoltcp egress + maintenance** — transmit queued packets, run timers.
205/// 3. **Service connections** — relay data between smoltcp sockets and proxy
206///    tasks (added by later tasks).
207/// 4. **Sleep** — wait on `tx_wake` + `proxy_wake` with smoltcp's requested
208///    timeout.
209///
210/// # Arguments
211///
212/// * `shared` - Stack-wide shared state: `tx_ring` / `rx_ring` for the virtio-net boundary
213///   and the wake eventfds.
214/// * `config` - Resolved per-sandbox parameters (gateway / guest MAC + IPv4 + IPv6, MTU).
215/// * `network_policy` - User-provided egress policy. Evaluated against the sandbox's
216///   gateway IPs (stored on [`SharedState`]) so `DestinationGroup::Host` rules match.
217/// * `platform_policy` - Optional host-owned policy floor. Traffic must pass both policies.
218/// * `dns_config` - DNS interception settings (block lists, upstreams, timeout).
219/// * `tls_state` - Optional TLS MITM state; drives interception of intercepted ports and DoT
220///   when present.
221/// * `published_ports` - Host → guest port publishes; the publisher accepts inbound
222///   connections on the host-bind address and forwards into the guest.
223/// * `max_tcp_connections` - Optional cap on concurrent guest connections tracked by
224///   [`TcpConnectionTracker`]; `None` uses the default.
225/// * `tokio_handle` - Runtime handle used for proxy tasks, DNS forwarding, port publishing,
226///   and ICMP relays.
227#[allow(clippy::too_many_arguments)]
228pub fn smoltcp_poll_loop(
229    shared: Arc<SharedState>,
230    config: PollLoopConfig,
231    network_policy: NetworkPolicy,
232    platform_policy: Option<NetworkPolicy>,
233    dns_config: DnsConfig,
234    tls_state: Option<Arc<TlsState>>,
235    published_ports: Vec<PublishedPort>,
236    strict: bool,
237    max_tcp_connections: Option<NonZeroUsize>,
238    max_udp_connections: Option<ConnectionLimit>,
239    tokio_handle: tokio::runtime::Handle,
240    secrets: SecretsHandle,
241    outbound_proxy: Option<Arc<ResolvedOutboundProxy>>,
242) {
243    let mut device = SmoltcpDevice::new(shared.clone(), config.mtu);
244    let mut iface = create_interface(&mut device, &config);
245    let mut sockets = SocketSet::new(vec![]);
246    let mut conn_tracker = TcpConnectionTracker::new(max_tcp_connections);
247
248    // The DNS forwarder needs to know which IPs count as "the gateway"
249    // (so it routes guest queries to those addresses through the
250    // configured upstream) and a policy evaluator (so guest-chosen
251    // `@target` resolvers are gated by egress rules just like any
252    // other outbound).
253    let gateway_ips: Arc<HashSet<IpAddr>> = Arc::new(
254        config
255            .gateway
256            .ipv4
257            .map(IpAddr::V4)
258            .into_iter()
259            .chain(config.gateway.ipv6.map(IpAddr::V6))
260            .collect(),
261    );
262    // Gateway IPs must be on SharedState before any egress evaluation runs,
263    // so `DestinationGroup::Host` rules can resolve to the right address.
264    shared.set_gateway_ips(config.gateway.ipv4, config.gateway.ipv6);
265    let network_policy = Arc::new(network_policy);
266    let platform_policy = platform_policy.map(Arc::new);
267
268    let (mut dns_interceptor, dns_forwarder_handle) = DnsInterceptor::new(
269        &mut sockets,
270        dns_config,
271        shared.clone(),
272        &tokio_handle,
273        gateway_ips,
274        network_policy.clone(),
275        platform_policy.clone(),
276        config.gateway,
277        config.gateway_mac,
278        config.guest_mac,
279    );
280    let mut port_publisher = PortPublisher::new(
281        &published_ports,
282        config.guest_ipv4,
283        config.guest_ipv6,
284        config.gateway.ipv4,
285        config.gateway.ipv6,
286        config.gateway_mac,
287        config.guest_mac,
288        network_policy.clone(),
289        shared.clone(),
290        &tokio_handle,
291    );
292    let mut udp_relay = UdpRelay::new(
293        shared.clone(),
294        config.gateway_mac,
295        config.guest_mac,
296        config.mtu,
297        tokio_handle.clone(),
298        outbound_proxy.clone(),
299    );
300    udp_relay.set_max_sessions(max_udp_connections.and_then(ConnectionLimit::cap));
301    udp_relay.attach_dns_forwarder(dns_forwarder_handle.clone());
302    let mut udp_fragments = Ipv4UdpFragmentReassembler::new();
303    let mut ipv6_udp_fragments = Ipv6UdpFragmentReassembler::new();
304    let icmp_relay = IcmpRelay::new(
305        shared.clone(),
306        config.gateway_mac,
307        config.guest_mac,
308        tokio_handle.clone(),
309    );
310
311    // Rate-limit cleanup operations: run at most once per second.
312    let mut last_cleanup = std::time::Instant::now();
313
314    // Wake sources for sleeping.
315    #[cfg(unix)]
316    let mut poll_fds = [
317        libc::pollfd {
318            fd: shared.tx_wake.as_raw_fd(),
319            events: libc::POLLIN,
320            revents: 0,
321        },
322        libc::pollfd {
323            fd: shared.proxy_wake.as_raw_fd(),
324            events: libc::POLLIN,
325            revents: 0,
326        },
327    ];
328    #[cfg(windows)]
329    let wait_context = match windows_stack_wait_context(&shared) {
330        Ok(context) => context,
331        Err(err) => {
332            tracing::error!(error = %err, "network poll loop: failed to create wait context");
333            return;
334        }
335    };
336
337    loop {
338        let now = smoltcp_now();
339
340        // ── Phase 1: Drain all guest frames with pre-inspection ──────────
341        while let Some(frame) = device.stage_next_frame() {
342            if handle_gateway_icmp_echo(
343                frame,
344                &config,
345                &shared,
346                &network_policy,
347                platform_policy.as_deref(),
348            ) {
349                device.drop_staged_frame();
350                continue;
351            }
352
353            if icmp_relay.relay_outbound_if_echo(
354                frame,
355                &config,
356                &network_policy,
357                platform_policy.as_deref(),
358            ) {
359                device.drop_staged_frame();
360                continue;
361            }
362
363            match classify_frame(frame) {
364                FrameAction::TcpSyn { src, dst } => {
365                    let allow = match DnsPortType::from_tcp(dst.port()) {
366                        // Plain DNS: the interceptor enforces policy at
367                        // the application layer (block list + rebind
368                        // protection); bypass the network egress check.
369                        DnsPortType::Dns => true,
370                        // DoT: intercept only when TLS MITM is
371                        // configured. Without it, the block list can't
372                        // apply (traffic is encrypted end-to-end), so
373                        // we refuse to force a fall-back to plain
374                        // TCP/53. When TLS MITM is configured, bypass
375                        // egress policy the same way plain DNS does —
376                        // policy for the upstream resolver is applied
377                        // per query by the forwarder.
378                        DnsPortType::EncryptedDns => {
379                            if tls_state.is_some() {
380                                true
381                            } else {
382                                tracing::debug!(%dst, "DoT port refused (TLS interception not configured); stub should fall back to TCP/53");
383                                false
384                            }
385                        }
386                        // Alternative DNS protocol we can't proxy:
387                        // refuse outright — no socket means smoltcp
388                        // emits RST, which the guest's stub treats as
389                        // "upstream unavailable" and falls back to
390                        // plain TCP/53.
391                        DnsPortType::AlternativeDns => {
392                            tracing::debug!(%dst, "alternative-DNS TCP port refused; stub should fall back to TCP/53");
393                            false
394                        }
395                        // Other: regular outbound — defer Domain rules to first-flight;
396                        // accept unless an IP-layer rule denies.
397                        DnsPortType::Other => {
398                            let platform_allows = platform_policy.as_deref().is_none_or(|policy| {
399                                policy
400                                    .evaluate_egress(dst, Protocol::Tcp, &shared)
401                                    .is_allow()
402                            });
403                            platform_allows
404                                && matches!(
405                                    network_policy.evaluate_egress_with_source(
406                                        dst,
407                                        Protocol::Tcp,
408                                        &shared,
409                                        HostnameSource::Deferred,
410                                    ),
411                                    EgressEvaluation::Allow | EgressEvaluation::DeferUntilHostname
412                                )
413                        }
414                    };
415                    if allow && !conn_tracker.has_socket_for(&src, &dst) {
416                        conn_tracker.create_tcp_socket(src, dst, &mut sockets);
417                    }
418                    // Let smoltcp process — matching socket completes
419                    // handshake, no socket means auto-RST.
420                    iface.poll_ingress_single(now, &mut device, &mut sockets);
421                }
422
423                FrameAction::UdpRelay { src, dst } => {
424                    relay_udp_frame(
425                        frame,
426                        src,
427                        dst,
428                        &config,
429                        &network_policy,
430                        platform_policy.as_deref(),
431                        &shared,
432                        &mut port_publisher,
433                        tls_state.as_deref(),
434                        &mut udp_relay,
435                    );
436                    device.drop_staged_frame();
437                }
438
439                FrameAction::Ipv4UdpFragment => {
440                    if let Some(datagram) = udp_fragments.push(frame) {
441                        handle_reassembled_udp_datagram(
442                            datagram,
443                            &mut device,
444                            &mut iface,
445                            now,
446                            &mut sockets,
447                            &config,
448                            &network_policy,
449                            platform_policy.as_deref(),
450                            &shared,
451                            &mut port_publisher,
452                            tls_state.as_deref(),
453                            &mut udp_relay,
454                        );
455                    } else {
456                        device.drop_staged_frame();
457                    }
458                }
459
460                FrameAction::Ipv6UdpFragment => {
461                    if let Some(datagram) = ipv6_udp_fragments.push(frame) {
462                        handle_reassembled_udp_datagram(
463                            datagram,
464                            &mut device,
465                            &mut iface,
466                            now,
467                            &mut sockets,
468                            &config,
469                            &network_policy,
470                            platform_policy.as_deref(),
471                            &shared,
472                            &mut port_publisher,
473                            tls_state.as_deref(),
474                            &mut udp_relay,
475                        );
476                    } else {
477                        device.drop_staged_frame();
478                    }
479                }
480
481                FrameAction::Ipv6UnsupportedFragment => {
482                    // Fragmented UDP is only forwarded after reassembly and policy evaluation.
483                    // Other fragmented IPv6 traffic is dropped rather than passed through with
484                    // an unknown transport tuple.
485                    device.drop_staged_frame();
486                }
487
488                FrameAction::Dns | FrameAction::Passthrough => {
489                    // ARP, ICMP, DNS (port 53), TCP data — smoltcp handles.
490                    iface.poll_ingress_single(now, &mut device, &mut sockets);
491                }
492            }
493        }
494
495        // ── Phase 2: Ingress egress + maintenance ─────────────────────────
496        // Flush frames generated by Phase 1 ingress (ACKs, SYN-ACKs, etc.)
497        // before relaying data so smoltcp has up-to-date state.
498        loop {
499            let result = iface.poll_egress(now, &mut device, &mut sockets);
500            if matches!(result, smoltcp::iface::PollResult::None) {
501                break;
502            }
503        }
504        iface.poll_maintenance(now);
505
506        // Coalesced wake: if Phase 1/2 emitted any frames, wake the
507        // NetWorker once instead of per-frame.
508        if device.frames_emitted.swap(false, Ordering::Relaxed) {
509            shared.rx_wake.wake();
510        }
511
512        // ── Phase 3: Service connections + relay data ────────────────────
513        // Relay proxy data INTO smoltcp sockets first, then a single egress
514        // pass flushes everything. This eliminates the former "Phase 2b"
515        // double-egress pattern.
516        conn_tracker.relay_data(&mut sockets);
517        dns_interceptor.process(&mut sockets);
518
519        // Accept queued inbound connections from published port listeners.
520        port_publisher.accept_inbound(&mut iface, &mut sockets, &shared, &tokio_handle);
521        port_publisher.relay_data(&mut sockets);
522
523        // Detect newly-established connections and spawn proxy tasks.
524        let new_conns = conn_tracker.take_new_connections(&mut sockets);
525        for conn in new_conns {
526            if let Some(ref tls_state) = tls_state
527                && tls_state
528                    .config
529                    .intercepted_ports
530                    .contains(&conn.dst.port())
531            {
532                // TLS-intercepted port — spawn TLS MITM proxy.
533                let connect_target = resolve_tcp_host_target(conn.dst, config.gateway);
534                let connection_outbound_proxy = ResolvedOutboundProxy::select_for_destination(
535                    &outbound_proxy,
536                    conn.dst,
537                    connect_target.primary(),
538                );
539                let proxy = TlsProxy::new(
540                    conn.dst,
541                    connect_target,
542                    conn.from_smoltcp,
543                    conn.to_smoltcp,
544                    shared.clone(),
545                    tls_state.clone(),
546                    network_policy.clone(),
547                    strict,
548                    conn.proxy_connect,
549                    connection_outbound_proxy,
550                );
551                tokio_handle.spawn(proxy.run());
552                continue;
553            }
554            if conn.dst.port() == 53 {
555                // DNS proxies have no guest-visible
556                // "upstream-unreachable" failure mode — even an
557                // upstream DNS failure yields SERVFAIL responses
558                // rather than a silently-closed connection. Mark the
559                // connection as connected so normal task exit
560                // produces FIN, not RST.
561                conn.proxy_connect.mark_connected();
562
563                // DNS over TCP: route through the same forwarder the UDP
564                // path uses. The forwarder applies the domain block list
565                // and rebind protection to every query and routes
566                // upstream based on `conn.dst.ip()` — the configured
567                // upstream for queries to the gateway, direct forward
568                // to the chosen `@target` (subject to egress policy)
569                // otherwise. No gateway→loopback rewrite here: the
570                // forwarder dials the configured upstream, not the
571                // gateway.
572                let proxy = DnsTcpProxy::new(
573                    conn.dst,
574                    conn.from_smoltcp,
575                    conn.to_smoltcp,
576                    dns_forwarder_handle.clone(),
577                    shared.clone(),
578                );
579                tokio_handle.spawn(proxy.run());
580                continue;
581            }
582            if conn.dst.port() == 853
583                && let Some(ref tls_state) = tls_state
584            {
585                // Same "always upstream-connected" reasoning as plain DNS over TCP.
586                conn.proxy_connect.mark_connected();
587
588                // DNS over TLS: terminate TLS at the gateway with a
589                // per-domain cert, hand the inner DNS frames to the
590                // same forwarder plain DNS uses. Policy for the
591                // chosen `@target` resolver is applied per-query by
592                // the forwarder (block list + rebind + egress).
593                let proxy = DotProxy::new(
594                    conn.dst,
595                    conn.from_smoltcp,
596                    conn.to_smoltcp,
597                    dns_forwarder_handle.clone(),
598                    tls_state.clone(),
599                    shared.clone(),
600                );
601                tokio_handle.spawn(proxy.run());
602                continue;
603            }
604            // Plain TCP proxy.
605            let connect_target = resolve_tcp_host_target(conn.dst, config.gateway);
606            let connection_outbound_proxy = ResolvedOutboundProxy::select_for_destination(
607                &outbound_proxy,
608                conn.dst,
609                connect_target.primary(),
610            );
611            let proxy = TcpProxy::new(
612                conn.dst,
613                connect_target,
614                conn.from_smoltcp,
615                conn.to_smoltcp,
616                shared.clone(),
617                network_policy.clone(),
618                // Load the current snapshot per connection so live secret
619                // updates apply to traffic the guest starts afterwards.
620                secrets.load(),
621                tls_state.clone(),
622                strict,
623                conn.proxy_connect,
624                connection_outbound_proxy,
625            );
626            tokio_handle.spawn(proxy.run());
627        }
628
629        // Periodic cleanup is the idle fallback. TCP creation also reclaims
630        // completed flows when the table is full, before rejecting a new SYN.
631        if last_cleanup.elapsed() >= std::time::Duration::from_secs(1) {
632            conn_tracker.cleanup_closed(&mut sockets);
633            conn_tracker.trace_stats(&sockets);
634            port_publisher.cleanup_closed(&mut sockets);
635            udp_relay.cleanup_expired();
636            udp_fragments.cleanup_expired();
637            ipv6_udp_fragments.cleanup_expired();
638            shared.cleanup_resolved_hostnames();
639            last_cleanup = std::time::Instant::now();
640        }
641
642        // ── Phase 4: Flush relay data + sleep ────────────────────────────
643        // Single egress pass flushes all data written by Phase 3.
644        loop {
645            let result = iface.poll_egress(now, &mut device, &mut sockets);
646            if matches!(result, smoltcp::iface::PollResult::None) {
647                break;
648            }
649        }
650
651        // Coalesced wake: if Phase 3/4 emitted any frames, wake once.
652        if device.frames_emitted.swap(false, Ordering::Relaxed) {
653            shared.rx_wake.wake();
654        }
655
656        let timeout_ms = iface
657            .poll_delay(now, &sockets)
658            .map(|d| d.total_millis().min(i32::MAX as u64) as i32)
659            .unwrap_or(100); // 100ms fallback when no timers pending.
660
661        #[cfg(unix)]
662        sleep_until_stack_wake(&shared, timeout_ms, &mut poll_fds);
663        #[cfg(windows)]
664        sleep_until_stack_wake_windows(&shared, timeout_ms, &wait_context);
665    }
666}
667
668//--------------------------------------------------------------------------------------------------
669// Functions: Helpers
670//--------------------------------------------------------------------------------------------------
671
672#[cfg(unix)]
673fn sleep_until_stack_wake(shared: &SharedState, timeout_ms: i32, poll_fds: &mut [libc::pollfd; 2]) {
674    // SAFETY: poll_fds is a valid array of pollfd structs with valid fds.
675    unsafe {
676        libc::poll(
677            poll_fds.as_mut_ptr(),
678            poll_fds.len() as libc::nfds_t,
679            timeout_ms,
680        );
681    }
682
683    if poll_fds[0].revents & libc::POLLIN != 0 {
684        shared.tx_wake.drain();
685    }
686    if poll_fds[1].revents & libc::POLLIN != 0 {
687        shared.proxy_wake.drain();
688    }
689}
690
691#[cfg(windows)]
692fn windows_stack_wait_context(shared: &SharedState) -> std::io::Result<WaitContext> {
693    let mut context = WaitContext::new();
694    context.add(
695        EventSource::waitable_handle(shared.tx_wake.as_raw_handle(), TX_WAKE_TOKEN),
696        EventSet::IN,
697    )?;
698    context.add(
699        EventSource::waitable_handle(shared.proxy_wake.as_raw_handle(), PROXY_WAKE_TOKEN),
700        EventSet::IN,
701    )?;
702    Ok(context)
703}
704
705#[cfg(windows)]
706fn sleep_until_stack_wake_windows(
707    shared: &SharedState,
708    timeout_ms: i32,
709    wait_context: &WaitContext,
710) {
711    let mut events = [WaitEvent::default(); 2];
712    let count = match wait_context.wait(timeout_ms, &mut events) {
713        Ok(count) => count,
714        Err(err) => {
715            tracing::warn!(error = %err, "network poll loop: wait failed");
716            return;
717        }
718    };
719
720    for event in events.iter().take(count) {
721        match event.token() {
722            TX_WAKE_TOKEN => shared.tx_wake.drain(),
723            PROXY_WAKE_TOKEN => shared.proxy_wake.drain(),
724            token => tracing::warn!(token, "network poll loop: unknown wake token"),
725        }
726    }
727}
728
729/// Apply the common non-DNS UDP dispatch path to a complete guest datagram.
730#[allow(clippy::too_many_arguments)]
731fn relay_udp_frame(
732    frame: &[u8],
733    src: SocketAddr,
734    dst: SocketAddr,
735    config: &PollLoopConfig,
736    network_policy: &NetworkPolicy,
737    platform_policy: Option<&NetworkPolicy>,
738    shared: &Arc<SharedState>,
739    port_publisher: &mut PortPublisher,
740    tls_state: Option<&TlsState>,
741    udp_relay: &mut UdpRelay,
742) {
743    if port_publisher.relay_udp_outbound(frame, src, dst) {
744        return;
745    }
746
747    // QUIC blocking: drop UDP to intercepted ports when TLS interception is active.
748    if let Some(tls) = tls_state
749        && tls.config.intercepted_ports.contains(&dst.port())
750        && tls.config.block_quic_on_intercept
751    {
752        return;
753    }
754
755    match DnsPortType::from_udp(dst.port()) {
756        // Dns: unreachable here — classify_transport routes UDP/53 to
757        // FrameAction::Dns, not UdpRelay. Defensive drop covers regressions.
758        DnsPortType::Dns | DnsPortType::EncryptedDns => return,
759        // Alternative DNS protocols on well-known UDP ports are dropped —
760        // forces fall-back to UDP/53.
761        DnsPortType::AlternativeDns => {
762            tracing::debug!(%dst, "alternative-DNS UDP port dropped; stub should fall back to UDP/53");
763            return;
764        }
765        DnsPortType::Other => {}
766    }
767
768    // Policy is applied after reassembly, when the UDP destination port is known.
769    if platform_policy
770        .is_some_and(|policy| policy.evaluate_egress(dst, Protocol::Udp, shared).is_deny())
771        || network_policy
772            .evaluate_egress(dst, Protocol::Udp, shared)
773            .is_deny()
774    {
775        return;
776    }
777
778    // Resolve the host-side destination for the dial. `dst` stays unchanged so
779    // reply frames are stamped with the IP the guest expects.
780    let host_dst = resolve_host_dst(dst, config.gateway);
781    udp_relay.relay_outbound(frame, src, dst, host_dst);
782}
783
784/// Dispatch a complete datagram produced by fragment reassembly.
785#[allow(clippy::too_many_arguments)]
786fn handle_reassembled_udp_datagram(
787    datagram: ReassembledUdpDatagram,
788    device: &mut SmoltcpDevice,
789    iface: &mut Interface,
790    now: Instant,
791    sockets: &mut SocketSet<'_>,
792    config: &PollLoopConfig,
793    network_policy: &NetworkPolicy,
794    platform_policy: Option<&NetworkPolicy>,
795    shared: &Arc<SharedState>,
796    port_publisher: &mut PortPublisher,
797    tls_state: Option<&TlsState>,
798    udp_relay: &mut UdpRelay,
799) {
800    if DnsPortType::from_udp(datagram.dst.port()) == DnsPortType::Dns {
801        device.replace_staged_frame(datagram.frame);
802        iface.poll_ingress_single(now, device, sockets);
803        return;
804    }
805
806    relay_udp_frame(
807        &datagram.frame,
808        datagram.src,
809        datagram.dst,
810        config,
811        network_policy,
812        platform_policy,
813        shared,
814        port_publisher,
815        tls_state,
816        udp_relay,
817    );
818    device.drop_staged_frame();
819}
820
821/// Resolve host-side TCP destination candidates for a guest connection.
822///
823/// A guest connection to either gateway family first dials the matching host
824/// loopback address, then may fall back to the other loopback family. Regular
825/// outbound destinations have no fallback.
826fn resolve_tcp_host_target(dst: SocketAddr, gateway: GatewayIps) -> UpstreamTcpTarget {
827    let port = dst.port();
828    match dst.ip() {
829        IpAddr::V4(v4) if gateway.ipv4 == Some(v4) => UpstreamTcpTarget::with_fallback(
830            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
831            SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), port),
832        ),
833        IpAddr::V6(v6) if gateway.ipv6 == Some(v6) => UpstreamTcpTarget::with_fallback(
834            SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), port),
835            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
836        ),
837        _ => UpstreamTcpTarget::direct(dst),
838    }
839}
840
841/// Map a guest-wire UDP destination to its host-socket equivalent.
842///
843/// Gateway IPs rewrite to loopback (`127.0.0.1` / `::1`); everything else
844/// passes through.
845///
846/// # Arguments
847///
848/// * `dst` - Destination from the guest's packet.
849/// * `gateway` - Per-sandbox gateway IPs that trigger the loopback rewrite.
850pub(crate) fn resolve_host_dst(dst: SocketAddr, gateway: GatewayIps) -> SocketAddr {
851    match dst.ip() {
852        IpAddr::V4(v4) if gateway.ipv4 == Some(v4) => {
853            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), dst.port())
854        }
855        IpAddr::V6(v6) if gateway.ipv6 == Some(v6) => {
856            SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), dst.port())
857        }
858        _ => dst,
859    }
860}
861
862/// Get the current time as a smoltcp [`Instant`] using a monotonic clock.
863///
864/// Uses `std::time::Instant` (monotonic) instead of `SystemTime` (wall
865/// clock) to avoid issues with NTP clock step corrections that could
866/// cause smoltcp timers to misbehave.
867fn smoltcp_now() -> Instant {
868    static EPOCH: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
869    let epoch = EPOCH.get_or_init(std::time::Instant::now);
870    let elapsed = epoch.elapsed();
871    Instant::from_millis(elapsed.as_millis() as i64)
872}
873
874/// Reply locally to ICMP echo requests aimed at the sandbox gateway.
875///
876/// `any_ip` is required so smoltcp accepts guest traffic for arbitrary remote
877/// destinations, but that would make smoltcp's automatic ICMP echo replies
878/// spoof remote hosts. Handle only the real gateway IPs here and leave all
879/// other ICMP traffic untouched.
880fn handle_gateway_icmp_echo(
881    frame: &[u8],
882    config: &PollLoopConfig,
883    shared: &SharedState,
884    network_policy: &NetworkPolicy,
885    platform_policy: Option<&NetworkPolicy>,
886) -> bool {
887    let Ok(eth) = EthernetFrame::new_checked(frame) else {
888        return false;
889    };
890
891    let reply = match eth.ethertype() {
892        EthernetProtocol::Ipv4 => gateway_icmpv4_echo_reply(&eth, config),
893        EthernetProtocol::Ipv6 => gateway_icmpv6_echo_reply(&eth, config),
894        _ => None,
895    };
896    let Some(reply) = reply else {
897        return false;
898    };
899
900    // This path bypasses the normal proxy evaluators, so it must independently
901    // satisfy both the tenant policy and the optional host-owned policy floor.
902    let tenant_denied = network_policy
903        .evaluate_egress_ip(reply.dst, reply.protocol, shared)
904        .is_deny();
905    let platform_denied = platform_policy.is_some_and(|policy| {
906        policy
907            .evaluate_egress_ip(reply.dst, reply.protocol, shared)
908            .is_deny()
909    });
910    if tenant_denied || platform_denied {
911        tracing::debug!(
912            dst = %reply.dst,
913            tenant_denied,
914            platform_denied,
915            "gateway ICMP echo denied by policy",
916        );
917        return true;
918    }
919
920    shared.push_rx_frame_and_wake(reply.frame);
921
922    true
923}
924
925/// Build an IPv4 ICMP echo reply when the guest pings the gateway IPv4.
926fn gateway_icmpv4_echo_reply(
927    eth: &EthernetFrame<&[u8]>,
928    config: &PollLoopConfig,
929) -> Option<GatewayIcmpReply> {
930    let gateway_ipv4 = config.gateway.ipv4?;
931    let ipv4 = Ipv4Packet::new_checked(eth.payload()).ok()?;
932    if ipv4.dst_addr() != gateway_ipv4 || ipv4.next_header() != IpProtocol::Icmp {
933        return None;
934    }
935
936    let icmp = Icmpv4Packet::new_checked(ipv4.payload()).ok()?;
937    let Icmpv4Repr::EchoRequest {
938        ident,
939        seq_no,
940        data,
941    } = Icmpv4Repr::parse(&icmp, &smoltcp::phy::ChecksumCapabilities::default()).ok()?
942    else {
943        return None;
944    };
945
946    let ipv4_repr = Ipv4Repr {
947        src_addr: gateway_ipv4,
948        dst_addr: ipv4.src_addr(),
949        next_header: IpProtocol::Icmp,
950        payload_len: 8 + data.len(),
951        hop_limit: 64,
952    };
953    let icmp_repr = Icmpv4Repr::EchoReply {
954        ident,
955        seq_no,
956        data,
957    };
958    let mut reply = vec![0u8; 14 + ipv4_repr.buffer_len() + icmp_repr.buffer_len()];
959
960    let mut reply_eth = EthernetFrame::new_unchecked(&mut reply);
961    reply_eth.set_src_addr(EthernetAddress(config.gateway_mac));
962    reply_eth.set_dst_addr(eth.src_addr());
963    reply_eth.set_ethertype(EthernetProtocol::Ipv4);
964
965    ipv4_repr.emit(
966        &mut Ipv4Packet::new_unchecked(&mut reply[14..34]),
967        &smoltcp::phy::ChecksumCapabilities::default(),
968    );
969    icmp_repr.emit(
970        &mut Icmpv4Packet::new_unchecked(&mut reply[34..]),
971        &smoltcp::phy::ChecksumCapabilities::default(),
972    );
973
974    Some(GatewayIcmpReply {
975        dst: IpAddr::V4(gateway_ipv4),
976        protocol: Protocol::Icmpv4,
977        frame: reply,
978    })
979}
980
981/// Build an IPv6 ICMP echo reply when the guest pings the gateway IPv6.
982fn gateway_icmpv6_echo_reply(
983    eth: &EthernetFrame<&[u8]>,
984    config: &PollLoopConfig,
985) -> Option<GatewayIcmpReply> {
986    let gateway_ipv6 = config.gateway.ipv6?;
987    let ipv6 = Ipv6Packet::new_checked(eth.payload()).ok()?;
988    if ipv6.dst_addr() != gateway_ipv6 || ipv6.next_header() != IpProtocol::Icmpv6 {
989        return None;
990    }
991
992    let icmp = Icmpv6Packet::new_checked(ipv6.payload()).ok()?;
993    let Icmpv6Repr::EchoRequest {
994        ident,
995        seq_no,
996        data,
997    } = Icmpv6Repr::parse(
998        &ipv6.src_addr(),
999        &ipv6.dst_addr(),
1000        &icmp,
1001        &smoltcp::phy::ChecksumCapabilities::default(),
1002    )
1003    .ok()?
1004    else {
1005        return None;
1006    };
1007
1008    let ipv6_repr = Ipv6Repr {
1009        src_addr: gateway_ipv6,
1010        dst_addr: ipv6.src_addr(),
1011        next_header: IpProtocol::Icmpv6,
1012        payload_len: icmp_repr_buffer_len_v6(data),
1013        hop_limit: 64,
1014    };
1015    let icmp_repr = Icmpv6Repr::EchoReply {
1016        ident,
1017        seq_no,
1018        data,
1019    };
1020    let ipv6_hdr_len = 40;
1021    let mut reply = vec![0u8; 14 + ipv6_hdr_len + icmp_repr.buffer_len()];
1022
1023    let mut reply_eth = EthernetFrame::new_unchecked(&mut reply);
1024    reply_eth.set_src_addr(EthernetAddress(config.gateway_mac));
1025    reply_eth.set_dst_addr(eth.src_addr());
1026    reply_eth.set_ethertype(EthernetProtocol::Ipv6);
1027
1028    ipv6_repr.emit(&mut Ipv6Packet::new_unchecked(&mut reply[14..54]));
1029    icmp_repr.emit(
1030        &gateway_ipv6,
1031        &ipv6.src_addr(),
1032        &mut Icmpv6Packet::new_unchecked(&mut reply[54..]),
1033        &smoltcp::phy::ChecksumCapabilities::default(),
1034    );
1035
1036    Some(GatewayIcmpReply {
1037        dst: IpAddr::V6(gateway_ipv6),
1038        protocol: Protocol::Icmpv6,
1039        frame: reply,
1040    })
1041}
1042
1043fn icmp_repr_buffer_len_v6(data: &[u8]) -> usize {
1044    Icmpv6Repr::EchoReply {
1045        ident: 0,
1046        seq_no: 0,
1047        data,
1048    }
1049    .buffer_len()
1050}
1051
1052/// Classify an IPv4 packet payload (after stripping the Ethernet header).
1053fn classify_ipv4(payload: &[u8]) -> FrameAction {
1054    let Ok(ipv4) = Ipv4Packet::new_checked(payload) else {
1055        return FrameAction::Passthrough;
1056    };
1057    if is_ipv4_udp_fragment(&ipv4) {
1058        return FrameAction::Ipv4UdpFragment;
1059    }
1060    classify_transport(
1061        ipv4.next_header(),
1062        ipv4.src_addr().into(),
1063        ipv4.dst_addr().into(),
1064        ipv4.payload(),
1065    )
1066}
1067
1068/// Classify an IPv6 packet payload (after stripping the Ethernet header).
1069fn classify_ipv6(payload: &[u8]) -> FrameAction {
1070    let Ok(ipv6) = Ipv6Packet::new_checked(payload) else {
1071        return FrameAction::Passthrough;
1072    };
1073    if is_ipv6_udp_fragment(&ipv6) {
1074        return FrameAction::Ipv6UdpFragment;
1075    }
1076    if is_ipv6_fragment(&ipv6) {
1077        return FrameAction::Ipv6UnsupportedFragment;
1078    }
1079    classify_transport(
1080        ipv6.next_header(),
1081        ipv6.src_addr().into(),
1082        ipv6.dst_addr().into(),
1083        ipv6.payload(),
1084    )
1085}
1086
1087/// Classify the transport-layer protocol (shared by IPv4 and IPv6).
1088fn classify_transport(
1089    protocol: IpProtocol,
1090    src_ip: std::net::IpAddr,
1091    dst_ip: std::net::IpAddr,
1092    transport_payload: &[u8],
1093) -> FrameAction {
1094    match protocol {
1095        IpProtocol::Tcp => {
1096            let Ok(tcp) = TcpPacket::new_checked(transport_payload) else {
1097                return FrameAction::Passthrough;
1098            };
1099            if tcp.syn() && !tcp.ack() {
1100                FrameAction::TcpSyn {
1101                    src: SocketAddr::new(src_ip, tcp.src_port()),
1102                    dst: SocketAddr::new(dst_ip, tcp.dst_port()),
1103                }
1104            } else {
1105                FrameAction::Passthrough
1106            }
1107        }
1108        IpProtocol::Udp => {
1109            let Ok(udp) = UdpPacket::new_checked(transport_payload) else {
1110                return FrameAction::Passthrough;
1111            };
1112            // The plain-DNS port (UDP/53) lives in dns::common::ports so
1113            // the alternative-DNS refusal logic and this dispatcher
1114            // share one source of truth for "which UDP ports are DNS".
1115            if DnsPortType::from_udp(udp.dst_port()) == DnsPortType::Dns {
1116                FrameAction::Dns
1117            } else {
1118                FrameAction::UdpRelay {
1119                    src: SocketAddr::new(src_ip, udp.src_port()),
1120                    dst: SocketAddr::new(dst_ip, udp.dst_port()),
1121                }
1122            }
1123        }
1124        _ => FrameAction::Passthrough, // ICMP, etc.
1125    }
1126}
1127
1128//--------------------------------------------------------------------------------------------------
1129// Tests
1130//--------------------------------------------------------------------------------------------------
1131
1132#[cfg(test)]
1133mod tests {
1134    use super::*;
1135    use std::sync::Arc;
1136
1137    use smoltcp::phy::ChecksumCapabilities;
1138    use smoltcp::wire::{
1139        ArpOperation, ArpPacket, ArpRepr, EthernetRepr, Icmpv4Packet, Icmpv4Repr, Ipv4Repr,
1140    };
1141
1142    use super::super::{device::SmoltcpDevice, shared::SharedState};
1143    use crate::tcp::connection::NewConnection;
1144
1145    /// Build a minimal Ethernet + IPv4 + TCP SYN frame.
1146    fn build_tcp_syn_frame(
1147        src_ip: [u8; 4],
1148        dst_ip: [u8; 4],
1149        src_port: u16,
1150        dst_port: u16,
1151    ) -> Vec<u8> {
1152        let mut frame = vec![0u8; 14 + 20 + 20]; // eth + ipv4 + tcp
1153
1154        // Ethernet header.
1155        frame[12] = 0x08; // EtherType: IPv4
1156        frame[13] = 0x00;
1157
1158        // IPv4 header.
1159        let ip = &mut frame[14..34];
1160        ip[0] = 0x45; // Version + IHL
1161        let total_len = 40u16; // 20 (IP) + 20 (TCP)
1162        ip[2..4].copy_from_slice(&total_len.to_be_bytes());
1163        ip[6] = 0x40; // Don't Fragment
1164        ip[8] = 64; // TTL
1165        ip[9] = 6; // Protocol: TCP
1166        ip[12..16].copy_from_slice(&src_ip);
1167        ip[16..20].copy_from_slice(&dst_ip);
1168
1169        // TCP header.
1170        let tcp = &mut frame[34..54];
1171        tcp[0..2].copy_from_slice(&src_port.to_be_bytes());
1172        tcp[2..4].copy_from_slice(&dst_port.to_be_bytes());
1173        tcp[12] = 0x50; // Data offset: 5 words
1174        tcp[13] = 0x02; // SYN flag
1175
1176        frame
1177    }
1178
1179    /// Build a minimal Ethernet + IPv4 + UDP frame.
1180    fn build_udp_frame(src_ip: [u8; 4], dst_ip: [u8; 4], src_port: u16, dst_port: u16) -> Vec<u8> {
1181        let mut frame = vec![0u8; 14 + 20 + 8]; // eth + ipv4 + udp
1182
1183        // Ethernet header.
1184        frame[12] = 0x08;
1185        frame[13] = 0x00;
1186
1187        // IPv4 header.
1188        let ip = &mut frame[14..34];
1189        ip[0] = 0x45;
1190        let total_len = 28u16; // 20 (IP) + 8 (UDP)
1191        ip[2..4].copy_from_slice(&total_len.to_be_bytes());
1192        ip[8] = 64;
1193        ip[9] = 17; // Protocol: UDP
1194        ip[12..16].copy_from_slice(&src_ip);
1195        ip[16..20].copy_from_slice(&dst_ip);
1196
1197        // UDP header.
1198        let udp = &mut frame[34..42];
1199        udp[0..2].copy_from_slice(&src_port.to_be_bytes());
1200        udp[2..4].copy_from_slice(&dst_port.to_be_bytes());
1201        let udp_len = 8u16;
1202        udp[4..6].copy_from_slice(&udp_len.to_be_bytes());
1203
1204        frame
1205    }
1206
1207    /// Build a minimal Ethernet + IPv4 + ICMP echo request frame.
1208    fn build_icmpv4_echo_frame(
1209        src_mac: [u8; 6],
1210        dst_mac: [u8; 6],
1211        src_ip: [u8; 4],
1212        dst_ip: [u8; 4],
1213        ident: u16,
1214        seq_no: u16,
1215        data: &[u8],
1216    ) -> Vec<u8> {
1217        let ipv4_repr = Ipv4Repr {
1218            src_addr: Ipv4Addr::from(src_ip),
1219            dst_addr: Ipv4Addr::from(dst_ip),
1220            next_header: IpProtocol::Icmp,
1221            payload_len: 8 + data.len(),
1222            hop_limit: 64,
1223        };
1224        let icmp_repr = Icmpv4Repr::EchoRequest {
1225            ident,
1226            seq_no,
1227            data,
1228        };
1229        let frame_len = 14 + ipv4_repr.buffer_len() + icmp_repr.buffer_len();
1230        let mut frame = vec![0u8; frame_len];
1231
1232        let mut eth_frame = EthernetFrame::new_unchecked(&mut frame);
1233        EthernetRepr {
1234            src_addr: EthernetAddress(src_mac),
1235            dst_addr: EthernetAddress(dst_mac),
1236            ethertype: EthernetProtocol::Ipv4,
1237        }
1238        .emit(&mut eth_frame);
1239
1240        ipv4_repr.emit(
1241            &mut Ipv4Packet::new_unchecked(&mut frame[14..34]),
1242            &ChecksumCapabilities::default(),
1243        );
1244        icmp_repr.emit(
1245            &mut Icmpv4Packet::new_unchecked(&mut frame[34..]),
1246            &ChecksumCapabilities::default(),
1247        );
1248
1249        frame
1250    }
1251
1252    /// Build a minimal Ethernet + ARP request frame.
1253    fn build_arp_request_frame(src_mac: [u8; 6], src_ip: [u8; 4], target_ip: [u8; 4]) -> Vec<u8> {
1254        let mut frame = vec![0u8; 14 + 28];
1255
1256        let mut eth_frame = EthernetFrame::new_unchecked(&mut frame);
1257        EthernetRepr {
1258            src_addr: EthernetAddress(src_mac),
1259            dst_addr: EthernetAddress([0xff; 6]),
1260            ethertype: EthernetProtocol::Arp,
1261        }
1262        .emit(&mut eth_frame);
1263
1264        ArpRepr::EthernetIpv4 {
1265            operation: ArpOperation::Request,
1266            source_hardware_addr: EthernetAddress(src_mac),
1267            source_protocol_addr: Ipv4Addr::from(src_ip),
1268            target_hardware_addr: EthernetAddress([0x00; 6]),
1269            target_protocol_addr: Ipv4Addr::from(target_ip),
1270        }
1271        .emit(&mut ArpPacket::new_unchecked(&mut frame[14..]));
1272
1273        frame
1274    }
1275
1276    #[test]
1277    fn classify_tcp_syn() {
1278        let frame = build_tcp_syn_frame([10, 0, 0, 2], [93, 184, 216, 34], 54321, 443);
1279        match classify_frame(&frame) {
1280            FrameAction::TcpSyn { src, dst } => {
1281                assert_eq!(
1282                    src,
1283                    SocketAddr::new(Ipv4Addr::new(10, 0, 0, 2).into(), 54321)
1284                );
1285                assert_eq!(
1286                    dst,
1287                    SocketAddr::new(Ipv4Addr::new(93, 184, 216, 34).into(), 443)
1288                );
1289            }
1290            _ => panic!("expected TcpSyn"),
1291        }
1292    }
1293
1294    #[test]
1295    fn classify_tcp_ack_is_passthrough() {
1296        let mut frame = build_tcp_syn_frame([10, 0, 0, 2], [93, 184, 216, 34], 54321, 443);
1297        // Change flags to ACK only (not SYN).
1298        frame[34 + 13] = 0x10; // ACK flag
1299        assert!(matches!(classify_frame(&frame), FrameAction::Passthrough));
1300    }
1301
1302    #[test]
1303    fn classify_udp_dns() {
1304        let frame = build_udp_frame([10, 0, 0, 2], [10, 0, 0, 1], 12345, 53);
1305        assert!(matches!(classify_frame(&frame), FrameAction::Dns));
1306    }
1307
1308    #[test]
1309    fn classify_udp_non_dns() {
1310        let frame = build_udp_frame([10, 0, 0, 2], [8, 8, 8, 8], 12345, 443);
1311        match classify_frame(&frame) {
1312            FrameAction::UdpRelay { src, dst } => {
1313                assert_eq!(src.port(), 12345);
1314                assert_eq!(dst.port(), 443);
1315            }
1316            _ => panic!("expected UdpRelay"),
1317        }
1318    }
1319
1320    #[test]
1321    fn classify_ipv4_udp_fragment() {
1322        let mut frame = build_udp_frame([10, 0, 0, 2], [8, 8, 8, 8], 12345, 443);
1323        frame[14 + 6] = 0x20; // More Fragments flag.
1324        assert!(matches!(
1325            classify_frame(&frame),
1326            FrameAction::Ipv4UdpFragment
1327        ));
1328    }
1329
1330    #[test]
1331    fn classify_ipv6_udp_fragment() {
1332        let mut frame = vec![0u8; 14 + 40 + 8];
1333
1334        frame[12] = 0x86;
1335        frame[13] = 0xdd;
1336
1337        let ip = &mut frame[14..54];
1338        ip[0] = 0x60;
1339        ip[4..6].copy_from_slice(&8u16.to_be_bytes());
1340        ip[6] = u8::from(IpProtocol::Ipv6Frag);
1341        ip[7] = 64;
1342        ip[8..24].copy_from_slice(&Ipv6Addr::LOCALHOST.octets());
1343        ip[24..40].copy_from_slice(&Ipv6Addr::LOCALHOST.octets());
1344
1345        let fragment = &mut frame[54..62];
1346        fragment[0] = u8::from(IpProtocol::Udp);
1347        fragment[3] = 1; // More Fragments flag.
1348
1349        assert!(matches!(
1350            classify_frame(&frame),
1351            FrameAction::Ipv6UdpFragment
1352        ));
1353    }
1354
1355    #[test]
1356    fn classify_ipv6_non_udp_fragment_is_unsupported() {
1357        let mut frame = vec![0u8; 14 + 40 + 8];
1358
1359        frame[12] = 0x86;
1360        frame[13] = 0xdd;
1361
1362        let ip = &mut frame[14..54];
1363        ip[0] = 0x60;
1364        ip[4..6].copy_from_slice(&8u16.to_be_bytes());
1365        ip[6] = u8::from(IpProtocol::Ipv6Frag);
1366        ip[7] = 64;
1367        ip[8..24].copy_from_slice(&Ipv6Addr::LOCALHOST.octets());
1368        ip[24..40].copy_from_slice(&Ipv6Addr::LOCALHOST.octets());
1369
1370        let fragment = &mut frame[54..62];
1371        fragment[0] = u8::from(IpProtocol::Tcp);
1372        fragment[3] = 1; // More Fragments flag.
1373
1374        assert!(matches!(
1375            classify_frame(&frame),
1376            FrameAction::Ipv6UnsupportedFragment
1377        ));
1378    }
1379
1380    #[test]
1381    fn classify_arp_is_passthrough() {
1382        let mut frame = vec![0u8; 42]; // ARP frame
1383        frame[12] = 0x08;
1384        frame[13] = 0x06; // EtherType: ARP
1385        assert!(matches!(classify_frame(&frame), FrameAction::Passthrough));
1386    }
1387
1388    #[test]
1389    fn classify_garbage_is_passthrough() {
1390        assert!(matches!(classify_frame(&[]), FrameAction::Passthrough));
1391        assert!(matches!(classify_frame(&[0; 5]), FrameAction::Passthrough));
1392    }
1393
1394    #[test]
1395    fn gateway_replies_to_icmp_echo_requests() {
1396        fn drive_one_frame(
1397            device: &mut SmoltcpDevice,
1398            iface: &mut Interface,
1399            sockets: &mut SocketSet<'_>,
1400            shared: &Arc<SharedState>,
1401            poll_config: &PollLoopConfig,
1402            now: Instant,
1403        ) {
1404            let frame = device.stage_next_frame().expect("expected staged frame");
1405            if handle_gateway_icmp_echo(
1406                frame,
1407                poll_config,
1408                shared,
1409                &NetworkPolicy::allow_all(),
1410                None,
1411            ) {
1412                device.drop_staged_frame();
1413                return;
1414            }
1415            let _ = iface.poll_ingress_single(now, device, sockets);
1416            let _ = iface.poll_egress(now, device, sockets);
1417        }
1418
1419        let shared = Arc::new(SharedState::new(4));
1420        let poll_config = PollLoopConfig {
1421            gateway_mac: [0x02, 0x00, 0x00, 0x00, 0x00, 0x01],
1422            guest_mac: [0x02, 0x00, 0x00, 0x00, 0x00, 0x02],
1423            gateway: GatewayIps {
1424                ipv4: Some(Ipv4Addr::new(100, 96, 0, 1)),
1425                ipv6: Some(Ipv6Addr::LOCALHOST),
1426            },
1427            guest_ipv4: Some(Ipv4Addr::new(100, 96, 0, 2)),
1428            guest_ipv6: None,
1429            mtu: 1500,
1430        };
1431        let guest_ipv4 = poll_config.guest_ipv4.unwrap();
1432        let gateway_ipv4 = poll_config.gateway.ipv4.unwrap();
1433        let mut device = SmoltcpDevice::new(shared.clone(), poll_config.mtu);
1434        let mut iface = create_interface(&mut device, &poll_config);
1435        let mut sockets = SocketSet::new(vec![]);
1436        let now = smoltcp_now();
1437
1438        // Mirror the real guest flow: resolve the gateway MAC before sending
1439        // the ICMP echo request.
1440        shared
1441            .tx_ring
1442            .push(build_arp_request_frame(
1443                poll_config.guest_mac,
1444                guest_ipv4.octets(),
1445                gateway_ipv4.octets(),
1446            ))
1447            .unwrap();
1448        shared
1449            .tx_ring
1450            .push(build_icmpv4_echo_frame(
1451                poll_config.guest_mac,
1452                poll_config.gateway_mac,
1453                guest_ipv4.octets(),
1454                gateway_ipv4.octets(),
1455                0x1234,
1456                0xABCD,
1457                b"ping",
1458            ))
1459            .unwrap();
1460
1461        drive_one_frame(
1462            &mut device,
1463            &mut iface,
1464            &mut sockets,
1465            &shared,
1466            &poll_config,
1467            now,
1468        );
1469        let _ = shared.rx_ring.pop().expect("expected ARP reply");
1470
1471        drive_one_frame(
1472            &mut device,
1473            &mut iface,
1474            &mut sockets,
1475            &shared,
1476            &poll_config,
1477            now,
1478        );
1479
1480        let reply = shared.rx_ring.pop().expect("expected ICMP echo reply");
1481        let eth = EthernetFrame::new_checked(&reply).expect("valid ethernet frame");
1482        assert_eq!(eth.src_addr(), EthernetAddress(poll_config.gateway_mac));
1483        assert_eq!(eth.dst_addr(), EthernetAddress(poll_config.guest_mac));
1484        assert_eq!(eth.ethertype(), EthernetProtocol::Ipv4);
1485
1486        let ipv4 = Ipv4Packet::new_checked(eth.payload()).expect("valid IPv4 packet");
1487        assert_eq!(ipv4.src_addr(), gateway_ipv4);
1488        assert_eq!(ipv4.dst_addr(), guest_ipv4);
1489        assert_eq!(ipv4.next_header(), IpProtocol::Icmp);
1490
1491        let icmp = Icmpv4Packet::new_checked(ipv4.payload()).expect("valid ICMP packet");
1492        let icmp_repr = Icmpv4Repr::parse(&icmp, &ChecksumCapabilities::default())
1493            .expect("valid ICMP echo reply");
1494        assert_eq!(
1495            icmp_repr,
1496            Icmpv4Repr::EchoReply {
1497                ident: 0x1234,
1498                seq_no: 0xABCD,
1499                data: b"ping",
1500            }
1501        );
1502    }
1503
1504    #[test]
1505    fn gateway_icmp_echo_respects_deny_policy() {
1506        let shared = SharedState::new(4);
1507        let poll_config = PollLoopConfig {
1508            gateway_mac: [0x02, 0x00, 0x00, 0x00, 0x00, 0x01],
1509            guest_mac: [0x02, 0x00, 0x00, 0x00, 0x00, 0x02],
1510            gateway: GatewayIps {
1511                ipv4: Some(Ipv4Addr::new(100, 96, 0, 1)),
1512                ipv6: None,
1513            },
1514            guest_ipv4: Some(Ipv4Addr::new(100, 96, 0, 2)),
1515            guest_ipv6: None,
1516            mtu: 1500,
1517        };
1518        let policy = NetworkPolicy::builder().default_deny().build().unwrap();
1519        let frame = build_icmpv4_echo_frame(
1520            poll_config.guest_mac,
1521            poll_config.gateway_mac,
1522            poll_config.guest_ipv4.unwrap().octets(),
1523            poll_config.gateway.ipv4.unwrap().octets(),
1524            0x1234,
1525            0xABCD,
1526            b"ping",
1527        );
1528
1529        assert!(handle_gateway_icmp_echo(
1530            &frame,
1531            &poll_config,
1532            &shared,
1533            &policy,
1534            None,
1535        ));
1536        assert!(
1537            shared.rx_ring.pop().is_none(),
1538            "denied gateway ICMP should not queue a reply"
1539        );
1540    }
1541
1542    #[test]
1543    fn platform_public_floor_consumes_gateway_echo_without_replying() {
1544        let shared = SharedState::new(4);
1545        let gateway = Ipv4Addr::new(100, 96, 0, 1);
1546        let guest = Ipv4Addr::new(100, 96, 0, 2);
1547        shared.set_gateway_ips(Some(gateway), None);
1548        let config = PollLoopConfig {
1549            gateway_mac: [0x02, 0, 0, 0, 0, 1],
1550            guest_mac: [0x02, 0, 0, 0, 0, 2],
1551            gateway: GatewayIps {
1552                ipv4: Some(gateway),
1553                ipv6: None,
1554            },
1555            guest_ipv4: Some(guest),
1556            guest_ipv6: None,
1557            mtu: 1500,
1558        };
1559        let frame = build_icmpv4_echo_frame(
1560            config.guest_mac,
1561            config.gateway_mac,
1562            guest.octets(),
1563            gateway.octets(),
1564            1,
1565            1,
1566            b"ping",
1567        );
1568        let platform = NetworkPolicy::from_profiles([crate::policy::NetworkProfile::Public]);
1569
1570        assert!(handle_gateway_icmp_echo(
1571            &frame,
1572            &config,
1573            &shared,
1574            &NetworkPolicy::allow_all(),
1575            Some(&platform),
1576        ));
1577        assert!(shared.rx_ring.pop().is_none());
1578    }
1579
1580    fn test_gateway() -> GatewayIps {
1581        GatewayIps {
1582            ipv4: Some(Ipv4Addr::new(100, 96, 0, 1)),
1583            ipv6: Some("fd42:6d73:62::1".parse().unwrap()),
1584        }
1585    }
1586
1587    #[test]
1588    fn resolve_tcp_host_target_ipv4_can_fall_back_to_ipv6() {
1589        let gw = test_gateway();
1590        let dst = SocketAddr::new(IpAddr::V4(gw.ipv4.unwrap()), 8080);
1591
1592        assert_eq!(
1593            resolve_tcp_host_target(dst, gw),
1594            UpstreamTcpTarget::with_fallback(
1595                SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080),
1596                SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8080),
1597            )
1598        );
1599    }
1600
1601    #[test]
1602    fn resolve_tcp_host_target_ipv6_can_fall_back_to_ipv4() {
1603        let gw = test_gateway();
1604        let dst = SocketAddr::new(IpAddr::V6(gw.ipv6.unwrap()), 8080);
1605
1606        assert_eq!(
1607            resolve_tcp_host_target(dst, gw),
1608            UpstreamTcpTarget::with_fallback(
1609                SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8080),
1610                SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080),
1611            )
1612        );
1613    }
1614
1615    #[test]
1616    fn resolve_tcp_host_target_external_has_no_fallback() {
1617        let dst = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 443);
1618
1619        assert_eq!(
1620            resolve_tcp_host_target(dst, test_gateway()),
1621            UpstreamTcpTarget::direct(dst)
1622        );
1623    }
1624
1625    #[test]
1626    fn resolve_host_dst_matches_ipv4() {
1627        let gw = test_gateway();
1628        let dst = SocketAddr::new(IpAddr::V4(gw.ipv4.unwrap()), 8080);
1629        assert_eq!(
1630            resolve_host_dst(dst, gw),
1631            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080)
1632        );
1633    }
1634
1635    #[test]
1636    fn resolve_host_dst_matches_ipv6() {
1637        let gw = test_gateway();
1638        let dst = SocketAddr::new(IpAddr::V6(gw.ipv6.unwrap()), 8080);
1639        assert_eq!(
1640            resolve_host_dst(dst, gw),
1641            SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8080)
1642        );
1643    }
1644
1645    #[test]
1646    fn resolve_host_dst_passes_through_when_family_absent() {
1647        let gw = GatewayIps {
1648            ipv4: None,
1649            ipv6: Some("fd42:6d73:62::1".parse().unwrap()),
1650        };
1651        // IPv4 dst with no IPv4 gateway must not be rewritten to loopback.
1652        let dst = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(100, 96, 0, 1)), 8080);
1653        assert_eq!(resolve_host_dst(dst, gw), dst);
1654    }
1655
1656    #[test]
1657    fn resolve_host_dst_passes_through_non_gateway() {
1658        let gw = test_gateway();
1659        let dst = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 443);
1660        assert_eq!(resolve_host_dst(dst, gw), dst);
1661    }
1662
1663    #[test]
1664    fn outbound_proxy_is_skipped_for_host_destination() {
1665        let gw = test_gateway();
1666        let guest_dst = SocketAddr::new(IpAddr::V4(gw.ipv4.unwrap()), 8080);
1667        let connect_target = resolve_tcp_host_target(guest_dst, gw);
1668        let proxy = Some(Arc::new(ResolvedOutboundProxy::Socks5 {
1669            address: "192.0.2.1:1080".parse().unwrap(),
1670            credentials: None,
1671        }));
1672
1673        assert!(
1674            ResolvedOutboundProxy::select_for_destination(
1675                &proxy,
1676                guest_dst,
1677                connect_target.primary(),
1678            )
1679            .is_none()
1680        );
1681    }
1682
1683    #[test]
1684    fn outbound_proxy_is_preserved_for_external_destination() {
1685        let gw = test_gateway();
1686        let guest_dst = "198.51.100.10:443".parse().unwrap();
1687        let connect_target = resolve_tcp_host_target(guest_dst, gw);
1688        let proxy = Some(Arc::new(ResolvedOutboundProxy::Socks5 {
1689            address: "192.0.2.1:1080".parse().unwrap(),
1690            credentials: None,
1691        }));
1692
1693        assert!(
1694            ResolvedOutboundProxy::select_for_destination(
1695                &proxy,
1696                guest_dst,
1697                connect_target.primary(),
1698            )
1699            .is_some()
1700        );
1701    }
1702
1703    #[test]
1704    fn external_icmp_echo_requests_are_not_answered_locally() {
1705        fn drive_one_frame(
1706            device: &mut SmoltcpDevice,
1707            iface: &mut Interface,
1708            sockets: &mut SocketSet<'_>,
1709            shared: &Arc<SharedState>,
1710            poll_config: &PollLoopConfig,
1711            now: Instant,
1712        ) {
1713            let frame = device.stage_next_frame().expect("expected staged frame");
1714            if handle_gateway_icmp_echo(
1715                frame,
1716                poll_config,
1717                shared,
1718                &NetworkPolicy::allow_all(),
1719                None,
1720            ) {
1721                device.drop_staged_frame();
1722                return;
1723            }
1724            let _ = iface.poll_ingress_single(now, device, sockets);
1725            let _ = iface.poll_egress(now, device, sockets);
1726        }
1727
1728        let shared = Arc::new(SharedState::new(4));
1729        let poll_config = PollLoopConfig {
1730            gateway_mac: [0x02, 0x00, 0x00, 0x00, 0x00, 0x01],
1731            guest_mac: [0x02, 0x00, 0x00, 0x00, 0x00, 0x02],
1732            gateway: GatewayIps {
1733                ipv4: Some(Ipv4Addr::new(100, 96, 0, 1)),
1734                ipv6: Some(Ipv6Addr::LOCALHOST),
1735            },
1736            guest_ipv4: Some(Ipv4Addr::new(100, 96, 0, 2)),
1737            guest_ipv6: None,
1738            mtu: 1500,
1739        };
1740        let guest_ipv4 = poll_config.guest_ipv4.unwrap();
1741        let gateway_ipv4 = poll_config.gateway.ipv4.unwrap();
1742        let mut device = SmoltcpDevice::new(shared.clone(), poll_config.mtu);
1743        let mut iface = create_interface(&mut device, &poll_config);
1744        let mut sockets = SocketSet::new(vec![]);
1745        let now = smoltcp_now();
1746
1747        shared
1748            .tx_ring
1749            .push(build_arp_request_frame(
1750                poll_config.guest_mac,
1751                guest_ipv4.octets(),
1752                gateway_ipv4.octets(),
1753            ))
1754            .unwrap();
1755        shared
1756            .tx_ring
1757            .push(build_icmpv4_echo_frame(
1758                poll_config.guest_mac,
1759                poll_config.gateway_mac,
1760                guest_ipv4.octets(),
1761                [142, 251, 216, 46],
1762                0x1234,
1763                0xABCD,
1764                b"ping",
1765            ))
1766            .unwrap();
1767
1768        drive_one_frame(
1769            &mut device,
1770            &mut iface,
1771            &mut sockets,
1772            &shared,
1773            &poll_config,
1774            now,
1775        );
1776        let _ = shared.rx_ring.pop().expect("expected ARP reply");
1777
1778        drive_one_frame(
1779            &mut device,
1780            &mut iface,
1781            &mut sockets,
1782            &shared,
1783            &poll_config,
1784            now,
1785        );
1786        assert!(
1787            shared.rx_ring.pop().is_none(),
1788            "external ICMP should not be answered locally"
1789        );
1790    }
1791
1792    // ─────────────────────────────────────────────────────────────────────
1793    // Guest-initiated TCP teardown.
1794    //
1795    // These tests drive the real `TcpConnectionTracker` + smoltcp interface
1796    // through a full TCP handshake and then a guest-initiated teardown,
1797    // asserting the observable the proxy task sees on its channel:
1798    //   - guest FIN => half-close propagated (channel EOF), server → guest
1799    //     stays open, and the slot is reclaimed once the proxy task exits
1800    //   - guest RST => immediate clean teardown
1801    // Regression tests for the CLOSE_WAIT orphan leak: without close
1802    // propagation, a guest FIN left the socket in CLOSE_WAIT forever, the
1803    // proxy task blocked on `from_smoltcp.recv()`, the upstream socket
1804    // open, and the connection-table slot consumed until the table filled.
1805    // ─────────────────────────────────────────────────────────────────────
1806
1807    use smoltcp::socket::tcp;
1808    use smoltcp::wire::{TcpControl, TcpPacket, TcpRepr, TcpSeqNumber};
1809
1810    const GUEST_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x02];
1811    const GATEWAY_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x01];
1812    const GUEST_IP: [u8; 4] = [100, 96, 0, 2];
1813    const GATEWAY_IP: [u8; 4] = [100, 96, 0, 1];
1814    // Off-subnet external destination reached via the default route.
1815    const SERVER_IP: [u8; 4] = [93, 184, 216, 34];
1816
1817    fn leak_poll_config() -> PollLoopConfig {
1818        PollLoopConfig {
1819            gateway_mac: GATEWAY_MAC,
1820            guest_mac: GUEST_MAC,
1821            gateway: GatewayIps {
1822                ipv4: Some(Ipv4Addr::from(GATEWAY_IP)),
1823                ipv6: None,
1824            },
1825            guest_ipv4: Some(Ipv4Addr::from(GUEST_IP)),
1826            guest_ipv6: None,
1827            mtu: 1500,
1828        }
1829    }
1830
1831    /// Build an Ethernet+IPv4+TCP frame from the guest with correct checksums.
1832    #[allow(clippy::too_many_arguments)]
1833    fn build_tcp_frame(
1834        src_port: u16,
1835        dst_port: u16,
1836        control: TcpControl,
1837        seq: i32,
1838        ack: Option<i32>,
1839        payload: &[u8],
1840    ) -> Vec<u8> {
1841        let src_ip = Ipv4Addr::from(GUEST_IP);
1842        let dst_ip = Ipv4Addr::from(SERVER_IP);
1843
1844        let tcp_repr = TcpRepr {
1845            src_port,
1846            dst_port,
1847            control,
1848            seq_number: TcpSeqNumber(seq),
1849            ack_number: ack.map(TcpSeqNumber),
1850            window_len: 65535,
1851            window_scale: None,
1852            max_seg_size: None,
1853            sack_permitted: false,
1854            sack_ranges: [None, None, None],
1855            timestamp: None,
1856            payload,
1857        };
1858        let ipv4_repr = Ipv4Repr {
1859            src_addr: src_ip,
1860            dst_addr: dst_ip,
1861            next_header: IpProtocol::Tcp,
1862            payload_len: tcp_repr.buffer_len(),
1863            hop_limit: 64,
1864        };
1865
1866        let frame_len = 14 + ipv4_repr.buffer_len() + tcp_repr.buffer_len();
1867        let mut frame = vec![0u8; frame_len];
1868
1869        let mut eth = EthernetFrame::new_unchecked(&mut frame);
1870        EthernetRepr {
1871            src_addr: EthernetAddress(GUEST_MAC),
1872            dst_addr: EthernetAddress(GATEWAY_MAC),
1873            ethertype: EthernetProtocol::Ipv4,
1874        }
1875        .emit(&mut eth);
1876
1877        let ip_end = 14 + ipv4_repr.buffer_len();
1878        ipv4_repr.emit(
1879            &mut Ipv4Packet::new_unchecked(&mut frame[14..ip_end]),
1880            &ChecksumCapabilities::default(),
1881        );
1882        tcp_repr.emit(
1883            &mut TcpPacket::new_unchecked(&mut frame[ip_end..]),
1884            &IpAddress::Ipv4(src_ip),
1885            &IpAddress::Ipv4(dst_ip),
1886            &ChecksumCapabilities::default(),
1887        );
1888
1889        frame
1890    }
1891
1892    /// Push one guest frame, run a single ingress pass, then drain egress.
1893    fn ingress(
1894        frame: Vec<u8>,
1895        device: &mut SmoltcpDevice,
1896        iface: &mut Interface,
1897        sockets: &mut SocketSet<'_>,
1898        shared: &Arc<SharedState>,
1899        now: Instant,
1900    ) {
1901        shared.tx_ring.push(frame).unwrap();
1902        device.stage_next_frame().expect("frame should stage");
1903        iface.poll_ingress_single(now, device, sockets);
1904        loop {
1905            let r = iface.poll_egress(now, device, sockets);
1906            if matches!(r, smoltcp::iface::PollResult::None) {
1907                break;
1908            }
1909        }
1910    }
1911
1912    /// Pop the newest smoltcp→guest reply and return its (seq, ack, is_syn,
1913    /// is_fin, is_rst). Drains all queued replies, returning the last TCP one.
1914    fn last_tcp_reply(shared: &Arc<SharedState>) -> Option<(i32, i32, bool, bool, bool)> {
1915        let mut out = None;
1916        while let Some(frame) = shared.rx_ring.pop() {
1917            if frame.len() < 34 {
1918                continue;
1919            }
1920            // eth(14) + ipv4(20) then TCP.
1921            if frame[23] != 6 {
1922                continue; // not TCP (e.g. ARP reply passes as non-IPv4)
1923            }
1924            let tcp = match TcpPacket::new_checked(&frame[34..]) {
1925                Ok(p) => p,
1926                Err(_) => continue,
1927            };
1928            out = Some((
1929                tcp.seq_number().0,
1930                tcp.ack_number().0,
1931                tcp.syn(),
1932                tcp.fin(),
1933                tcp.rst(),
1934            ));
1935        }
1936        out
1937    }
1938
1939    /// Find the single tracked TCP socket's state.
1940    fn only_tcp_state(sockets: &SocketSet<'_>) -> Option<tcp::State> {
1941        for (_h, sock) in sockets.iter() {
1942            if let smoltcp::socket::Socket::Tcp(s) = sock {
1943                return Some(s.state());
1944            }
1945        }
1946        None
1947    }
1948
1949    /// Drive guest→server handshake to ESTABLISHED, returning (server_isn,
1950    /// guest_seq_after_handshake).
1951    fn handshake(
1952        tracker: &mut TcpConnectionTracker,
1953        device: &mut SmoltcpDevice,
1954        iface: &mut Interface,
1955        sockets: &mut SocketSet<'_>,
1956        shared: &Arc<SharedState>,
1957        now: Instant,
1958        guest_port: u16,
1959    ) -> (i32, i32) {
1960        let src = SocketAddr::new(Ipv4Addr::from(GUEST_IP).into(), guest_port);
1961        let dst = SocketAddr::new(Ipv4Addr::from(SERVER_IP).into(), 443);
1962
1963        // Pre-populate neighbor cache so smoltcp can address replies to the
1964        // guest without stalling on ARP.
1965        ingress(
1966            build_arp_request_frame(GUEST_MAC, GUEST_IP, GATEWAY_IP),
1967            device,
1968            iface,
1969            sockets,
1970            shared,
1971            now,
1972        );
1973        let _ = shared.rx_ring.pop(); // ARP reply
1974
1975        let guest_isn = 1000i32;
1976
1977        // 1) Guest SYN — tracker creates the listening socket first (as the
1978        //    real poll loop does), then smoltcp completes the handshake.
1979        assert!(
1980            tracker.create_tcp_socket(src, dst, sockets),
1981            "socket creation should succeed under the limit"
1982        );
1983        ingress(
1984            build_tcp_frame(guest_port, 443, TcpControl::Syn, guest_isn, None, &[]),
1985            device,
1986            iface,
1987            sockets,
1988            shared,
1989            now,
1990        );
1991        let (server_isn, ack, is_syn, _, _) =
1992            last_tcp_reply(shared).expect("expected SYN-ACK from smoltcp");
1993        assert!(is_syn, "expected SYN flag on handshake reply");
1994        assert_eq!(ack, guest_isn + 1, "SYN-ACK should ack guest ISN+1");
1995
1996        // 2) Guest ACK — completes the handshake.
1997        ingress(
1998            build_tcp_frame(
1999                guest_port,
2000                443,
2001                TcpControl::None,
2002                guest_isn + 1,
2003                Some(server_isn + 1),
2004                &[],
2005            ),
2006            device,
2007            iface,
2008            sockets,
2009            shared,
2010            now,
2011        );
2012        assert_eq!(
2013            only_tcp_state(sockets),
2014            Some(tcp::State::Established),
2015            "socket should be ESTABLISHED after handshake",
2016        );
2017
2018        (server_isn, guest_isn + 1)
2019    }
2020
2021    /// Complete a guest→server handshake and hand the connection to a proxy.
2022    fn establish(
2023        tracker: &mut TcpConnectionTracker,
2024        device: &mut SmoltcpDevice,
2025        iface: &mut Interface,
2026        sockets: &mut SocketSet<'_>,
2027        shared: &Arc<SharedState>,
2028        now: Instant,
2029        guest_port: u16,
2030    ) -> (i32, i32, Vec<NewConnection>) {
2031        let (server_isn, guest_seq) =
2032            handshake(tracker, device, iface, sockets, shared, now, guest_port);
2033
2034        // Poll loop detects the established connection and spawns a proxy.
2035        let new_conns = tracker.take_new_connections(sockets);
2036        assert_eq!(
2037            new_conns.len(),
2038            1,
2039            "one new connection should be handed off"
2040        );
2041
2042        (server_isn, guest_seq, new_conns)
2043    }
2044
2045    #[test]
2046    fn guest_fin_propagates_half_close_without_killing_the_connection() {
2047        let shared = Arc::new(SharedState::new(64));
2048        let poll_config = leak_poll_config();
2049        let mut device = SmoltcpDevice::new(shared.clone(), poll_config.mtu);
2050        let mut iface = create_interface(&mut device, &poll_config);
2051        let mut sockets = SocketSet::new(vec![]);
2052        let mut tracker = TcpConnectionTracker::new(None);
2053        let now = smoltcp_now();
2054
2055        let (server_isn, guest_seq, mut new_conns) = establish(
2056            &mut tracker,
2057            &mut device,
2058            &mut iface,
2059            &mut sockets,
2060            &shared,
2061            now,
2062            54321,
2063        );
2064        // The proxy side of the channel: a real proxy task blocks on
2065        // `from_smoltcp.recv()` while the connection is idle.
2066        let conn = new_conns.remove(0);
2067        let mut from_smoltcp = conn.from_smoltcp;
2068        let to_smoltcp = conn.to_smoltcp;
2069
2070        // Guest half-closes (shutdown(SHUT_WR), or its process exits while
2071        // holding an idle keep-alive: no unread data => FIN, not RST).
2072        ingress(
2073            build_tcp_frame(
2074                54321,
2075                443,
2076                TcpControl::Fin,
2077                guest_seq,
2078                Some(server_isn + 1),
2079                &[],
2080            ),
2081            &mut device,
2082            &mut iface,
2083            &mut sockets,
2084            &shared,
2085            now,
2086        );
2087        assert_eq!(
2088            only_tcp_state(&sockets),
2089            Some(tcp::State::CloseWait),
2090            "guest FIN should move the smoltcp socket to CLOSE_WAIT",
2091        );
2092
2093        // One relay pass propagates the half-close: the proxy task's
2094        // receiver disconnects, so its `recv()` returns `None` and it can
2095        // shut down the guest → server direction upstream.
2096        tracker.relay_data(&mut sockets);
2097        assert!(
2098            matches!(
2099                from_smoltcp.try_recv(),
2100                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected)
2101            ),
2102            "guest FIN must propagate EOF to the proxy task",
2103        );
2104
2105        // The connection must NOT be torn down: a half-closed guest can
2106        // still receive. Pending server → guest data is still delivered.
2107        assert!(
2108            tracker.has_socket_for(
2109                &SocketAddr::new(Ipv4Addr::from(GUEST_IP).into(), 54321),
2110                &SocketAddr::new(Ipv4Addr::from(SERVER_IP).into(), 443),
2111            ),
2112            "half-closed connection must stay tracked while the proxy runs",
2113        );
2114        let payload = b"pending server response";
2115        to_smoltcp
2116            .try_send(bytes::Bytes::from_static(payload))
2117            .expect("server → guest channel should accept data");
2118        while shared.rx_ring.pop().is_some() {} // drain handshake/ACK frames
2119        tracker.relay_data(&mut sockets);
2120        loop {
2121            let r = iface.poll_egress(now, &mut device, &mut sockets);
2122            if matches!(r, smoltcp::iface::PollResult::None) {
2123                break;
2124            }
2125        }
2126        let mut delivered = false;
2127        while let Some(frame) = shared.rx_ring.pop() {
2128            if frame.windows(payload.len()).any(|w| w == payload) {
2129                delivered = true;
2130            }
2131        }
2132        assert!(
2133            delivered,
2134            "server data must still reach a half-closed guest",
2135        );
2136        assert_eq!(
2137            only_tcp_state(&sockets),
2138            Some(tcp::State::CloseWait),
2139            "socket must stay open (CLOSE_WAIT) while the proxy is alive",
2140        );
2141    }
2142
2143    #[test]
2144    fn guest_fin_connection_is_reaped_after_proxy_exit() {
2145        let shared = Arc::new(SharedState::new(64));
2146        let poll_config = leak_poll_config();
2147        let mut device = SmoltcpDevice::new(shared.clone(), poll_config.mtu);
2148        let mut iface = create_interface(&mut device, &poll_config);
2149        let mut sockets = SocketSet::new(vec![]);
2150        let mut tracker = TcpConnectionTracker::new(None);
2151        let now = smoltcp_now();
2152
2153        let (server_isn, guest_seq, mut new_conns) = establish(
2154            &mut tracker,
2155            &mut device,
2156            &mut iface,
2157            &mut sockets,
2158            &shared,
2159            now,
2160            54321,
2161        );
2162        let conn = new_conns.remove(0);
2163        let from_smoltcp = conn.from_smoltcp;
2164        let to_smoltcp = conn.to_smoltcp;
2165
2166        // Guest FIN, then a relay pass to propagate the half-close.
2167        ingress(
2168            build_tcp_frame(
2169                54321,
2170                443,
2171                TcpControl::Fin,
2172                guest_seq,
2173                Some(server_isn + 1),
2174                &[],
2175            ),
2176            &mut device,
2177            &mut iface,
2178            &mut sockets,
2179            &shared,
2180            now,
2181        );
2182        tracker.relay_data(&mut sockets);
2183
2184        // The proxy task exits (upstream closed after seeing our FIN) and
2185        // drops its channel ends.
2186        drop(from_smoltcp);
2187        drop(to_smoltcp);
2188
2189        // The tracker detects the proxy exit and closes the socket: the
2190        // guest gets our FIN (CLOSE_WAIT → LAST_ACK)...
2191        while shared.rx_ring.pop().is_some() {} // drain handshake/ACK frames
2192        tracker.relay_data(&mut sockets);
2193        loop {
2194            let r = iface.poll_egress(now, &mut device, &mut sockets);
2195            if matches!(r, smoltcp::iface::PollResult::None) {
2196                break;
2197            }
2198        }
2199        let (fin_seq, _, _, is_fin, _) =
2200            last_tcp_reply(&shared).expect("expected FIN toward the guest");
2201        assert!(is_fin, "proxy exit after guest FIN must FIN the guest side");
2202
2203        // ...and the guest's final ACK completes the close.
2204        ingress(
2205            build_tcp_frame(
2206                54321,
2207                443,
2208                TcpControl::None,
2209                guest_seq + 1,
2210                Some(fin_seq + 1),
2211                &[],
2212            ),
2213            &mut device,
2214            &mut iface,
2215            &mut sockets,
2216            &shared,
2217            now,
2218        );
2219        tracker.relay_data(&mut sockets);
2220        tracker.cleanup_closed(&mut sockets);
2221
2222        // The socket is reaped and the table slot is free again — no
2223        // CLOSE_WAIT orphan pinning a slot until the table fills.
2224        assert!(
2225            !tracker.has_socket_for(
2226                &SocketAddr::new(Ipv4Addr::from(GUEST_IP).into(), 54321),
2227                &SocketAddr::new(Ipv4Addr::from(SERVER_IP).into(), 443),
2228            ),
2229            "connection must be evicted after FIN + proxy exit",
2230        );
2231        assert_eq!(
2232            only_tcp_state(&sockets),
2233            None,
2234            "socket must be removed from the socket set",
2235        );
2236    }
2237
2238    #[test]
2239    fn guest_rst_is_cleaned_up() {
2240        let shared = Arc::new(SharedState::new(64));
2241        let poll_config = leak_poll_config();
2242        let mut device = SmoltcpDevice::new(shared.clone(), poll_config.mtu);
2243        let mut iface = create_interface(&mut device, &poll_config);
2244        let mut sockets = SocketSet::new(vec![]);
2245        let mut tracker = TcpConnectionTracker::new(None);
2246        let now = smoltcp_now();
2247
2248        let (server_isn, guest_seq, mut new_conns) = establish(
2249            &mut tracker,
2250            &mut device,
2251            &mut iface,
2252            &mut sockets,
2253            &shared,
2254            now,
2255            54322,
2256        );
2257        let conn = new_conns.remove(0);
2258        let mut from_smoltcp = conn.from_smoltcp;
2259        let _to_smoltcp = conn.to_smoltcp;
2260
2261        // Guest aborts the connection (RST) instead of closing it cleanly.
2262        ingress(
2263            build_tcp_frame(
2264                54322,
2265                443,
2266                TcpControl::Rst,
2267                guest_seq,
2268                Some(server_isn + 1),
2269                &[],
2270            ),
2271            &mut device,
2272            &mut iface,
2273            &mut sockets,
2274            &shared,
2275            now,
2276        );
2277
2278        // Maintenance reaps the Closed socket and drops the proxy channel.
2279        for _ in 0..8 {
2280            tracker.relay_data(&mut sockets);
2281            tracker.cleanup_closed(&mut sockets);
2282            let _ = iface.poll_egress(now, &mut device, &mut sockets);
2283        }
2284
2285        assert!(
2286            !tracker.has_socket_for(
2287                &SocketAddr::new(Ipv4Addr::from(GUEST_IP).into(), 54322),
2288                &SocketAddr::new(Ipv4Addr::from(SERVER_IP).into(), 443),
2289            ),
2290            "RST connection should be evicted from the tracker",
2291        );
2292        // The proxy task's receiver IS disconnected => `recv()` returns None
2293        // => the proxy breaks its relay loop => the upstream socket is closed.
2294        assert!(
2295            matches!(
2296                from_smoltcp.try_recv(),
2297                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected)
2298            ),
2299            "RST teardown must close the proxy channel (clean, no orphan)",
2300        );
2301    }
2302
2303    #[test]
2304    fn closed_slot_is_reused_under_pressure_but_pending_reset_is_sent_first() {
2305        let shared = Arc::new(SharedState::new(64));
2306        let config = leak_poll_config();
2307        let mut device = SmoltcpDevice::new(shared.clone(), config.mtu);
2308        let mut iface = create_interface(&mut device, &config);
2309        let mut sockets = SocketSet::new(vec![]);
2310        let mut tracker = TcpConnectionTracker::new(NonZeroUsize::new(1));
2311        let now = smoltcp_now();
2312        handshake(
2313            &mut tracker,
2314            &mut device,
2315            &mut iface,
2316            &mut sockets,
2317            &shared,
2318            now,
2319            40000,
2320        );
2321        let handle = sockets.iter().next().unwrap().0;
2322        sockets.get_mut::<tcp::Socket>(handle).abort();
2323        tracker.cleanup_closed(&mut sockets);
2324        assert_eq!(
2325            only_tcp_state(&sockets),
2326            Some(tcp::State::Closed),
2327            "pending RST must not be lost during cleanup"
2328        );
2329        let src = SocketAddr::new(Ipv4Addr::from(GUEST_IP).into(), 40001);
2330        let dst = SocketAddr::new(Ipv4Addr::from(SERVER_IP).into(), 443);
2331        assert!(
2332            !tracker.create_tcp_socket(src, dst, &mut sockets),
2333            "the pending reset still owns its socket budget"
2334        );
2335        loop {
2336            if matches!(
2337                iface.poll_egress(now, &mut device, &mut sockets),
2338                smoltcp::iface::PollResult::None
2339            ) {
2340                break;
2341            }
2342        }
2343        let (_, _, _, _, rst) = last_tcp_reply(&shared).expect("RST must reach guest");
2344        assert!(rst);
2345        // No periodic cleanup call: allocating the next SYN reclaims the slot.
2346        assert!(tracker.create_tcp_socket(src, dst, &mut sockets));
2347        assert!(!tracker.has_socket_for(
2348            &SocketAddr::new(Ipv4Addr::from(GUEST_IP).into(), 40000),
2349            &dst
2350        ));
2351    }
2352
2353    #[test]
2354    fn reset_during_handshake_does_not_leave_an_idle_listener_at_capacity() {
2355        let shared = Arc::new(SharedState::new(64));
2356        let config = leak_poll_config();
2357        let mut device = SmoltcpDevice::new(shared.clone(), config.mtu);
2358        let mut iface = create_interface(&mut device, &config);
2359        let mut sockets = SocketSet::new(vec![]);
2360        let mut tracker = TcpConnectionTracker::new(NonZeroUsize::new(1));
2361        let now = smoltcp_now();
2362        ingress(
2363            build_arp_request_frame(GUEST_MAC, GUEST_IP, GATEWAY_IP),
2364            &mut device,
2365            &mut iface,
2366            &mut sockets,
2367            &shared,
2368            now,
2369        );
2370        let src = SocketAddr::new(Ipv4Addr::from(GUEST_IP).into(), 40000);
2371        let dst = SocketAddr::new(Ipv4Addr::from(SERVER_IP).into(), 443);
2372        assert!(tracker.create_tcp_socket(src, dst, &mut sockets));
2373        ingress(
2374            build_tcp_frame(40000, 443, TcpControl::Syn, 1000, None, &[]),
2375            &mut device,
2376            &mut iface,
2377            &mut sockets,
2378            &shared,
2379            now,
2380        );
2381        let (server_seq, _, syn, _, _) = last_tcp_reply(&shared).unwrap();
2382        assert!(syn);
2383        assert_eq!(only_tcp_state(&sockets), Some(tcp::State::SynReceived));
2384        ingress(
2385            build_tcp_frame(40000, 443, TcpControl::Rst, 1001, Some(server_seq + 1), &[]),
2386            &mut device,
2387            &mut iface,
2388            &mut sockets,
2389            &shared,
2390            now,
2391        );
2392        assert_eq!(only_tcp_state(&sockets), Some(tcp::State::Listen));
2393        let next_src = SocketAddr::new(Ipv4Addr::from(GUEST_IP).into(), 40001);
2394        assert!(tracker.create_tcp_socket(next_src, dst, &mut sockets));
2395        assert!(!tracker.has_socket_for(&src, &dst));
2396    }
2397
2398    #[test]
2399    fn connection_pressure_preserves_time_wait_protection() {
2400        let shared = Arc::new(SharedState::new(64));
2401        let config = leak_poll_config();
2402        let mut device = SmoltcpDevice::new(shared.clone(), config.mtu);
2403        let mut iface = create_interface(&mut device, &config);
2404        let mut sockets = SocketSet::new(vec![]);
2405        let mut tracker = TcpConnectionTracker::new(NonZeroUsize::new(1));
2406        let now = smoltcp_now();
2407        let (_, guest_seq) = handshake(
2408            &mut tracker,
2409            &mut device,
2410            &mut iface,
2411            &mut sockets,
2412            &shared,
2413            now,
2414            40000,
2415        );
2416        let handle = sockets.iter().next().unwrap().0;
2417        sockets.get_mut::<tcp::Socket>(handle).close();
2418        loop {
2419            if matches!(
2420                iface.poll_egress(now, &mut device, &mut sockets),
2421                smoltcp::iface::PollResult::None
2422            ) {
2423                break;
2424            }
2425        }
2426        let (fin_seq, _, _, fin, _) = last_tcp_reply(&shared).unwrap();
2427        assert!(fin);
2428        ingress(
2429            build_tcp_frame(
2430                40000,
2431                443,
2432                TcpControl::None,
2433                guest_seq,
2434                Some(fin_seq + 1),
2435                &[],
2436            ),
2437            &mut device,
2438            &mut iface,
2439            &mut sockets,
2440            &shared,
2441            now,
2442        );
2443        ingress(
2444            build_tcp_frame(
2445                40000,
2446                443,
2447                TcpControl::Fin,
2448                guest_seq,
2449                Some(fin_seq + 1),
2450                &[],
2451            ),
2452            &mut device,
2453            &mut iface,
2454            &mut sockets,
2455            &shared,
2456            now,
2457        );
2458        assert_eq!(only_tcp_state(&sockets), Some(tcp::State::TimeWait));
2459        tracker.cleanup_closed(&mut sockets);
2460        let src = SocketAddr::new(Ipv4Addr::from(GUEST_IP).into(), 40001);
2461        let dst = SocketAddr::new(Ipv4Addr::from(SERVER_IP).into(), 443);
2462        assert!(!tracker.create_tcp_socket(src, dst, &mut sockets));
2463        assert_eq!(only_tcp_state(&sockets), Some(tcp::State::TimeWait));
2464    }
2465
2466    #[test]
2467    fn full_connection_table_refuses_new_sockets() {
2468        // Once the table is full, new guest connections are refused. Uses a
2469        // small max to avoid 256 full handshakes; the gating logic is
2470        // identical to the 256 default.
2471        let mut tracker = TcpConnectionTracker::new(NonZeroUsize::new(4));
2472        let mut sockets = SocketSet::new(vec![]);
2473        let shared = Arc::new(SharedState::new(64));
2474        let config = leak_poll_config();
2475        let mut device = SmoltcpDevice::new(shared.clone(), config.mtu);
2476        let mut iface = create_interface(&mut device, &config);
2477        let now = smoltcp_now();
2478        let dst = SocketAddr::new(Ipv4Addr::from(SERVER_IP).into(), 443);
2479
2480        for port in 40000u16..40004 {
2481            handshake(
2482                &mut tracker,
2483                &mut device,
2484                &mut iface,
2485                &mut sockets,
2486                &shared,
2487                now,
2488                port,
2489            );
2490        }
2491        // Table full (4 slots held). The 5th guest SYN gets no socket — which
2492        // in the poll loop means smoltcp emits RST / no reply => the guest
2493        // sees egress as unreachable.
2494        let src = SocketAddr::new(Ipv4Addr::from(GUEST_IP).into(), 40004);
2495        assert!(
2496            !tracker.create_tcp_socket(src, dst, &mut sockets),
2497            "creation at the limit must be refused",
2498        );
2499    }
2500
2501    #[test]
2502    fn guest_fin_before_proxy_spawn_is_handed_off() {
2503        let shared = Arc::new(SharedState::new(64));
2504        let poll_config = leak_poll_config();
2505        let mut device = SmoltcpDevice::new(shared.clone(), poll_config.mtu);
2506        let mut iface = create_interface(&mut device, &poll_config);
2507        let mut sockets = SocketSet::new(vec![]);
2508        let mut tracker = TcpConnectionTracker::new(None);
2509        let now = smoltcp_now();
2510        let guest_port = 54323;
2511        let (server_isn, guest_seq) = handshake(
2512            &mut tracker,
2513            &mut device,
2514            &mut iface,
2515            &mut sockets,
2516            &shared,
2517            now,
2518            guest_port,
2519        );
2520
2521        // The production poll loop drains every queued guest frame before
2522        // taking new connections, so the FIN can be processed before the
2523        // proxy task is spawned.
2524        ingress(
2525            build_tcp_frame(
2526                guest_port,
2527                443,
2528                TcpControl::Fin,
2529                guest_seq,
2530                Some(server_isn + 1),
2531                &[],
2532            ),
2533            &mut device,
2534            &mut iface,
2535            &mut sockets,
2536            &shared,
2537            now,
2538        );
2539        assert_eq!(
2540            only_tcp_state(&sockets),
2541            Some(tcp::State::CloseWait),
2542            "guest FIN should arrive before the proxy handoff",
2543        );
2544
2545        let new_conns = tracker.take_new_connections(&mut sockets);
2546        assert_eq!(
2547            new_conns.len(),
2548            1,
2549            "a connection that reached CLOSE_WAIT still needs a proxy task",
2550        );
2551    }
2552}