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
13use smoltcp::iface::{Config, Interface, SocketSet};
14use smoltcp::time::Instant;
15
16use smoltcp::wire::{
17    EthernetAddress, EthernetFrame, EthernetProtocol, HardwareAddress, Icmpv4Packet, Icmpv4Repr,
18    Icmpv6Packet, Icmpv6Repr, IpAddress, IpCidr, IpProtocol, Ipv4Packet, Ipv4Repr, Ipv6Packet,
19    Ipv6Repr, TcpPacket, UdpPacket,
20};
21
22use crate::config::{DnsConfig, PublishedPort};
23use crate::conn::ConnectionTracker;
24use crate::device::SmoltcpDevice;
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::proxy;
33use crate::publisher::PortPublisher;
34use crate::shared::SharedState;
35use crate::tls::{proxy as tls_proxy, state::TlsState};
36use crate::udp_relay::UdpRelay;
37
38//--------------------------------------------------------------------------------------------------
39// Types
40//--------------------------------------------------------------------------------------------------
41
42/// Result of classifying a guest ethernet frame before smoltcp processes it.
43///
44/// Pre-inspection allows the poll loop to:
45/// - Create TCP sockets before smoltcp sees a SYN (preventing auto-RST).
46/// - Handle non-DNS UDP outside smoltcp (smoltcp lacks wildcard port binding).
47/// - Route DNS queries to the interception handler.
48pub enum FrameAction {
49    /// TCP SYN to a new destination — create a smoltcp socket before
50    /// letting smoltcp process the frame.
51    TcpSyn { src: SocketAddr, dst: SocketAddr },
52
53    /// Non-DNS UDP datagram — handle entirely outside smoltcp via the UDP
54    /// relay.
55    UdpRelay { src: SocketAddr, dst: SocketAddr },
56
57    /// DNS query (UDP to port 53) — let smoltcp's bound UDP socket handle it.
58    Dns,
59
60    /// Everything else (ARP, NDP, ICMP, TCP data/ACK/FIN, etc.) — let
61    /// smoltcp process normally.
62    Passthrough,
63}
64
65/// Resolved network parameters for the poll loop. Created by
66/// `SmoltcpNetwork::new()` from `NetworkConfig` + sandbox slot.
67pub struct PollLoopConfig {
68    /// Gateway MAC address (smoltcp's identity on the virtual LAN).
69    pub gateway_mac: [u8; 6],
70    /// Guest MAC address.
71    pub guest_mac: [u8; 6],
72    /// Gateway addresses owned by the smoltcp virtual stack. Each family
73    /// is `Some` when that family is active for this sandbox (host has a
74    /// route, or the user supplied an explicit address).
75    pub gateway: GatewayIps,
76    /// Guest IPv4 address. `None` when IPv4 is inactive for this sandbox.
77    pub guest_ipv4: Option<Ipv4Addr>,
78    /// Guest IPv6 address. `None` when IPv6 is inactive for this sandbox.
79    pub guest_ipv6: Option<Ipv6Addr>,
80    /// IP-level MTU (e.g. 1500).
81    pub mtu: usize,
82}
83
84/// Per-sandbox gateway addresses owned by the smoltcp virtual stack.
85///
86/// Each family is `Some` when active for this sandbox and `None` otherwise.
87/// `resolve_host_dst` rewrites gateway-bound connections to loopback at dial time.
88#[derive(Debug, Clone, Copy)]
89pub struct GatewayIps {
90    /// Gateway IPv4.
91    pub ipv4: Option<Ipv4Addr>,
92    /// Gateway IPv6.
93    pub ipv6: Option<Ipv6Addr>,
94}
95
96//--------------------------------------------------------------------------------------------------
97// Functions
98//--------------------------------------------------------------------------------------------------
99
100/// Classify a raw ethernet frame for pre-inspection.
101///
102/// Uses smoltcp's wire module for zero-copy parsing. Returns
103/// [`FrameAction::Passthrough`] for any frame that cannot be parsed or
104/// doesn't match a special case.
105pub fn classify_frame(frame: &[u8]) -> FrameAction {
106    let Ok(eth) = EthernetFrame::new_checked(frame) else {
107        return FrameAction::Passthrough;
108    };
109
110    match eth.ethertype() {
111        EthernetProtocol::Ipv4 => classify_ipv4(eth.payload()),
112        EthernetProtocol::Ipv6 => classify_ipv6(eth.payload()),
113        _ => FrameAction::Passthrough, // ARP, etc.
114    }
115}
116
117/// Create and configure the smoltcp [`Interface`].
118///
119/// The interface is configured as the **gateway**: it owns the gateway IP
120/// addresses and responds to ARP/NDP for them. `any_ip` mode is enabled so
121/// smoltcp accepts traffic destined for arbitrary remote IPs (not just the
122/// gateway), combined with default routes.
123pub fn create_interface(device: &mut SmoltcpDevice, config: &PollLoopConfig) -> Interface {
124    let hw_addr = HardwareAddress::Ethernet(EthernetAddress(config.gateway_mac));
125    let iface_config = Config::new(hw_addr);
126    let mut iface = Interface::new(iface_config, device, smoltcp_now());
127
128    // Configure gateway IP addresses for the active families.
129    iface.update_ip_addrs(|addrs| {
130        if let Some(ipv4) = config.gateway.ipv4 {
131            addrs
132                .push(IpCidr::new(IpAddress::Ipv4(ipv4), 30)) // 30 subnet: gateway + guest.
133                .expect("failed to add gateway IPv4 address");
134        }
135        if let Some(ipv6) = config.gateway.ipv6 {
136            addrs
137                .push(IpCidr::new(IpAddress::Ipv6(ipv6), 64))
138                .expect("failed to add gateway IPv6 address");
139        }
140    });
141
142    // Default routes so smoltcp accepts traffic for all destinations.
143    if let Some(ipv4) = config.gateway.ipv4 {
144        iface
145            .routes_mut()
146            .add_default_ipv4_route(ipv4)
147            .expect("failed to add default IPv4 route");
148    }
149    if let Some(ipv6) = config.gateway.ipv6 {
150        iface
151            .routes_mut()
152            .add_default_ipv6_route(ipv6)
153            .expect("failed to add default IPv6 route");
154    }
155
156    // Accept traffic destined for any IP, not just gateway addresses.
157    iface.set_any_ip(true);
158
159    iface
160}
161
162/// Main smoltcp poll loop. Runs on a dedicated OS thread.
163///
164/// Processes guest frames with pre-inspection, drives smoltcp's TCP/IP stack,
165/// and sleeps via `poll(2)` between events.
166///
167/// # Phases per iteration
168///
169/// 1. **Drain guest frames** — pop from `tx_ring`, classify, pre-inspect.
170/// 2. **smoltcp egress + maintenance** — transmit queued packets, run timers.
171/// 3. **Service connections** — relay data between smoltcp sockets and proxy
172///    tasks (added by later tasks).
173/// 4. **Sleep** — `poll(2)` on `tx_wake` + `proxy_wake` pipes with smoltcp's
174///    requested timeout.
175///
176/// # Arguments
177///
178/// * `shared` - Stack-wide shared state: `tx_ring` / `rx_ring` for the virtio-net boundary
179///   and the wake eventfds.
180/// * `config` - Resolved per-sandbox parameters (gateway / guest MAC + IPv4 + IPv6, MTU).
181/// * `network_policy` - User-provided egress policy. Evaluated against the sandbox's
182///   gateway IPs (stored on [`SharedState`]) so `DestinationGroup::Host` rules match.
183/// * `dns_config` - DNS interception settings (block lists, upstreams, timeout).
184/// * `tls_state` - Optional TLS MITM state; drives interception of intercepted ports and DoT
185///   when present.
186/// * `published_ports` - Host → guest port publishes; the publisher accepts inbound
187///   connections on the host-bind address and forwards into the guest.
188/// * `max_connections` - Optional cap on concurrent guest connections tracked by
189///   [`ConnectionTracker`]; `None` uses the default.
190/// * `tokio_handle` - Runtime handle used for proxy tasks, DNS forwarding, port publishing,
191///   and ICMP relays.
192#[allow(clippy::too_many_arguments)]
193pub fn smoltcp_poll_loop(
194    shared: Arc<SharedState>,
195    config: PollLoopConfig,
196    network_policy: NetworkPolicy,
197    dns_config: DnsConfig,
198    tls_state: Option<Arc<TlsState>>,
199    published_ports: Vec<PublishedPort>,
200    max_connections: Option<usize>,
201    tokio_handle: tokio::runtime::Handle,
202) {
203    let mut device = SmoltcpDevice::new(shared.clone(), config.mtu);
204    let mut iface = create_interface(&mut device, &config);
205    let mut sockets = SocketSet::new(vec![]);
206    let mut conn_tracker = ConnectionTracker::new(max_connections);
207
208    // The DNS forwarder needs to know which IPs count as "the gateway"
209    // (so it routes guest queries to those addresses through the
210    // configured upstream) and a policy evaluator (so guest-chosen
211    // `@target` resolvers are gated by egress rules just like any
212    // other outbound).
213    let gateway_ips: Arc<HashSet<IpAddr>> = Arc::new(
214        config
215            .gateway
216            .ipv4
217            .map(IpAddr::V4)
218            .into_iter()
219            .chain(config.gateway.ipv6.map(IpAddr::V6))
220            .collect(),
221    );
222    // Gateway IPs must be on SharedState before any egress evaluation runs,
223    // so `DestinationGroup::Host` rules can resolve to the right address.
224    shared.set_gateway_ips(config.gateway.ipv4, config.gateway.ipv6);
225    let network_policy = Arc::new(network_policy);
226
227    let (mut dns_interceptor, dns_forwarder_handle) = DnsInterceptor::new(
228        &mut sockets,
229        dns_config,
230        shared.clone(),
231        &tokio_handle,
232        gateway_ips,
233        network_policy.clone(),
234        config.gateway,
235        config.gateway_mac,
236        config.guest_mac,
237    );
238    let mut port_publisher = PortPublisher::new(
239        &published_ports,
240        config.guest_ipv4,
241        config.guest_ipv6,
242        config.gateway.ipv4,
243        config.gateway.ipv6,
244        config.gateway_mac,
245        config.guest_mac,
246        network_policy.clone(),
247        shared.clone(),
248        &tokio_handle,
249    );
250    let mut udp_relay = UdpRelay::new(
251        shared.clone(),
252        config.gateway_mac,
253        config.guest_mac,
254        tokio_handle.clone(),
255    );
256    let icmp_relay = IcmpRelay::new(
257        shared.clone(),
258        config.gateway_mac,
259        config.guest_mac,
260        tokio_handle.clone(),
261    );
262
263    // Rate-limit cleanup operations: run at most once per second.
264    let mut last_cleanup = std::time::Instant::now();
265
266    // poll(2) file descriptors for sleeping.
267    let mut poll_fds = [
268        libc::pollfd {
269            fd: shared.tx_wake.as_raw_fd(),
270            events: libc::POLLIN,
271            revents: 0,
272        },
273        libc::pollfd {
274            fd: shared.proxy_wake.as_raw_fd(),
275            events: libc::POLLIN,
276            revents: 0,
277        },
278    ];
279
280    loop {
281        let now = smoltcp_now();
282
283        // ── Phase 1: Drain all guest frames with pre-inspection ──────────
284        while let Some(frame) = device.stage_next_frame() {
285            if handle_gateway_icmp_echo(frame, &config, &shared) {
286                device.drop_staged_frame();
287                continue;
288            }
289
290            if icmp_relay.relay_outbound_if_echo(frame, &config, &network_policy) {
291                device.drop_staged_frame();
292                continue;
293            }
294
295            match classify_frame(frame) {
296                FrameAction::TcpSyn { src, dst } => {
297                    let allow = match DnsPortType::from_tcp(dst.port()) {
298                        // Plain DNS: the interceptor enforces policy at
299                        // the application layer (block list + rebind
300                        // protection); bypass the network egress check.
301                        DnsPortType::Dns => true,
302                        // DoT: intercept only when TLS MITM is
303                        // configured. Without it, the block list can't
304                        // apply (traffic is encrypted end-to-end), so
305                        // we refuse to force a fall-back to plain
306                        // TCP/53. When TLS MITM is configured, bypass
307                        // egress policy the same way plain DNS does —
308                        // policy for the upstream resolver is applied
309                        // per query by the forwarder.
310                        DnsPortType::EncryptedDns => {
311                            if tls_state.is_some() {
312                                true
313                            } else {
314                                tracing::debug!(%dst, "DoT port refused (TLS interception not configured); stub should fall back to TCP/53");
315                                false
316                            }
317                        }
318                        // Alternative DNS protocol we can't proxy:
319                        // refuse outright — no socket means smoltcp
320                        // emits RST, which the guest's stub treats as
321                        // "upstream unavailable" and falls back to
322                        // plain TCP/53.
323                        DnsPortType::AlternativeDns => {
324                            tracing::debug!(%dst, "alternative-DNS TCP port refused; stub should fall back to TCP/53");
325                            false
326                        }
327                        // Other: regular outbound — defer Domain rules to first-flight;
328                        // accept unless an IP-layer rule denies.
329                        DnsPortType::Other => match network_policy.evaluate_egress_with_source(
330                            dst,
331                            Protocol::Tcp,
332                            &shared,
333                            HostnameSource::Deferred,
334                        ) {
335                            EgressEvaluation::Allow | EgressEvaluation::DeferUntilHostname => true,
336                            EgressEvaluation::Deny => false,
337                        },
338                    };
339                    if allow && !conn_tracker.has_socket_for(&src, &dst) {
340                        conn_tracker.create_tcp_socket(src, dst, &mut sockets);
341                    }
342                    // Let smoltcp process — matching socket completes
343                    // handshake, no socket means auto-RST.
344                    iface.poll_ingress_single(now, &mut device, &mut sockets);
345                }
346
347                FrameAction::UdpRelay { src, dst } => {
348                    if port_publisher.relay_udp_outbound(frame, src, dst) {
349                        device.drop_staged_frame();
350                        continue;
351                    }
352
353                    // QUIC blocking: drop UDP to intercepted ports when
354                    // TLS interception is active.
355                    if let Some(ref tls) = tls_state
356                        && tls.config.intercepted_ports.contains(&dst.port())
357                        && tls.config.block_quic_on_intercept
358                    {
359                        device.drop_staged_frame();
360                        continue;
361                    }
362
363                    match DnsPortType::from_udp(dst.port()) {
364                        // Dns: unreachable here — classify_transport
365                        // routes UDP/53 to FrameAction::Dns, not
366                        // UdpRelay. Defensive drop covers regressions.
367                        DnsPortType::Dns => {
368                            device.drop_staged_frame();
369                            continue;
370                        }
371                        // EncryptedDns: unreachable here —
372                        // `DnsPortType::from_udp` never returns it
373                        // today (DoT is TCP-only; UDP/853 is DoQ and
374                        // returns AlternativeDns). Defensive drop.
375                        DnsPortType::EncryptedDns => {
376                            device.drop_staged_frame();
377                            continue;
378                        }
379                        // Alternative DNS protocols on well-known UDP
380                        // ports are dropped — forces fall-back to UDP/53.
381                        DnsPortType::AlternativeDns => {
382                            tracing::debug!(%dst, "alternative-DNS UDP port dropped; stub should fall back to UDP/53");
383                            device.drop_staged_frame();
384                            continue;
385                        }
386                        DnsPortType::Other => {}
387                    }
388
389                    // Policy check.
390                    if network_policy
391                        .evaluate_egress(dst, Protocol::Udp, &shared)
392                        .is_deny()
393                    {
394                        device.drop_staged_frame();
395                        continue;
396                    }
397
398                    // Resolve the host-side destination for the dial.
399                    // `dst` stays unchanged so reply frames are stamped
400                    // with the IP the guest expects.
401                    let host_dst = resolve_host_dst(dst, config.gateway);
402                    udp_relay.relay_outbound(frame, src, dst, host_dst);
403                    device.drop_staged_frame();
404                }
405
406                FrameAction::Dns | FrameAction::Passthrough => {
407                    // ARP, ICMP, DNS (port 53), TCP data — smoltcp handles.
408                    iface.poll_ingress_single(now, &mut device, &mut sockets);
409                }
410            }
411        }
412
413        // ── Phase 2: Ingress egress + maintenance ─────────────────────────
414        // Flush frames generated by Phase 1 ingress (ACKs, SYN-ACKs, etc.)
415        // before relaying data so smoltcp has up-to-date state.
416        loop {
417            let result = iface.poll_egress(now, &mut device, &mut sockets);
418            if matches!(result, smoltcp::iface::PollResult::None) {
419                break;
420            }
421        }
422        iface.poll_maintenance(now);
423
424        // Coalesced wake: if Phase 1/2 emitted any frames, wake the
425        // NetWorker once instead of per-frame.
426        if device.frames_emitted.swap(false, Ordering::Relaxed) {
427            shared.rx_wake.wake();
428        }
429
430        // ── Phase 3: Service connections + relay data ────────────────────
431        // Relay proxy data INTO smoltcp sockets first, then a single egress
432        // pass flushes everything. This eliminates the former "Phase 2b"
433        // double-egress pattern.
434        conn_tracker.relay_data(&mut sockets);
435        dns_interceptor.process(&mut sockets);
436
437        // Accept queued inbound connections from published port listeners.
438        port_publisher.accept_inbound(&mut iface, &mut sockets, &shared, &tokio_handle);
439        port_publisher.relay_data(&mut sockets);
440
441        // Detect newly-established connections and spawn proxy tasks.
442        let new_conns = conn_tracker.take_new_connections(&mut sockets);
443        for conn in new_conns {
444            if let Some(ref tls_state) = tls_state
445                && tls_state
446                    .config
447                    .intercepted_ports
448                    .contains(&conn.dst.port())
449            {
450                // TLS-intercepted port — spawn TLS MITM proxy.
451                let connect_dst = resolve_host_dst(conn.dst, config.gateway);
452                tls_proxy::spawn_tls_proxy(
453                    &tokio_handle,
454                    conn.dst,
455                    connect_dst,
456                    conn.from_smoltcp,
457                    conn.to_smoltcp,
458                    shared.clone(),
459                    tls_state.clone(),
460                    network_policy.clone(),
461                    conn.upstream_connected,
462                );
463                continue;
464            }
465            if conn.dst.port() == 53 {
466                // DNS proxies have no guest-visible
467                // "upstream-unreachable" failure mode — even an
468                // upstream DNS failure yields SERVFAIL responses
469                // rather than a silently-closed connection. Mark the
470                // connection as upstream-connected so normal task exit
471                // produces FIN, not RST.
472                conn.upstream_connected.store(true, Ordering::Release);
473
474                // DNS over TCP: route through the same forwarder the UDP
475                // path uses. The forwarder applies the domain block list
476                // and rebind protection to every query and routes
477                // upstream based on `conn.dst.ip()` — the configured
478                // upstream for queries to the gateway, direct forward
479                // to the chosen `@target` (subject to egress policy)
480                // otherwise. No gateway→loopback rewrite here: the
481                // forwarder dials the configured upstream, not the
482                // gateway.
483                DnsTcpProxy::spawn(
484                    &tokio_handle,
485                    conn.dst,
486                    conn.from_smoltcp,
487                    conn.to_smoltcp,
488                    dns_forwarder_handle.clone(),
489                    shared.clone(),
490                );
491                continue;
492            }
493            if conn.dst.port() == 853
494                && let Some(ref tls_state) = tls_state
495            {
496                // Same "always upstream-connected" reasoning as plain DNS over TCP.
497                conn.upstream_connected.store(true, Ordering::Release);
498
499                // DNS over TLS: terminate TLS at the gateway with a
500                // per-domain cert, hand the inner DNS frames to the
501                // same forwarder plain DNS uses. Policy for the
502                // chosen `@target` resolver is applied per-query by
503                // the forwarder (block list + rebind + egress).
504                DotProxy::spawn(
505                    &tokio_handle,
506                    conn.dst,
507                    conn.from_smoltcp,
508                    conn.to_smoltcp,
509                    dns_forwarder_handle.clone(),
510                    tls_state.clone(),
511                    shared.clone(),
512                );
513                continue;
514            }
515            // Plain TCP proxy.
516            let connect_dst = resolve_host_dst(conn.dst, config.gateway);
517            proxy::spawn_tcp_proxy(
518                &tokio_handle,
519                conn.dst,
520                connect_dst,
521                conn.from_smoltcp,
522                conn.to_smoltcp,
523                shared.clone(),
524                network_policy.clone(),
525                conn.upstream_connected,
526            );
527        }
528
529        // Rate-limited cleanup: TIME_WAIT is 60s, session timeout is 60s,
530        // so checking once per second is more than sufficient.
531        if last_cleanup.elapsed() >= std::time::Duration::from_secs(1) {
532            conn_tracker.cleanup_closed(&mut sockets);
533            port_publisher.cleanup_closed(&mut sockets);
534            udp_relay.cleanup_expired();
535            shared.cleanup_resolved_hostnames();
536            last_cleanup = std::time::Instant::now();
537        }
538
539        // ── Phase 4: Flush relay data + sleep ────────────────────────────
540        // Single egress pass flushes all data written by Phase 3.
541        loop {
542            let result = iface.poll_egress(now, &mut device, &mut sockets);
543            if matches!(result, smoltcp::iface::PollResult::None) {
544                break;
545            }
546        }
547
548        // Coalesced wake: if Phase 3/4 emitted any frames, wake once.
549        if device.frames_emitted.swap(false, Ordering::Relaxed) {
550            shared.rx_wake.wake();
551        }
552
553        let timeout_ms = iface
554            .poll_delay(now, &sockets)
555            .map(|d| d.total_millis().min(i32::MAX as u64) as i32)
556            .unwrap_or(100); // 100ms fallback when no timers pending.
557
558        // SAFETY: poll_fds is a valid array of pollfd structs with valid fds.
559        unsafe {
560            libc::poll(
561                poll_fds.as_mut_ptr(),
562                poll_fds.len() as libc::nfds_t,
563                timeout_ms,
564            );
565        }
566
567        // Conditional drain: only drain pipes that actually have data.
568        if poll_fds[0].revents & libc::POLLIN != 0 {
569            shared.tx_wake.drain();
570        }
571        if poll_fds[1].revents & libc::POLLIN != 0 {
572            shared.proxy_wake.drain();
573        }
574    }
575}
576
577//--------------------------------------------------------------------------------------------------
578// Functions: Helpers
579//--------------------------------------------------------------------------------------------------
580
581/// Map a guest-wire destination to its host-socket equivalent.
582///
583/// Gateway IPs rewrite to loopback (`127.0.0.1` / `::1`); everything else
584/// passes through. Shared by the TCP proxy dispatch and the UDP relay.
585///
586/// # Arguments
587///
588/// * `dst` - Destination from the guest's packet.
589/// * `gateway` - Per-sandbox gateway IPs that trigger the loopback rewrite.
590pub(crate) fn resolve_host_dst(dst: SocketAddr, gateway: GatewayIps) -> SocketAddr {
591    match dst.ip() {
592        IpAddr::V4(v4) if gateway.ipv4 == Some(v4) => {
593            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), dst.port())
594        }
595        IpAddr::V6(v6) if gateway.ipv6 == Some(v6) => {
596            SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), dst.port())
597        }
598        _ => dst,
599    }
600}
601
602/// Get the current time as a smoltcp [`Instant`] using a monotonic clock.
603///
604/// Uses `std::time::Instant` (monotonic) instead of `SystemTime` (wall
605/// clock) to avoid issues with NTP clock step corrections that could
606/// cause smoltcp timers to misbehave.
607fn smoltcp_now() -> Instant {
608    static EPOCH: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
609    let epoch = EPOCH.get_or_init(std::time::Instant::now);
610    let elapsed = epoch.elapsed();
611    Instant::from_millis(elapsed.as_millis() as i64)
612}
613
614/// Reply locally to ICMP echo requests aimed at the sandbox gateway.
615///
616/// `any_ip` is required so smoltcp accepts guest traffic for arbitrary remote
617/// destinations, but that would make smoltcp's automatic ICMP echo replies
618/// spoof remote hosts. Handle only the real gateway IPs here and leave all
619/// other ICMP traffic untouched.
620fn handle_gateway_icmp_echo(frame: &[u8], config: &PollLoopConfig, shared: &SharedState) -> bool {
621    let Ok(eth) = EthernetFrame::new_checked(frame) else {
622        return false;
623    };
624
625    let reply = match eth.ethertype() {
626        EthernetProtocol::Ipv4 => gateway_icmpv4_echo_reply(&eth, config),
627        EthernetProtocol::Ipv6 => gateway_icmpv6_echo_reply(&eth, config),
628        _ => None,
629    };
630    let Some(reply) = reply else {
631        return false;
632    };
633
634    shared.push_rx_frame_and_wake(reply);
635
636    true
637}
638
639/// Build an IPv4 ICMP echo reply when the guest pings the gateway IPv4.
640fn gateway_icmpv4_echo_reply(
641    eth: &EthernetFrame<&[u8]>,
642    config: &PollLoopConfig,
643) -> Option<Vec<u8>> {
644    let gateway_ipv4 = config.gateway.ipv4?;
645    let ipv4 = Ipv4Packet::new_checked(eth.payload()).ok()?;
646    if ipv4.dst_addr() != gateway_ipv4 || ipv4.next_header() != IpProtocol::Icmp {
647        return None;
648    }
649
650    let icmp = Icmpv4Packet::new_checked(ipv4.payload()).ok()?;
651    let Icmpv4Repr::EchoRequest {
652        ident,
653        seq_no,
654        data,
655    } = Icmpv4Repr::parse(&icmp, &smoltcp::phy::ChecksumCapabilities::default()).ok()?
656    else {
657        return None;
658    };
659
660    let ipv4_repr = Ipv4Repr {
661        src_addr: gateway_ipv4,
662        dst_addr: ipv4.src_addr(),
663        next_header: IpProtocol::Icmp,
664        payload_len: 8 + data.len(),
665        hop_limit: 64,
666    };
667    let icmp_repr = Icmpv4Repr::EchoReply {
668        ident,
669        seq_no,
670        data,
671    };
672    let mut reply = vec![0u8; 14 + ipv4_repr.buffer_len() + icmp_repr.buffer_len()];
673
674    let mut reply_eth = EthernetFrame::new_unchecked(&mut reply);
675    reply_eth.set_src_addr(EthernetAddress(config.gateway_mac));
676    reply_eth.set_dst_addr(eth.src_addr());
677    reply_eth.set_ethertype(EthernetProtocol::Ipv4);
678
679    ipv4_repr.emit(
680        &mut Ipv4Packet::new_unchecked(&mut reply[14..34]),
681        &smoltcp::phy::ChecksumCapabilities::default(),
682    );
683    icmp_repr.emit(
684        &mut Icmpv4Packet::new_unchecked(&mut reply[34..]),
685        &smoltcp::phy::ChecksumCapabilities::default(),
686    );
687
688    Some(reply)
689}
690
691/// Build an IPv6 ICMP echo reply when the guest pings the gateway IPv6.
692fn gateway_icmpv6_echo_reply(
693    eth: &EthernetFrame<&[u8]>,
694    config: &PollLoopConfig,
695) -> Option<Vec<u8>> {
696    let gateway_ipv6 = config.gateway.ipv6?;
697    let ipv6 = Ipv6Packet::new_checked(eth.payload()).ok()?;
698    if ipv6.dst_addr() != gateway_ipv6 || ipv6.next_header() != IpProtocol::Icmpv6 {
699        return None;
700    }
701
702    let icmp = Icmpv6Packet::new_checked(ipv6.payload()).ok()?;
703    let Icmpv6Repr::EchoRequest {
704        ident,
705        seq_no,
706        data,
707    } = Icmpv6Repr::parse(
708        &ipv6.src_addr(),
709        &ipv6.dst_addr(),
710        &icmp,
711        &smoltcp::phy::ChecksumCapabilities::default(),
712    )
713    .ok()?
714    else {
715        return None;
716    };
717
718    let ipv6_repr = Ipv6Repr {
719        src_addr: gateway_ipv6,
720        dst_addr: ipv6.src_addr(),
721        next_header: IpProtocol::Icmpv6,
722        payload_len: icmp_repr_buffer_len_v6(data),
723        hop_limit: 64,
724    };
725    let icmp_repr = Icmpv6Repr::EchoReply {
726        ident,
727        seq_no,
728        data,
729    };
730    let ipv6_hdr_len = 40;
731    let mut reply = vec![0u8; 14 + ipv6_hdr_len + icmp_repr.buffer_len()];
732
733    let mut reply_eth = EthernetFrame::new_unchecked(&mut reply);
734    reply_eth.set_src_addr(EthernetAddress(config.gateway_mac));
735    reply_eth.set_dst_addr(eth.src_addr());
736    reply_eth.set_ethertype(EthernetProtocol::Ipv6);
737
738    ipv6_repr.emit(&mut Ipv6Packet::new_unchecked(&mut reply[14..54]));
739    icmp_repr.emit(
740        &gateway_ipv6,
741        &ipv6.src_addr(),
742        &mut Icmpv6Packet::new_unchecked(&mut reply[54..]),
743        &smoltcp::phy::ChecksumCapabilities::default(),
744    );
745
746    Some(reply)
747}
748
749fn icmp_repr_buffer_len_v6(data: &[u8]) -> usize {
750    Icmpv6Repr::EchoReply {
751        ident: 0,
752        seq_no: 0,
753        data,
754    }
755    .buffer_len()
756}
757
758/// Classify an IPv4 packet payload (after stripping the Ethernet header).
759fn classify_ipv4(payload: &[u8]) -> FrameAction {
760    let Ok(ipv4) = Ipv4Packet::new_checked(payload) else {
761        return FrameAction::Passthrough;
762    };
763    classify_transport(
764        ipv4.next_header(),
765        ipv4.src_addr().into(),
766        ipv4.dst_addr().into(),
767        ipv4.payload(),
768    )
769}
770
771/// Classify an IPv6 packet payload (after stripping the Ethernet header).
772fn classify_ipv6(payload: &[u8]) -> FrameAction {
773    let Ok(ipv6) = Ipv6Packet::new_checked(payload) else {
774        return FrameAction::Passthrough;
775    };
776    classify_transport(
777        ipv6.next_header(),
778        ipv6.src_addr().into(),
779        ipv6.dst_addr().into(),
780        ipv6.payload(),
781    )
782}
783
784/// Classify the transport-layer protocol (shared by IPv4 and IPv6).
785fn classify_transport(
786    protocol: IpProtocol,
787    src_ip: std::net::IpAddr,
788    dst_ip: std::net::IpAddr,
789    transport_payload: &[u8],
790) -> FrameAction {
791    match protocol {
792        IpProtocol::Tcp => {
793            let Ok(tcp) = TcpPacket::new_checked(transport_payload) else {
794                return FrameAction::Passthrough;
795            };
796            if tcp.syn() && !tcp.ack() {
797                FrameAction::TcpSyn {
798                    src: SocketAddr::new(src_ip, tcp.src_port()),
799                    dst: SocketAddr::new(dst_ip, tcp.dst_port()),
800                }
801            } else {
802                FrameAction::Passthrough
803            }
804        }
805        IpProtocol::Udp => {
806            let Ok(udp) = UdpPacket::new_checked(transport_payload) else {
807                return FrameAction::Passthrough;
808            };
809            // The plain-DNS port (UDP/53) lives in dns::common::ports so
810            // the alternative-DNS refusal logic and this dispatcher
811            // share one source of truth for "which UDP ports are DNS".
812            if DnsPortType::from_udp(udp.dst_port()) == DnsPortType::Dns {
813                FrameAction::Dns
814            } else {
815                FrameAction::UdpRelay {
816                    src: SocketAddr::new(src_ip, udp.src_port()),
817                    dst: SocketAddr::new(dst_ip, udp.dst_port()),
818                }
819            }
820        }
821        _ => FrameAction::Passthrough, // ICMP, etc.
822    }
823}
824
825//--------------------------------------------------------------------------------------------------
826// Tests
827//--------------------------------------------------------------------------------------------------
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832    use std::sync::Arc;
833
834    use smoltcp::phy::ChecksumCapabilities;
835    use smoltcp::wire::{
836        ArpOperation, ArpPacket, ArpRepr, EthernetRepr, Icmpv4Packet, Icmpv4Repr, Ipv4Repr,
837    };
838
839    use crate::device::SmoltcpDevice;
840    use crate::shared::SharedState;
841
842    /// Build a minimal Ethernet + IPv4 + TCP SYN frame.
843    fn build_tcp_syn_frame(
844        src_ip: [u8; 4],
845        dst_ip: [u8; 4],
846        src_port: u16,
847        dst_port: u16,
848    ) -> Vec<u8> {
849        let mut frame = vec![0u8; 14 + 20 + 20]; // eth + ipv4 + tcp
850
851        // Ethernet header.
852        frame[12] = 0x08; // EtherType: IPv4
853        frame[13] = 0x00;
854
855        // IPv4 header.
856        let ip = &mut frame[14..34];
857        ip[0] = 0x45; // Version + IHL
858        let total_len = 40u16; // 20 (IP) + 20 (TCP)
859        ip[2..4].copy_from_slice(&total_len.to_be_bytes());
860        ip[6] = 0x40; // Don't Fragment
861        ip[8] = 64; // TTL
862        ip[9] = 6; // Protocol: TCP
863        ip[12..16].copy_from_slice(&src_ip);
864        ip[16..20].copy_from_slice(&dst_ip);
865
866        // TCP header.
867        let tcp = &mut frame[34..54];
868        tcp[0..2].copy_from_slice(&src_port.to_be_bytes());
869        tcp[2..4].copy_from_slice(&dst_port.to_be_bytes());
870        tcp[12] = 0x50; // Data offset: 5 words
871        tcp[13] = 0x02; // SYN flag
872
873        frame
874    }
875
876    /// Build a minimal Ethernet + IPv4 + UDP frame.
877    fn build_udp_frame(src_ip: [u8; 4], dst_ip: [u8; 4], src_port: u16, dst_port: u16) -> Vec<u8> {
878        let mut frame = vec![0u8; 14 + 20 + 8]; // eth + ipv4 + udp
879
880        // Ethernet header.
881        frame[12] = 0x08;
882        frame[13] = 0x00;
883
884        // IPv4 header.
885        let ip = &mut frame[14..34];
886        ip[0] = 0x45;
887        let total_len = 28u16; // 20 (IP) + 8 (UDP)
888        ip[2..4].copy_from_slice(&total_len.to_be_bytes());
889        ip[8] = 64;
890        ip[9] = 17; // Protocol: UDP
891        ip[12..16].copy_from_slice(&src_ip);
892        ip[16..20].copy_from_slice(&dst_ip);
893
894        // UDP header.
895        let udp = &mut frame[34..42];
896        udp[0..2].copy_from_slice(&src_port.to_be_bytes());
897        udp[2..4].copy_from_slice(&dst_port.to_be_bytes());
898        let udp_len = 8u16;
899        udp[4..6].copy_from_slice(&udp_len.to_be_bytes());
900
901        frame
902    }
903
904    /// Build a minimal Ethernet + IPv4 + ICMP echo request frame.
905    fn build_icmpv4_echo_frame(
906        src_mac: [u8; 6],
907        dst_mac: [u8; 6],
908        src_ip: [u8; 4],
909        dst_ip: [u8; 4],
910        ident: u16,
911        seq_no: u16,
912        data: &[u8],
913    ) -> Vec<u8> {
914        let ipv4_repr = Ipv4Repr {
915            src_addr: Ipv4Addr::from(src_ip),
916            dst_addr: Ipv4Addr::from(dst_ip),
917            next_header: IpProtocol::Icmp,
918            payload_len: 8 + data.len(),
919            hop_limit: 64,
920        };
921        let icmp_repr = Icmpv4Repr::EchoRequest {
922            ident,
923            seq_no,
924            data,
925        };
926        let frame_len = 14 + ipv4_repr.buffer_len() + icmp_repr.buffer_len();
927        let mut frame = vec![0u8; frame_len];
928
929        let mut eth_frame = EthernetFrame::new_unchecked(&mut frame);
930        EthernetRepr {
931            src_addr: EthernetAddress(src_mac),
932            dst_addr: EthernetAddress(dst_mac),
933            ethertype: EthernetProtocol::Ipv4,
934        }
935        .emit(&mut eth_frame);
936
937        ipv4_repr.emit(
938            &mut Ipv4Packet::new_unchecked(&mut frame[14..34]),
939            &ChecksumCapabilities::default(),
940        );
941        icmp_repr.emit(
942            &mut Icmpv4Packet::new_unchecked(&mut frame[34..]),
943            &ChecksumCapabilities::default(),
944        );
945
946        frame
947    }
948
949    /// Build a minimal Ethernet + ARP request frame.
950    fn build_arp_request_frame(src_mac: [u8; 6], src_ip: [u8; 4], target_ip: [u8; 4]) -> Vec<u8> {
951        let mut frame = vec![0u8; 14 + 28];
952
953        let mut eth_frame = EthernetFrame::new_unchecked(&mut frame);
954        EthernetRepr {
955            src_addr: EthernetAddress(src_mac),
956            dst_addr: EthernetAddress([0xff; 6]),
957            ethertype: EthernetProtocol::Arp,
958        }
959        .emit(&mut eth_frame);
960
961        ArpRepr::EthernetIpv4 {
962            operation: ArpOperation::Request,
963            source_hardware_addr: EthernetAddress(src_mac),
964            source_protocol_addr: Ipv4Addr::from(src_ip),
965            target_hardware_addr: EthernetAddress([0x00; 6]),
966            target_protocol_addr: Ipv4Addr::from(target_ip),
967        }
968        .emit(&mut ArpPacket::new_unchecked(&mut frame[14..]));
969
970        frame
971    }
972
973    #[test]
974    fn classify_tcp_syn() {
975        let frame = build_tcp_syn_frame([10, 0, 0, 2], [93, 184, 216, 34], 54321, 443);
976        match classify_frame(&frame) {
977            FrameAction::TcpSyn { src, dst } => {
978                assert_eq!(
979                    src,
980                    SocketAddr::new(Ipv4Addr::new(10, 0, 0, 2).into(), 54321)
981                );
982                assert_eq!(
983                    dst,
984                    SocketAddr::new(Ipv4Addr::new(93, 184, 216, 34).into(), 443)
985                );
986            }
987            _ => panic!("expected TcpSyn"),
988        }
989    }
990
991    #[test]
992    fn classify_tcp_ack_is_passthrough() {
993        let mut frame = build_tcp_syn_frame([10, 0, 0, 2], [93, 184, 216, 34], 54321, 443);
994        // Change flags to ACK only (not SYN).
995        frame[34 + 13] = 0x10; // ACK flag
996        assert!(matches!(classify_frame(&frame), FrameAction::Passthrough));
997    }
998
999    #[test]
1000    fn classify_udp_dns() {
1001        let frame = build_udp_frame([10, 0, 0, 2], [10, 0, 0, 1], 12345, 53);
1002        assert!(matches!(classify_frame(&frame), FrameAction::Dns));
1003    }
1004
1005    #[test]
1006    fn classify_udp_non_dns() {
1007        let frame = build_udp_frame([10, 0, 0, 2], [8, 8, 8, 8], 12345, 443);
1008        match classify_frame(&frame) {
1009            FrameAction::UdpRelay { src, dst } => {
1010                assert_eq!(src.port(), 12345);
1011                assert_eq!(dst.port(), 443);
1012            }
1013            _ => panic!("expected UdpRelay"),
1014        }
1015    }
1016
1017    #[test]
1018    fn classify_arp_is_passthrough() {
1019        let mut frame = vec![0u8; 42]; // ARP frame
1020        frame[12] = 0x08;
1021        frame[13] = 0x06; // EtherType: ARP
1022        assert!(matches!(classify_frame(&frame), FrameAction::Passthrough));
1023    }
1024
1025    #[test]
1026    fn classify_garbage_is_passthrough() {
1027        assert!(matches!(classify_frame(&[]), FrameAction::Passthrough));
1028        assert!(matches!(classify_frame(&[0; 5]), FrameAction::Passthrough));
1029    }
1030
1031    #[test]
1032    fn gateway_replies_to_icmp_echo_requests() {
1033        fn drive_one_frame(
1034            device: &mut SmoltcpDevice,
1035            iface: &mut Interface,
1036            sockets: &mut SocketSet<'_>,
1037            shared: &Arc<SharedState>,
1038            poll_config: &PollLoopConfig,
1039            now: Instant,
1040        ) {
1041            let frame = device.stage_next_frame().expect("expected staged frame");
1042            if handle_gateway_icmp_echo(frame, poll_config, shared) {
1043                device.drop_staged_frame();
1044                return;
1045            }
1046            let _ = iface.poll_ingress_single(now, device, sockets);
1047            let _ = iface.poll_egress(now, device, sockets);
1048        }
1049
1050        let shared = Arc::new(SharedState::new(4));
1051        let poll_config = PollLoopConfig {
1052            gateway_mac: [0x02, 0x00, 0x00, 0x00, 0x00, 0x01],
1053            guest_mac: [0x02, 0x00, 0x00, 0x00, 0x00, 0x02],
1054            gateway: GatewayIps {
1055                ipv4: Some(Ipv4Addr::new(100, 96, 0, 1)),
1056                ipv6: Some(Ipv6Addr::LOCALHOST),
1057            },
1058            guest_ipv4: Some(Ipv4Addr::new(100, 96, 0, 2)),
1059            guest_ipv6: None,
1060            mtu: 1500,
1061        };
1062        let guest_ipv4 = poll_config.guest_ipv4.unwrap();
1063        let gateway_ipv4 = poll_config.gateway.ipv4.unwrap();
1064        let mut device = SmoltcpDevice::new(shared.clone(), poll_config.mtu);
1065        let mut iface = create_interface(&mut device, &poll_config);
1066        let mut sockets = SocketSet::new(vec![]);
1067        let now = smoltcp_now();
1068
1069        // Mirror the real guest flow: resolve the gateway MAC before sending
1070        // the ICMP echo request.
1071        shared
1072            .tx_ring
1073            .push(build_arp_request_frame(
1074                poll_config.guest_mac,
1075                guest_ipv4.octets(),
1076                gateway_ipv4.octets(),
1077            ))
1078            .unwrap();
1079        shared
1080            .tx_ring
1081            .push(build_icmpv4_echo_frame(
1082                poll_config.guest_mac,
1083                poll_config.gateway_mac,
1084                guest_ipv4.octets(),
1085                gateway_ipv4.octets(),
1086                0x1234,
1087                0xABCD,
1088                b"ping",
1089            ))
1090            .unwrap();
1091
1092        drive_one_frame(
1093            &mut device,
1094            &mut iface,
1095            &mut sockets,
1096            &shared,
1097            &poll_config,
1098            now,
1099        );
1100        let _ = shared.rx_ring.pop().expect("expected ARP reply");
1101
1102        drive_one_frame(
1103            &mut device,
1104            &mut iface,
1105            &mut sockets,
1106            &shared,
1107            &poll_config,
1108            now,
1109        );
1110
1111        let reply = shared.rx_ring.pop().expect("expected ICMP echo reply");
1112        let eth = EthernetFrame::new_checked(&reply).expect("valid ethernet frame");
1113        assert_eq!(eth.src_addr(), EthernetAddress(poll_config.gateway_mac));
1114        assert_eq!(eth.dst_addr(), EthernetAddress(poll_config.guest_mac));
1115        assert_eq!(eth.ethertype(), EthernetProtocol::Ipv4);
1116
1117        let ipv4 = Ipv4Packet::new_checked(eth.payload()).expect("valid IPv4 packet");
1118        assert_eq!(ipv4.src_addr(), gateway_ipv4);
1119        assert_eq!(ipv4.dst_addr(), guest_ipv4);
1120        assert_eq!(ipv4.next_header(), IpProtocol::Icmp);
1121
1122        let icmp = Icmpv4Packet::new_checked(ipv4.payload()).expect("valid ICMP packet");
1123        let icmp_repr = Icmpv4Repr::parse(&icmp, &ChecksumCapabilities::default())
1124            .expect("valid ICMP echo reply");
1125        assert_eq!(
1126            icmp_repr,
1127            Icmpv4Repr::EchoReply {
1128                ident: 0x1234,
1129                seq_no: 0xABCD,
1130                data: b"ping",
1131            }
1132        );
1133    }
1134
1135    fn test_gateway() -> GatewayIps {
1136        GatewayIps {
1137            ipv4: Some(Ipv4Addr::new(100, 96, 0, 1)),
1138            ipv6: Some("fd42:6d73:62::1".parse().unwrap()),
1139        }
1140    }
1141
1142    #[test]
1143    fn resolve_host_dst_matches_ipv4() {
1144        let gw = test_gateway();
1145        let dst = SocketAddr::new(IpAddr::V4(gw.ipv4.unwrap()), 8080);
1146        assert_eq!(
1147            resolve_host_dst(dst, gw),
1148            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080)
1149        );
1150    }
1151
1152    #[test]
1153    fn resolve_host_dst_matches_ipv6() {
1154        let gw = test_gateway();
1155        let dst = SocketAddr::new(IpAddr::V6(gw.ipv6.unwrap()), 8080);
1156        assert_eq!(
1157            resolve_host_dst(dst, gw),
1158            SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8080)
1159        );
1160    }
1161
1162    #[test]
1163    fn resolve_host_dst_passes_through_when_family_absent() {
1164        let gw = GatewayIps {
1165            ipv4: None,
1166            ipv6: Some("fd42:6d73:62::1".parse().unwrap()),
1167        };
1168        // IPv4 dst with no IPv4 gateway must not be rewritten to loopback.
1169        let dst = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(100, 96, 0, 1)), 8080);
1170        assert_eq!(resolve_host_dst(dst, gw), dst);
1171    }
1172
1173    #[test]
1174    fn resolve_host_dst_passes_through_non_gateway() {
1175        let gw = test_gateway();
1176        let dst = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 443);
1177        assert_eq!(resolve_host_dst(dst, gw), dst);
1178    }
1179
1180    #[test]
1181    fn external_icmp_echo_requests_are_not_answered_locally() {
1182        fn drive_one_frame(
1183            device: &mut SmoltcpDevice,
1184            iface: &mut Interface,
1185            sockets: &mut SocketSet<'_>,
1186            shared: &Arc<SharedState>,
1187            poll_config: &PollLoopConfig,
1188            now: Instant,
1189        ) {
1190            let frame = device.stage_next_frame().expect("expected staged frame");
1191            if handle_gateway_icmp_echo(frame, poll_config, shared) {
1192                device.drop_staged_frame();
1193                return;
1194            }
1195            let _ = iface.poll_ingress_single(now, device, sockets);
1196            let _ = iface.poll_egress(now, device, sockets);
1197        }
1198
1199        let shared = Arc::new(SharedState::new(4));
1200        let poll_config = PollLoopConfig {
1201            gateway_mac: [0x02, 0x00, 0x00, 0x00, 0x00, 0x01],
1202            guest_mac: [0x02, 0x00, 0x00, 0x00, 0x00, 0x02],
1203            gateway: GatewayIps {
1204                ipv4: Some(Ipv4Addr::new(100, 96, 0, 1)),
1205                ipv6: Some(Ipv6Addr::LOCALHOST),
1206            },
1207            guest_ipv4: Some(Ipv4Addr::new(100, 96, 0, 2)),
1208            guest_ipv6: None,
1209            mtu: 1500,
1210        };
1211        let guest_ipv4 = poll_config.guest_ipv4.unwrap();
1212        let gateway_ipv4 = poll_config.gateway.ipv4.unwrap();
1213        let mut device = SmoltcpDevice::new(shared.clone(), poll_config.mtu);
1214        let mut iface = create_interface(&mut device, &poll_config);
1215        let mut sockets = SocketSet::new(vec![]);
1216        let now = smoltcp_now();
1217
1218        shared
1219            .tx_ring
1220            .push(build_arp_request_frame(
1221                poll_config.guest_mac,
1222                guest_ipv4.octets(),
1223                gateway_ipv4.octets(),
1224            ))
1225            .unwrap();
1226        shared
1227            .tx_ring
1228            .push(build_icmpv4_echo_frame(
1229                poll_config.guest_mac,
1230                poll_config.gateway_mac,
1231                guest_ipv4.octets(),
1232                [142, 251, 216, 46],
1233                0x1234,
1234                0xABCD,
1235                b"ping",
1236            ))
1237            .unwrap();
1238
1239        drive_one_frame(
1240            &mut device,
1241            &mut iface,
1242            &mut sockets,
1243            &shared,
1244            &poll_config,
1245            now,
1246        );
1247        let _ = shared.rx_ring.pop().expect("expected ARP reply");
1248
1249        drive_one_frame(
1250            &mut device,
1251            &mut iface,
1252            &mut sockets,
1253            &shared,
1254            &poll_config,
1255            now,
1256        );
1257        assert!(
1258            shared.rx_ring.pop().is_none(),
1259            "external ICMP should not be answered locally"
1260        );
1261    }
1262}