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