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