Skip to main content

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