Skip to main content

smolvm_network/
stack.rs

1//! Host-side smoltcp runtime for the virtio-net backend.
2//!
3//! Context
4//! =======
5//!
6//! This file is the in-process "gateway" that sits behind the guest's virtio
7//! NIC. It does not configure the guest interface; that already happened inside
8//! `smolvm-agent`. Instead, this module:
9//! - receives raw Ethernet frames coming out of libkrun
10//! - feeds them into smoltcp as if smolvm were the guest's next-hop gateway
11//! - forwards guest DNS queries to a host UDP socket
12//! - relays guest TCP streams to host `TcpStream`s
13//!
14//! Conceptually, it plays the role of a tiny virtual router/NAT-side gateway:
15//!
16//! ```text
17//! guest eth0
18//!   -> Ethernet frame
19//!   -> Frame queues
20//!   -> smoltcp Interface (gateway MAC/IP)
21//!   -> protocol-specific handling:
22//!        - TCP  -> host relay threads
23//!        - DNS  -> host UDP socket
24//!        - UDP  -> per-flow host socket relay (udp_relay)
25//!   -> outbound network
26//! ```
27//!
28//! Poll-loop-centric view:
29//!
30//! ```text
31//! guest_to_host queue
32//!   -> VirtioNetworkDevice::stage_next_frame()
33//!   -> classify_guest_frame()
34//!   -> smoltcp ingress
35//!   -> protocol-specific side effects
36//!        - TCP SYN  -> create relay/socket state
37//!        - DNS UDP  -> gateway UDP socket
38//!        - other UDP-> destination-keyed relay socket
39//!   -> smoltcp egress
40//!   -> host_to_guest queue
41//!   -> FrameStream writer
42//! ```
43//!
44//! Runtime control flow:
45//!
46//! ```text
47//! new guest frame         -> guest_wake  -> poll loop
48//! host relay has data     -> relay_wake  -> poll loop
49//! published host connect  -> relay_wake  -> poll loop
50//! smoltcp emitted frames  -> host_wake   -> frame writer
51//! ```
52
53use crate::device::VirtioNetworkDevice;
54use crate::dns;
55use crate::dns_relay::{self, DnsQuery, DnsResponse, DnsTransport};
56use crate::egress::EgressPolicy;
57use crate::icmp_relay;
58use crate::queues::NetworkFrameQueues;
59use crate::tcp_listeners::AcceptedTcpConnection;
60use crate::tcp_relay::{spawn_tcp_relay, TcpRelayTable};
61use crate::udp_relay;
62use crate::virtio_net_log;
63use smoltcp::iface::{
64    Config, Interface, PollIngressSingleResult, PollResult, SocketHandle, SocketSet,
65};
66use smoltcp::socket::raw::{
67    PacketBuffer as RawPacketBuffer, PacketMetadata as RawPacketMetadata, Socket as RawSocket,
68};
69use smoltcp::socket::tcp;
70use smoltcp::socket::udp::{PacketBuffer, PacketMetadata, Socket as UdpSocket, UdpMetadata};
71use smoltcp::time::Instant;
72use smoltcp::wire::{
73    EthernetAddress, EthernetFrame, EthernetProtocol, HardwareAddress, IpAddress, IpCidr,
74    IpListenEndpoint, IpProtocol, IpVersion, Ipv4Packet, Ipv6Packet, TcpPacket, UdpPacket,
75};
76use std::collections::HashMap;
77use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
78use std::sync::atomic::Ordering;
79use std::sync::mpsc::{Receiver, SyncSender, TryRecvError, TrySendError};
80use std::sync::Arc;
81use std::thread::{self, JoinHandle};
82use std::time::{Duration, Instant as StdInstant};
83
84const DNS_SOCKET_PORT: u16 = 53;
85const DNS_PACKET_SLOTS: usize = 8;
86const DNS_BUFFER_BYTES: usize = 2048;
87// DNS-over-TCP to the gateway. Real resolvers (and resolv.conf clients) fall
88// back to TCP for truncated answers and EDNS, so the gateway filter must serve
89// TCP/53 in addition to UDP/53. A small pool of listening sockets handles
90// concurrent queries; DNS/TCP is rare and short-lived (one query per
91// connection), so a few suffice.
92const DNS_TCP_LISTENERS: usize = 4;
93const DNS_TCP_RX_BYTES: usize = 4096;
94const DNS_TCP_TX_BYTES: usize = 8192;
95// A length-prefixed DNS message is bounded by a 16-bit length, but the gateway
96// only needs to handle ordinary queries/responses; cap to keep buffers small
97// and reject a guest that sends a bogus oversized prefix.
98const DNS_TCP_MAX_MSG: usize = 4096;
99const DEFAULT_IDLE_TIMEOUT_MS: i32 = 100;
100/// Packet slots per ICMP raw socket buffer (per direction).
101const ICMP_PACKET_SLOTS: usize = 16;
102/// Payload bytes per ICMP raw socket buffer (per direction).
103const ICMP_BUFFER_BYTES: usize = 32 * 1024;
104
105/// Resolved network parameters for one guest NIC.
106///
107/// These are the host-side parameters for the virtual link. Note that the
108/// smoltcp interface is configured with the *gateway* MAC/IP, because the host
109/// runtime is acting as the guest-visible gateway endpoint.
110#[derive(Debug, Clone, Copy)]
111pub struct VirtioPollConfig {
112    /// Host-side gateway MAC visible to the guest.
113    pub gateway_mac: [u8; 6],
114    /// Guest MAC address.
115    pub guest_mac: [u8; 6],
116    /// Gateway IPv4 address.
117    pub gateway_ipv4: Ipv4Addr,
118    /// Guest IPv4 address.
119    pub guest_ipv4: Ipv4Addr,
120    /// Gateway IPv6 (ULA) address.
121    pub gateway_ipv6: Ipv6Addr,
122    /// Guest IPv6 (ULA) address.
123    pub guest_ipv6: Ipv6Addr,
124    /// IPv6 prefix length for the virtual link.
125    pub prefix_len6: u8,
126    /// Upstream resolver the gateway forwards guest DNS queries to.
127    pub upstream_dns: Ipv4Addr,
128    /// IP-level MTU.
129    pub mtu: usize,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133enum FrameAction {
134    TcpSyn {
135        source: SocketAddr,
136        destination: SocketAddr,
137    },
138    DnsQuery,
139    /// Non-DNS guest UDP, relayed to a host socket (see `udp_relay`).
140    UdpFlow {
141        destination: SocketAddr,
142    },
143    Passthrough,
144}
145
146/// Start the dedicated smoltcp poll thread for the virtio-net backend.
147///
148/// This creates one long-lived poll loop thread per guest NIC. That thread owns
149/// the smoltcp `Interface`, its socket set, and the TCP relay table.
150///
151/// Ownership boundary:
152/// - this thread owns all smoltcp state
153/// - relay threads never touch smoltcp sockets directly
154/// - frame bridge threads never parse protocols beyond raw Ethernet framing
155pub fn start_network_stack(
156    queues: Arc<NetworkFrameQueues>,
157    config: VirtioPollConfig,
158    tcp_receiver: Option<Receiver<AcceptedTcpConnection>>,
159    egress: EgressPolicy,
160) -> std::io::Result<JoinHandle<()>> {
161    virtio_net_log!(
162        "virtio-net: spawning poll thread guest_ip={} gateway_ip={} mtu={}",
163        config.guest_ipv4,
164        config.gateway_ipv4,
165        config.mtu
166    );
167    thread::Builder::new()
168        .name("smolvm-net-poll".into())
169        .spawn(move || run_network_stack(queues, config, tcp_receiver, egress))
170}
171
172fn run_network_stack(
173    queues: Arc<NetworkFrameQueues>,
174    config: VirtioPollConfig,
175    mut tcp_receiver: Option<Receiver<AcceptedTcpConnection>>,
176    egress: EgressPolicy,
177) {
178    // Poll loop overview:
179    //
180    // 1. Drain staged guest Ethernet frames from the guest_to_host queue.
181    // 2. Pre-classify them so we can create relay/socket state before smoltcp
182    //    consumes the frame.
183    // 3. Poll smoltcp ingress/egress.
184    // 4. Forward DNS and relay TCP payloads.
185    // 5. Sleep in poll(2) on wake pipes until guest frames, relay activity, or
186    //    timers require more work.
187    //
188    // A useful mental model is:
189    //
190    //   queue input -> classify -> smoltcp -> protocol handling -> queue output
191    virtio_net_log!(
192        "virtio-net: poll loop started guest_ip={} gateway_ip={}",
193        config.guest_ipv4,
194        config.gateway_ipv4
195    );
196    let clock = StdInstant::now();
197    let mut device = VirtioNetworkDevice::new(queues.clone(), config.mtu);
198    let mut interface = create_interface(&mut device, &config);
199    let mut sockets = SocketSet::new(vec![]);
200    let dns_socket_handle = add_dns_socket(&mut sockets);
201    let dns_tcp_handles = add_dns_tcp_sockets(&mut sockets);
202    let mut dns_tcp_conns: Vec<DnsTcpConn> = (0..dns_tcp_handles.len())
203        .map(|_| DnsTcpConn::default())
204        .collect();
205    let (icmp4_handle, icmp6_handle) = add_icmp_raw_sockets(&mut sockets);
206    // Gateway addresses answer their own pings locally; everything else is
207    // relayed out to real host ICMP sockets.
208    let gateway_addrs = [
209        IpAddr::V4(config.gateway_ipv4),
210        IpAddr::V6(config.gateway_ipv6),
211        IpAddr::V6(link_local_from_mac(config.gateway_mac)),
212    ];
213    let relay_wake = Arc::new(queues.relay_wake.clone());
214    let mut relays = TcpRelayTable::new(None, egress.clone(), gateway_addrs.to_vec());
215    let mut udp_sockets = udp_relay::UdpSocketTable::new();
216    let udp_channels = {
217        let shutdown_queues = queues.clone();
218        udp_relay::start_udp_relay(
219            relay_wake.clone(),
220            Arc::new(move || shutdown_queues.is_shutting_down()),
221        )
222    };
223    let icmp_channels = {
224        let shutdown_queues = queues.clone();
225        icmp_relay::start_icmp_relay(
226            relay_wake.clone(),
227            Arc::new(move || shutdown_queues.is_shutting_down()),
228        )
229    };
230    // DNS resolution is offloaded to its own thread so the blocking upstream
231    // exchange never stalls this poll loop (which owns every one of the VM's
232    // sockets). Egress policy stays here; only the raw forward is offloaded.
233    let dns_channels = {
234        let shutdown_queues = queues.clone();
235        dns_relay::start_dns_relay(
236            relay_wake.clone(),
237            Arc::new(move || shutdown_queues.is_shutting_down()),
238        )
239    };
240    let mut dns_gateway = DnsGateway::new();
241
242    // The smoltcp loop is driven by poller wakeups rather than busy spinning.
243    // guest_wake  -> new guest frame or shutdown
244    // relay_wake  -> host TCP relay thread produced data or shutdown
245    //
246    // Both wakes share a single underlying poller (see `NetworkFrameQueues`), so
247    // the loop blocks on that one poller and either side unblocks it. The loop
248    // re-runs its whole pipeline on every wakeup, so it does not need to know
249    // which wake fired.
250    let poller = queues.guest_wake.poller().clone();
251    let mut events = polling::Events::new();
252
253    loop {
254        if queues.is_shutting_down() {
255            return;
256        }
257        let now = smoltcp_now(clock);
258
259        while let Some(frame) = device.stage_next_frame() {
260            // We inspect the frame before giving it to smoltcp because certain
261            // flows need side effects first:
262            // - TCP SYN: pre-create a matching smoltcp socket + relay entry
263            // - DNS UDP: allow through for gateway-side forwarding
264            // - other UDP: pre-create the destination-keyed relay socket
265            match classify_guest_frame(frame, &gateway_addrs) {
266                FrameAction::TcpSyn {
267                    source,
268                    destination,
269                } => {
270                    virtio_net_log!(
271                        "virtio-net: guest TCP SYN source={} destination={}",
272                        source,
273                        destination
274                    );
275                    if !relays.has_socket_for(&source, &destination) {
276                        relays.create_tcp_socket(source, destination, &mut sockets);
277                    }
278                    if matches!(
279                        interface.poll_ingress_single(now, &mut device, &mut sockets),
280                        PollIngressSingleResult::None
281                    ) {
282                        device.drop_staged_frame();
283                    }
284                }
285                FrameAction::DnsQuery | FrameAction::Passthrough => {
286                    if matches!(
287                        interface.poll_ingress_single(now, &mut device, &mut sockets),
288                        PollIngressSingleResult::None
289                    ) {
290                        device.drop_staged_frame();
291                    }
292                }
293                FrameAction::UdpFlow { destination } => {
294                    // Same egress policy as TCP; a denied destination's datagram
295                    // is silently dropped (a guest sees a normal UDP black hole).
296                    if udp_relay::should_relay_udp(destination, &egress)
297                        && udp_sockets.ensure_socket(destination, &mut sockets)
298                    {
299                        if matches!(
300                            interface.poll_ingress_single(now, &mut device, &mut sockets),
301                            PollIngressSingleResult::None
302                        ) {
303                            device.drop_staged_frame();
304                        }
305                    } else {
306                        device.drop_staged_frame();
307                    }
308                }
309            }
310        }
311
312        relay_accepted_tcp_connection(
313            &mut tcp_receiver,
314            &mut relays,
315            &mut interface,
316            &mut sockets,
317            config.gateway_ipv4,
318            config.guest_ipv4,
319        );
320
321        // First egress pass: let smoltcp emit any packets caused by the most
322        // recent ingress work before we service higher-level relays.
323        flush_interface_egress(&mut interface, &mut device, &mut sockets, now);
324        interface.poll_maintenance(now);
325        wake_guest_if_needed(&queues, &device);
326
327        // Move payloads between established smoltcp TCP sockets and host relay
328        // threads, and service the DNS gateway socket.
329        relays.relay_data(&mut sockets);
330        // Enqueue guest DNS queries to the offload thread (or answer blocked
331        // ones locally), then deliver any answers it has produced back to the
332        // guest. Neither step blocks on the upstream resolver.
333        let mut woke_dns = false;
334        woke_dns |= dispatch_dns_udp(
335            dns_socket_handle,
336            &mut sockets,
337            &egress,
338            config.upstream_dns,
339            &mut dns_gateway,
340            &dns_channels.to_relay,
341        );
342        woke_dns |= process_dns_tcp(
343            &dns_tcp_handles,
344            &mut dns_tcp_conns,
345            &mut sockets,
346            &egress,
347            config.upstream_dns,
348            &mut dns_gateway,
349            &dns_channels.to_relay,
350        );
351        if woke_dns {
352            dns_channels.relay_thread_wake.wake();
353        }
354        deliver_dns_responses(
355            dns_socket_handle,
356            &dns_tcp_handles,
357            &mut dns_tcp_conns,
358            &mut sockets,
359            &egress,
360            &mut dns_gateway,
361            &dns_channels.from_relay,
362        );
363
364        // General UDP: forward staged guest datagrams to the relay thread,
365        // deliver any replies it produced, and expire idle destination sockets.
366        if udp_sockets.drain_to_relay(&mut sockets, &udp_channels.to_relay) {
367            udp_channels.relay_thread_wake.wake();
368        }
369        udp_sockets.deliver_replies(&mut sockets, &udp_channels.from_relay);
370        udp_sockets.expire_idle(&mut sockets);
371
372        // ICMP echo: the raw sockets captured any guest echo requests during
373        // ingress above. Forward external pings to the relay (answering gateway
374        // pings locally), then send back any replies it produced.
375        let mut woke_icmp = false;
376        woke_icmp |= drain_icmp_echo(
377            &mut sockets,
378            icmp4_handle,
379            false,
380            &egress,
381            &gateway_addrs,
382            &icmp_channels.to_relay,
383        );
384        woke_icmp |= drain_icmp_echo(
385            &mut sockets,
386            icmp6_handle,
387            true,
388            &egress,
389            &gateway_addrs,
390            &icmp_channels.to_relay,
391        );
392        if woke_icmp {
393            icmp_channels.relay_thread_wake.wake();
394        }
395        deliver_icmp_replies(
396            &mut sockets,
397            icmp4_handle,
398            icmp6_handle,
399            &icmp_channels.from_relay,
400        );
401
402        // Once the guest-side TCP handshake is established inside smoltcp, we
403        // can spawn the corresponding host relay thread.
404        for connection in relays.take_new_connections(&mut sockets) {
405            spawn_tcp_relay(
406                connection.destination,
407                connection.relay_target,
408                connection.from_smoltcp,
409                connection.to_smoltcp,
410                relay_wake.clone(),
411                connection.exit_state,
412            );
413        }
414
415        relays.cleanup_closed(&mut sockets);
416
417        // Second egress pass: DNS responses or relay data may have queued more
418        // packets for the guest.
419        flush_interface_egress(&mut interface, &mut device, &mut sockets, now);
420        wake_guest_if_needed(&queues, &device);
421
422        let timeout = interface
423            .poll_delay(now, &sockets)
424            .map(|duration| Duration::from_millis(duration.total_millis().min(u32::MAX as u64)));
425        let timeout = match timeout {
426            Some(timeout) => Some(timeout),
427            None => Some(Duration::from_millis(DEFAULT_IDLE_TIMEOUT_MS as u64)),
428        };
429
430        // Block until either wake notifies the shared poller or the timeout
431        // elapses. The wakes are notify-only, so no events are reported; the
432        // loop re-runs unconditionally on the next iteration.
433        events.clear();
434        let _ = poller.wait(&mut events, timeout);
435    }
436}
437
438fn create_interface(device: &mut VirtioNetworkDevice, config: &VirtioPollConfig) -> Interface {
439    // This interface models the host-side gateway endpoint, not the guest NIC.
440    //
441    // Equivalent conceptual state:
442    //   MAC: config.gateway_mac
443    //   IP : config.gateway_ipv4/30
444    //        config.gateway_ipv6/64 (ULA) + fe80 link-local
445    //
446    // The guest IP exists as a peer on the same virtual link; it is not an
447    // address owned by this interface.
448    let mut interface = Interface::new(
449        Config::new(HardwareAddress::Ethernet(EthernetAddress(
450            config.gateway_mac,
451        ))),
452        device,
453        Instant::ZERO,
454    );
455    interface.update_ip_addrs(|addresses| {
456        addresses
457            .push(IpCidr::new(IpAddress::Ipv4(config.gateway_ipv4), 30))
458            .expect("failed to add gateway IPv4 address");
459        addresses
460            .push(IpCidr::new(
461                IpAddress::Ipv6(config.gateway_ipv6),
462                config.prefix_len6,
463            ))
464            .expect("failed to add gateway IPv6 address");
465        // RFC-clean NDP wants a link-local peer on the segment; derive the
466        // standard EUI-64 link-local from the gateway MAC so the guest kernel
467        // can talk NDP to fe80::… as well as to the ULA.
468        addresses
469            .push(IpCidr::new(
470                IpAddress::Ipv6(link_local_from_mac(config.gateway_mac)),
471                64,
472            ))
473            .expect("failed to add gateway IPv6 link-local address");
474    });
475    // The interface acts as the gateway and may need to answer packets for
476    // destinations other than its directly assigned IP, so the route table and
477    // "any IP" mode are opened up accordingly.
478    interface
479        .routes_mut()
480        .add_default_ipv4_route(config.gateway_ipv4)
481        .expect("failed to add default IPv4 route");
482    interface
483        .routes_mut()
484        .add_default_ipv6_route(config.gateway_ipv6)
485        .expect("failed to add default IPv6 route");
486    interface.set_any_ip(true);
487    interface
488}
489
490/// Derive the EUI-64 IPv6 link-local address for a MAC (RFC 4291 appendix A):
491/// flip the universal/local bit, insert `ff:fe` in the middle.
492fn link_local_from_mac(mac: [u8; 6]) -> Ipv6Addr {
493    Ipv6Addr::new(
494        0xfe80,
495        0,
496        0,
497        0,
498        u16::from_be_bytes([mac[0] ^ 0x02, mac[1]]),
499        u16::from_be_bytes([mac[2], 0xff]),
500        u16::from_be_bytes([0xfe, mac[3]]),
501        u16::from_be_bytes([mac[4], mac[5]]),
502    )
503}
504
505/// add_dns_socket is adding an UDP socket inside smoltcp, so that the guest DNS packet will
506/// hit this socket first. It is then proxied to the resolver. Note that this will not cause
507/// a host side :53 collesion, because the smoltcp Interface, SocketSet is per VM, and the
508/// gateway:53 is for that set of Interface and SocketSet, it is not bind to a host-kernel UDP socket.
509///
510/// The bind is wildcard (port-only) on purpose: combined with `set_any_ip`, every
511/// guest UDP datagram to port 53 — whatever its destination address or family
512/// (the v4 gateway, the v6 gateway, or an external resolver IP) — lands on this
513/// socket and is answered from that same destination address. That transparently
514/// intercepts hardcoded external resolvers too, matching TSI's DNS handling.
515fn add_dns_socket(sockets: &mut SocketSet<'_>) -> SocketHandle {
516    let rx_meta = vec![PacketMetadata::EMPTY; DNS_PACKET_SLOTS];
517    let tx_meta = vec![PacketMetadata::EMPTY; DNS_PACKET_SLOTS];
518    let rx_buffer = PacketBuffer::new(rx_meta, vec![0u8; DNS_BUFFER_BYTES]);
519    let tx_buffer = PacketBuffer::new(tx_meta, vec![0u8; DNS_BUFFER_BYTES]);
520    let mut socket = UdpSocket::new(rx_buffer, tx_buffer);
521    socket
522        .bind(smoltcp::wire::IpListenEndpoint {
523            addr: None,
524            port: DNS_SOCKET_PORT,
525        })
526        .expect("failed to bind gateway DNS socket");
527    sockets.add(socket)
528}
529
530/// Add the two raw IP sockets that capture guest ICMP echo traffic.
531///
532/// A `raw::Socket` receives a copy of every matching IP packet *before* the
533/// interface's "is this addressed to me?" check, so these capture the guest's
534/// echo requests even though their destination is some external host. The same
535/// sockets carry the relayed echo *replies* back out, fully addressed (source =
536/// the pinged host), letting smoltcp own the Ethernet framing and ARP/NDP.
537fn add_icmp_raw_sockets(sockets: &mut SocketSet<'_>) -> (SocketHandle, SocketHandle) {
538    fn raw_socket(version: IpVersion, protocol: IpProtocol) -> RawSocket<'static> {
539        let rx = RawPacketBuffer::new(
540            vec![RawPacketMetadata::EMPTY; ICMP_PACKET_SLOTS],
541            vec![0u8; ICMP_BUFFER_BYTES],
542        );
543        let tx = RawPacketBuffer::new(
544            vec![RawPacketMetadata::EMPTY; ICMP_PACKET_SLOTS],
545            vec![0u8; ICMP_BUFFER_BYTES],
546        );
547        RawSocket::new(Some(version), Some(protocol), rx, tx)
548    }
549
550    let v4 = sockets.add(raw_socket(IpVersion::Ipv4, IpProtocol::Icmp));
551    let v6 = sockets.add(raw_socket(IpVersion::Ipv6, IpProtocol::Icmpv6));
552    (v4, v6)
553}
554
555/// Drain guest echo requests captured on one ICMP raw socket. Gateway-destined
556/// pings are answered locally (the gateway *is* the source), external ones are
557/// forwarded to the relay thread subject to egress policy, and denied
558/// destinations are dropped. Returns true if anything was sent to the relay.
559fn drain_icmp_echo(
560    sockets: &mut SocketSet<'_>,
561    handle: SocketHandle,
562    is_ipv6: bool,
563    egress: &EgressPolicy,
564    gateway_addrs: &[IpAddr],
565    to_relay: &SyncSender<icmp_relay::IcmpEcho>,
566) -> bool {
567    // Phase 1: drain received requests into owned values so the socket can be
568    // re-borrowed below to emit local gateway replies.
569    let mut echoes = Vec::new();
570    {
571        let socket = sockets.get_mut::<RawSocket>(handle);
572        while socket.can_recv() {
573            let Ok(packet) = socket.recv() else {
574                break;
575            };
576            let parsed = if is_ipv6 {
577                icmp_relay::parse_guest_echo_v6(packet)
578            } else {
579                icmp_relay::parse_guest_echo_v4(packet)
580            };
581            if let Some(echo) = parsed {
582                echoes.push(echo);
583            }
584        }
585    }
586
587    // Phase 2: route each echo.
588    let mut woke = false;
589    let mut local_replies = Vec::new();
590    for echo in echoes {
591        if gateway_addrs.contains(&echo.destination) {
592            local_replies.push(echo);
593        } else if icmp_relay::should_relay_icmp(echo.destination, egress) {
594            match to_relay.try_send(echo) {
595                Ok(()) => woke = true,
596                Err(TrySendError::Full(_)) => {
597                    virtio_net_log!("virtio-net: dropping guest ICMP echo (relay queue full)");
598                }
599                Err(TrySendError::Disconnected(_)) => return woke,
600            }
601        }
602        // else: egress policy denies the destination — silent black hole.
603    }
604
605    // Phase 3: answer gateway pings straight back out the raw socket.
606    if !local_replies.is_empty() {
607        let socket = sockets.get_mut::<RawSocket>(handle);
608        for reply in local_replies {
609            let frame = if is_ipv6 {
610                icmp_relay::build_echo_reply_v6(&reply)
611            } else {
612                icmp_relay::build_echo_reply_v4(&reply)
613            };
614            if let Some(frame) = frame {
615                let _ = socket.send_slice(&frame);
616            }
617        }
618    }
619    woke
620}
621
622/// Deliver echo replies produced by the relay thread, sending each as a
623/// fully-addressed IP packet (source = the pinged host) out the matching raw
624/// socket so smoltcp frames it back to the guest.
625fn deliver_icmp_replies(
626    sockets: &mut SocketSet<'_>,
627    icmp4_handle: SocketHandle,
628    icmp6_handle: SocketHandle,
629    from_relay: &Receiver<icmp_relay::IcmpEcho>,
630) {
631    while let Ok(reply) = from_relay.try_recv() {
632        let (handle, frame) = match reply.guest {
633            IpAddr::V4(_) => (icmp4_handle, icmp_relay::build_echo_reply_v4(&reply)),
634            IpAddr::V6(_) => (icmp6_handle, icmp_relay::build_echo_reply_v6(&reply)),
635        };
636        let Some(frame) = frame else {
637            continue;
638        };
639        let socket = sockets.get_mut::<RawSocket>(handle);
640        if socket.send_slice(&frame).is_err() {
641            virtio_net_log!(
642                "virtio-net: dropping ICMP reply to {} (raw socket buffer full)",
643                reply.guest
644            );
645        }
646    }
647}
648
649/// Receive the accepted TCP connection from the tcp_channel, and then relay it to
650/// the TcpRelayTable where the TCP network packets will be relayed to the guest.
651fn relay_accepted_tcp_connection(
652    tcp_receiver: &mut Option<Receiver<AcceptedTcpConnection>>,
653    relays: &mut TcpRelayTable,
654    interface: &mut Interface,
655    sockets: &mut SocketSet<'_>,
656    gateway_ipv4: Ipv4Addr,
657    guest_ipv4: Ipv4Addr,
658) {
659    // Published-port model:
660    //
661    // host client -> accepted host TcpStream
662    //             -> create guest-facing smoltcp socket from gateway_ip:ephemeral
663    //             -> guest sees a normal inbound TCP connection to guest_port
664    //             -> once Established, the relay thread bridges payloads
665    //
666    // The guest does not see the original host peer address here. This path is
667    // effectively a small userspace TCP proxy/NAT at the gateway boundary.
668    let mut disconnected = false;
669
670    if let Some(receiver) = tcp_receiver.as_mut() {
671        loop {
672            match receiver.try_recv() {
673                Ok(connection) => {
674                    let guest_destination =
675                        SocketAddr::new(std::net::IpAddr::V4(guest_ipv4), connection.guest_port);
676                    virtio_net_log!(
677                        "virtio-net: accepted published TCP connection peer={} host_port={} guest_destination={}",
678                        connection.peer_addr,
679                        connection.host_port,
680                        guest_destination
681                    );
682                    if !relays.create_published_socket(
683                        interface,
684                        gateway_ipv4,
685                        guest_destination,
686                        connection.stream,
687                        sockets,
688                    ) {
689                        tracing::warn!(
690                            host_port = connection.host_port,
691                            guest_port = connection.guest_port,
692                            peer_addr = %connection.peer_addr,
693                            "dropping published TCP connection because the guest relay path could not be created"
694                        );
695                    }
696                }
697                Err(TryRecvError::Empty) => break,
698                Err(TryRecvError::Disconnected) => {
699                    disconnected = true;
700                    break;
701                }
702            }
703        }
704    }
705
706    if disconnected {
707        *tcp_receiver = None;
708    }
709}
710
711/// Bound on outstanding forwarded DNS queries (UDP context) awaiting an answer
712/// from the offload thread. Every enqueued query eventually yields a response
713/// (answer or timeout) that clears its entry, so this is only a safety ceiling
714/// against a wedged relay; DNS volume is far below it in practice.
715const MAX_PENDING_DNS: usize = 512;
716
717/// Poll-loop-side DNS state: the id generator and the reply context for each
718/// in-flight *UDP* query. TCP context lives on the owning [`DnsTcpConn`]
719/// (`awaiting`), since a DNS/TCP query is pinned to one listener socket.
720struct DnsGateway {
721    next_id: u64,
722    pending_udp: HashMap<u64, PendingDnsUdp>,
723}
724
725/// The guest-reply context we must remember while a UDP query is off-thread.
726struct PendingDnsUdp {
727    endpoint: smoltcp::wire::IpEndpoint,
728    local_address: Option<IpAddress>,
729    /// Whether to learn A/AAAA answer records into the egress allow-list.
730    learn: bool,
731}
732
733impl DnsGateway {
734    fn new() -> Self {
735        Self {
736            next_id: 0,
737            pending_udp: HashMap::new(),
738        }
739    }
740
741    fn next_id(&mut self) -> u64 {
742        let id = self.next_id;
743        self.next_id = self.next_id.wrapping_add(1);
744        id
745    }
746}
747
748/// The decision for a single guest DNS query, made on the poll thread using the
749/// egress allow-host policy (no host I/O). Mirrors the previous inline filter.
750enum DnsDecision {
751    /// Answer the guest immediately with these raw DNS message bytes
752    /// (blocked -> NXDOMAIN, unparseable -> SERVFAIL).
753    Immediate(Vec<u8>),
754    /// Forward the query upstream via the offload thread; `learn` records the
755    /// answer's A/AAAA IPs into the egress allow-list when it returns.
756    Forward { learn: bool },
757}
758
759/// Classify a query under the allow-host policy. When the DNS filter is
760/// inactive everything is forwarded (no learning); otherwise only allow-listed
761/// names are forwarded and learned, others get NXDOMAIN and unparseable ones
762/// SERVFAIL. Identical policy to the old `filtered_dns_response`.
763fn classify_dns_query(query: &[u8], egress: &EgressPolicy) -> DnsDecision {
764    if !egress.dns_filter_active() {
765        return DnsDecision::Forward { learn: false };
766    }
767    match dns::question_name(query) {
768        Some(name) if egress.hostname_allowed(&name) => DnsDecision::Forward { learn: true },
769        Some(name) => {
770            virtio_net_log!(
771                "virtio-net: blocking DNS query by allow-host policy name={}",
772                name
773            );
774            DnsDecision::Immediate(dns::error_response(query, dns::DNS_RCODE_NXDOMAIN))
775        }
776        None => DnsDecision::Immediate(dns::error_response(query, dns::DNS_RCODE_SERVFAIL)),
777    }
778}
779
780/// Drain guest UDP/53 queries out of the gateway socket. Blocked queries are
781/// answered inline (no host I/O); allowed queries are handed to the DNS offload
782/// thread and their reply context remembered. Returns true if anything was
783/// enqueued (the caller then wakes the relay thread). Never blocks upstream.
784fn dispatch_dns_udp(
785    dns_socket_handle: SocketHandle,
786    sockets: &mut SocketSet<'_>,
787    egress: &EgressPolicy,
788    upstream_dns: Ipv4Addr,
789    gateway: &mut DnsGateway,
790    to_relay: &SyncSender<DnsQuery>,
791) -> bool {
792    let mut queued = false;
793    let socket = sockets.get_mut::<UdpSocket>(dns_socket_handle);
794    while socket.can_recv() {
795        // Copy out the query and the Copy reply-context fields, releasing the
796        // recv borrow before we may re-borrow the socket to answer inline.
797        let (query, endpoint, local_address) = match socket.recv() {
798            Ok((q, m)) => (q.to_vec(), m.endpoint, m.local_address),
799            Err(_) => break,
800        };
801        match classify_dns_query(&query, egress) {
802            DnsDecision::Immediate(response) => {
803                let response_meta = UdpMetadata {
804                    endpoint,
805                    local_address,
806                    meta: Default::default(),
807                };
808                let _ = socket.send_slice(&response, response_meta);
809            }
810            DnsDecision::Forward { learn } => {
811                if gateway.pending_udp.len() >= MAX_PENDING_DNS {
812                    virtio_net_log!("virtio-net: dropping guest DNS query (pending table full)");
813                    continue;
814                }
815                let id = gateway.next_id();
816                gateway.pending_udp.insert(
817                    id,
818                    PendingDnsUdp {
819                        endpoint,
820                        local_address,
821                        learn,
822                    },
823                );
824                match to_relay.try_send(DnsQuery {
825                    id,
826                    transport: DnsTransport::Udp,
827                    upstream: upstream_dns,
828                    query,
829                }) {
830                    Ok(()) => queued = true,
831                    Err(TrySendError::Full(_)) => {
832                        gateway.pending_udp.remove(&id);
833                        virtio_net_log!("virtio-net: dropping guest DNS query (relay queue full)");
834                    }
835                    Err(TrySendError::Disconnected(_)) => {
836                        gateway.pending_udp.remove(&id);
837                        return queued;
838                    }
839                }
840            }
841        }
842    }
843    queued
844}
845
846/// Deliver answers produced by the DNS offload thread back into the guest-facing
847/// smoltcp sockets. UDP answers are matched by id to their remembered reply
848/// context; TCP answers are matched to the listener whose connection is awaiting
849/// that id. A `None` answer (upstream error/timeout) is dropped for UDP (the
850/// guest sees a normal DNS timeout) and closes the TCP connection empty-handed.
851fn deliver_dns_responses(
852    dns_socket_handle: SocketHandle,
853    dns_tcp_handles: &[SocketHandle],
854    dns_tcp_conns: &mut [DnsTcpConn],
855    sockets: &mut SocketSet<'_>,
856    egress: &EgressPolicy,
857    gateway: &mut DnsGateway,
858    from_relay: &Receiver<DnsResponse>,
859) {
860    while let Ok(response) = from_relay.try_recv() {
861        if let Some(pending) = gateway.pending_udp.remove(&response.id) {
862            if let Some(answer) = response.answer {
863                if pending.learn {
864                    egress.learn_ip_records(&dns::answer_ip_records(&answer));
865                }
866                let socket = sockets.get_mut::<UdpSocket>(dns_socket_handle);
867                let response_meta = UdpMetadata {
868                    endpoint: pending.endpoint,
869                    local_address: pending.local_address,
870                    meta: Default::default(),
871                };
872                let _ = socket.send_slice(&answer, response_meta);
873            }
874            continue;
875        }
876
877        // Otherwise it is a DNS/TCP answer: find the awaiting listener.
878        for (handle, conn) in dns_tcp_handles.iter().zip(dns_tcp_conns.iter_mut()) {
879            match conn.awaiting {
880                Some(pending) if pending.id == response.id => {
881                    conn.awaiting = None;
882                    if let Some(answer) = response.answer {
883                        if pending.learn {
884                            egress.learn_ip_records(&dns::answer_ip_records(&answer));
885                        }
886                        frame_dns_tcp_response(conn, &answer);
887                    }
888                    conn.done = true;
889                    let socket = sockets.get_mut::<tcp::Socket>(*handle);
890                    drain_dns_tcp_tx(socket, conn);
891                    break;
892                }
893                _ => {}
894            }
895        }
896    }
897}
898
899/// Append a framed (2-byte length prefix + message) DNS response to a
900/// connection's pending TX buffer.
901fn frame_dns_tcp_response(conn: &mut DnsTcpConn, response: &[u8]) {
902    if let Ok(resp_len) = u16::try_from(response.len()) {
903        conn.tx.extend_from_slice(&resp_len.to_be_bytes());
904        conn.tx.extend_from_slice(response);
905    }
906}
907
908/// Create the pool of smoltcp TCP listening sockets bound to the gateway's
909/// port 53. Each accepts one DNS-over-TCP connection at a time and is re-armed
910/// by [`process_dns_tcp`] after the connection closes.
911fn add_dns_tcp_sockets(sockets: &mut SocketSet<'_>) -> Vec<SocketHandle> {
912    (0..DNS_TCP_LISTENERS)
913        .map(|_| {
914            let rx_buffer = tcp::SocketBuffer::new(vec![0u8; DNS_TCP_RX_BYTES]);
915            let tx_buffer = tcp::SocketBuffer::new(vec![0u8; DNS_TCP_TX_BYTES]);
916            let mut socket = tcp::Socket::new(rx_buffer, tx_buffer);
917            socket
918                .listen(IpListenEndpoint {
919                    addr: None,
920                    port: DNS_SOCKET_PORT,
921                })
922                .expect("failed to listen on gateway DNS TCP socket");
923            sockets.add(socket)
924        })
925        .collect()
926}
927
928/// Per-listener state for an in-flight DNS-over-TCP connection: the
929/// accumulating length-prefixed query, and the framed response we still owe the
930/// guest.
931#[derive(Default)]
932struct DnsTcpConn {
933    /// Guest -> gateway bytes received so far (2-byte length prefix + query).
934    rx: Vec<u8>,
935    /// Framed response (2-byte length prefix + message) to send to the guest.
936    tx: Vec<u8>,
937    /// Bytes of `tx` already written to the socket.
938    tx_sent: usize,
939    /// The query has been answered (or rejected); drain `tx` then close.
940    done: bool,
941    /// Set once the query has been handed to the DNS offload thread and we are
942    /// waiting for its answer (carries the correlation id + learn flag). While
943    /// set, the connection is parked — `deliver_dns_responses` completes it.
944    awaiting: Option<DnsTcpPending>,
945}
946
947/// Correlation state for a DNS/TCP query parked on the offload thread.
948#[derive(Clone, Copy)]
949struct DnsTcpPending {
950    id: u64,
951    learn: bool,
952}
953
954/// Service the DNS-over-TCP listening sockets: accept a connection, read the
955/// length-prefixed query, apply the allow-host filter, forward allowed queries
956/// upstream over TCP, write the length-prefixed response, and close. Closed
957/// sockets are re-armed to listen again.
958fn process_dns_tcp(
959    handles: &[SocketHandle],
960    conns: &mut [DnsTcpConn],
961    sockets: &mut SocketSet<'_>,
962    egress: &EgressPolicy,
963    upstream_dns: Ipv4Addr,
964    gateway: &mut DnsGateway,
965    to_relay: &SyncSender<DnsQuery>,
966) -> bool {
967    let mut queued = false;
968    for (handle, conn) in handles.iter().zip(conns.iter_mut()) {
969        let socket = sockets.get_mut::<tcp::Socket>(*handle);
970
971        // A closed/finished socket: reset state and re-arm to accept the next
972        // connection. `listen` only succeeds from the CLOSED state; if the
973        // socket is still draining (e.g. TIME-WAIT) the error is ignored and the
974        // next poll retries.
975        if !socket.is_open() {
976            if !conn.rx.is_empty() || !conn.tx.is_empty() || conn.done || conn.awaiting.is_some() {
977                *conn = DnsTcpConn::default();
978            }
979            let _ = socket.listen(IpListenEndpoint {
980                addr: None,
981                port: DNS_SOCKET_PORT,
982            });
983            continue;
984        }
985
986        // Parked awaiting the offload thread's answer: nothing to do here until
987        // `deliver_dns_responses` fills `tx` / sets `done`.
988        if conn.awaiting.is_some() {
989            continue;
990        }
991
992        // Already answered: flush whatever response remains, then close.
993        if conn.done {
994            drain_dns_tcp_tx(socket, conn);
995            continue;
996        }
997
998        // Accumulate the length-prefixed query.
999        while socket.can_recv() {
1000            let appended = socket.recv(|data| (data.len(), data.to_vec()));
1001            match appended {
1002                Ok(bytes) if !bytes.is_empty() => conn.rx.extend_from_slice(&bytes),
1003                _ => break,
1004            }
1005        }
1006
1007        // Reject a guest that floods without ever completing a message.
1008        if conn.rx.len() > DNS_TCP_MAX_MSG + 2 {
1009            conn.done = true;
1010            socket.close();
1011            continue;
1012        }
1013
1014        if conn.rx.len() >= 2 {
1015            let msg_len = u16::from_be_bytes([conn.rx[0], conn.rx[1]]) as usize;
1016            if msg_len == 0 || msg_len > DNS_TCP_MAX_MSG {
1017                conn.done = true;
1018                socket.close();
1019                continue;
1020            }
1021            if conn.rx.len() >= 2 + msg_len {
1022                let query = conn.rx[2..2 + msg_len].to_vec();
1023                virtio_net_log!(
1024                    "virtio-net: DNS/TCP query query_len={} upstream_dns={}",
1025                    query.len(),
1026                    upstream_dns
1027                );
1028                match classify_dns_query(&query, egress) {
1029                    DnsDecision::Immediate(response) => {
1030                        frame_dns_tcp_response(conn, &response);
1031                        conn.done = true;
1032                        drain_dns_tcp_tx(socket, conn);
1033                    }
1034                    DnsDecision::Forward { learn } => {
1035                        // Hand the query to the offload thread and park the
1036                        // connection; the poll loop stays free.
1037                        let id = gateway.next_id();
1038                        match to_relay.try_send(DnsQuery {
1039                            id,
1040                            transport: DnsTransport::Tcp,
1041                            upstream: upstream_dns,
1042                            query,
1043                        }) {
1044                            Ok(()) => {
1045                                conn.awaiting = Some(DnsTcpPending { id, learn });
1046                                queued = true;
1047                            }
1048                            Err(_) => {
1049                                // Relay unavailable/full: close empty-handed
1050                                // (guest sees EOF, then retries — DNS semantics).
1051                                conn.done = true;
1052                                socket.close();
1053                            }
1054                        }
1055                    }
1056                }
1057            }
1058        }
1059    }
1060    queued
1061}
1062
1063/// Write as much of the pending framed response as the socket will accept; once
1064/// fully sent, close the connection (the guest reads the answer then sees EOF).
1065fn drain_dns_tcp_tx(socket: &mut tcp::Socket<'_>, conn: &mut DnsTcpConn) {
1066    while conn.tx_sent < conn.tx.len() && socket.can_send() {
1067        match socket.send_slice(&conn.tx[conn.tx_sent..]) {
1068            Ok(n) if n > 0 => conn.tx_sent += n,
1069            _ => break,
1070        }
1071    }
1072    if conn.tx_sent >= conn.tx.len() {
1073        socket.close();
1074    }
1075}
1076
1077fn flush_interface_egress(
1078    interface: &mut Interface,
1079    device: &mut VirtioNetworkDevice,
1080    sockets: &mut SocketSet<'_>,
1081    now: Instant,
1082) {
1083    // smoltcp may have multiple pending egress packets after a single ingress
1084    // event or timeout. Keep polling until the interface reports there is no
1085    // more immediate work.
1086    loop {
1087        let result = interface.poll_egress(now, device, sockets);
1088        if matches!(result, PollResult::None) {
1089            break;
1090        }
1091    }
1092}
1093
1094fn wake_guest_if_needed(queues: &NetworkFrameQueues, device: &VirtioNetworkDevice) {
1095    // The device records only that "some frame was emitted". We convert that
1096    // sticky bit into one wake for the writer thread and let the writer drain
1097    // the entire host_to_guest queue.
1098    if device.frames_emitted.swap(false, Ordering::Relaxed) {
1099        queues.host_wake.wake();
1100    }
1101}
1102
1103fn smoltcp_now(clock: StdInstant) -> Instant {
1104    let elapsed = clock.elapsed();
1105    Instant::from_millis(elapsed.as_millis() as i64)
1106}
1107
1108fn classify_guest_frame(frame: &[u8], gateway_addrs: &[IpAddr]) -> FrameAction {
1109    let ethernet = match EthernetFrame::new_checked(frame) {
1110        Ok(frame) => frame,
1111        Err(_) => return FrameAction::Passthrough,
1112    };
1113
1114    // Extract (src, dst, transport protocol, transport payload) from either IP
1115    // family. Anything that isn't plain IPv4/IPv6 — ARP, and IPv6 packets with
1116    // extension headers (which guest TCP/UDP traffic doesn't use) — passes
1117    // through to smoltcp untouched; that also covers ICMPv6/NDP.
1118    let (src_ip, dst_ip, protocol, transport): (IpAddr, IpAddr, _, _) = match ethernet.ethertype() {
1119        EthernetProtocol::Ipv4 => {
1120            let ipv4 = match Ipv4Packet::new_checked(ethernet.payload()) {
1121                Ok(packet) => packet,
1122                Err(_) => return FrameAction::Passthrough,
1123            };
1124            (
1125                IpAddr::V4(ipv4.src_addr()),
1126                IpAddr::V4(ipv4.dst_addr()),
1127                ipv4.next_header(),
1128                ipv4.payload(),
1129            )
1130        }
1131        EthernetProtocol::Ipv6 => {
1132            let ipv6 = match Ipv6Packet::new_checked(ethernet.payload()) {
1133                Ok(packet) => packet,
1134                Err(_) => return FrameAction::Passthrough,
1135            };
1136            (
1137                IpAddr::V6(ipv6.src_addr()),
1138                IpAddr::V6(ipv6.dst_addr()),
1139                ipv6.next_header(),
1140                ipv6.payload(),
1141            )
1142        }
1143        _ => return FrameAction::Passthrough,
1144    };
1145
1146    match protocol {
1147        smoltcp::wire::IpProtocol::Tcp => {
1148            let tcp = match TcpPacket::new_checked(transport) {
1149                Ok(packet) => packet,
1150                Err(_) => return FrameAction::Passthrough,
1151            };
1152
1153            if tcp.syn() && !tcp.ack() {
1154                // DNS-over-TCP to the gateway itself is intercepted by the local
1155                // listening sockets (process_dns_tcp), not relayed. TCP/53 to an
1156                // external resolver (an allow-listed IP) is left to the egress
1157                // relay so the policy still applies.
1158                if tcp.dst_port() == DNS_SOCKET_PORT && gateway_addrs.contains(&dst_ip) {
1159                    FrameAction::Passthrough
1160                } else {
1161                    FrameAction::TcpSyn {
1162                        source: SocketAddr::new(src_ip, tcp.src_port()),
1163                        destination: SocketAddr::new(dst_ip, tcp.dst_port()),
1164                    }
1165                }
1166            } else {
1167                FrameAction::Passthrough
1168            }
1169        }
1170        smoltcp::wire::IpProtocol::Udp => {
1171            let udp = match UdpPacket::new_checked(transport) {
1172                Ok(packet) => packet,
1173                Err(_) => return FrameAction::Passthrough,
1174            };
1175
1176            if udp.dst_port() == DNS_SOCKET_PORT {
1177                FrameAction::DnsQuery
1178            } else {
1179                FrameAction::UdpFlow {
1180                    destination: SocketAddr::new(dst_ip, udp.dst_port()),
1181                }
1182            }
1183        }
1184        _ => FrameAction::Passthrough,
1185    }
1186}
1187
1188/// Fuzz-only entrypoint for `classify_guest_frame`.
1189///
1190/// A malicious guest sends arbitrary ethernet frames over virtio-net, and the
1191/// host parses every one here — so this MUST NOT panic on any input. Gated
1192/// behind the `fuzzing` feature so it never ships in a normal build.
1193#[cfg(feature = "fuzzing")]
1194pub fn fuzz_classify_guest_frame(frame: &[u8]) {
1195    let _ = classify_guest_frame(frame, &[]);
1196}
1197
1198#[cfg(test)]
1199mod tests {
1200    use super::*;
1201
1202    /// Minimal Ethernet(IPv4(TCP SYN)) frame with no payload. `new_checked`
1203    /// validates lengths/header fields (not checksums), so dummy checksums are
1204    /// fine for exercising `classify_guest_frame`.
1205    fn tcp_syn_frame(dst_ip: [u8; 4], dst_port: u16) -> Vec<u8> {
1206        let mut f = Vec::new();
1207        // Ethernet: dst MAC, src MAC, ethertype IPv4.
1208        f.extend_from_slice(&[0xff; 6]);
1209        f.extend_from_slice(&[0x02, 0, 0, 0, 0, 1]);
1210        f.extend_from_slice(&[0x08, 0x00]);
1211        // IPv4: v4/IHL5, DSCP, total_len=40, id, flags/frag, ttl, proto=TCP, csum, src, dst.
1212        f.extend_from_slice(&[0x45, 0x00, 0x00, 0x28, 0, 0, 0, 0, 0x40, 0x06, 0, 0]);
1213        f.extend_from_slice(&[10, 0, 0, 2]); // src ip
1214        f.extend_from_slice(&dst_ip);
1215        // TCP: src/dst port, seq, ack, data-offset(5)/flags(SYN), window, csum, urg.
1216        f.extend_from_slice(&54321u16.to_be_bytes());
1217        f.extend_from_slice(&dst_port.to_be_bytes());
1218        f.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0]); // seq + ack
1219        f.extend_from_slice(&[0x50, 0x02, 0xff, 0xff, 0, 0, 0, 0]); // offset/SYN/window/csum/urg
1220        f
1221    }
1222
1223    #[test]
1224    fn dns_tcp_to_gateway_is_intercepted_not_relayed() {
1225        let gw = IpAddr::V4(Ipv4Addr::new(100, 96, 0, 1));
1226        // TCP/53 to the gateway -> handled by the local DNS listeners (Passthrough).
1227        assert_eq!(
1228            classify_guest_frame(&tcp_syn_frame([100, 96, 0, 1], 53), &[gw]),
1229            FrameAction::Passthrough
1230        );
1231    }
1232
1233    #[test]
1234    fn dns_tcp_to_external_resolver_still_relayed() {
1235        let gw = IpAddr::V4(Ipv4Addr::new(100, 96, 0, 1));
1236        // TCP/53 to an external (allow-listed) resolver must go through the egress
1237        // relay, NOT be swallowed by the gateway DNS listeners.
1238        assert!(matches!(
1239            classify_guest_frame(&tcp_syn_frame([1, 1, 1, 1], 53), &[gw]),
1240            FrameAction::TcpSyn { .. }
1241        ));
1242    }
1243
1244    #[test]
1245    fn non_dns_tcp_to_gateway_still_relayed() {
1246        let gw = IpAddr::V4(Ipv4Addr::new(100, 96, 0, 1));
1247        // Only port 53 is intercepted; other gateway ports relay as usual.
1248        assert!(matches!(
1249            classify_guest_frame(&tcp_syn_frame([100, 96, 0, 1], 443), &[gw]),
1250            FrameAction::TcpSyn { .. }
1251        ));
1252    }
1253}