Skip to main content

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