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