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::egress::EgressPolicy;
56use crate::icmp_relay;
57use crate::queues::NetworkFrameQueues;
58use crate::tcp_listeners::AcceptedTcpConnection;
59use crate::tcp_relay::{spawn_tcp_relay, TcpRelayTable};
60use crate::udp_relay;
61use crate::{virtio_net_log, DEFAULT_DNS_ADDR};
62use smoltcp::iface::{
63    Config, Interface, PollIngressSingleResult, PollResult, SocketHandle, SocketSet,
64};
65use smoltcp::socket::raw::{
66    PacketBuffer as RawPacketBuffer, PacketMetadata as RawPacketMetadata, Socket as RawSocket,
67};
68use smoltcp::socket::udp::{PacketBuffer, PacketMetadata, Socket as UdpSocket, UdpMetadata};
69use smoltcp::time::Instant;
70use smoltcp::wire::{
71    EthernetAddress, EthernetFrame, EthernetProtocol, HardwareAddress, IpAddress, IpCidr,
72    IpProtocol, IpVersion, Ipv4Packet, Ipv6Packet, TcpPacket, UdpPacket,
73};
74use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket as HostUdpSocket};
75use std::sync::atomic::Ordering;
76use std::sync::mpsc::{Receiver, SyncSender, TryRecvError, TrySendError};
77use std::sync::Arc;
78use std::thread::{self, JoinHandle};
79use std::time::{Duration, Instant as StdInstant};
80
81const DNS_SOCKET_PORT: u16 = 53;
82const DNS_PACKET_SLOTS: usize = 8;
83const DNS_BUFFER_BYTES: usize = 2048;
84const DEFAULT_IDLE_TIMEOUT_MS: i32 = 100;
85/// Packet slots per ICMP raw socket buffer (per direction).
86const ICMP_PACKET_SLOTS: usize = 16;
87/// Payload bytes per ICMP raw socket buffer (per direction).
88const ICMP_BUFFER_BYTES: usize = 32 * 1024;
89
90/// Resolved network parameters for one guest NIC.
91///
92/// These are the host-side parameters for the virtual link. Note that the
93/// smoltcp interface is configured with the *gateway* MAC/IP, because the host
94/// runtime is acting as the guest-visible gateway endpoint.
95#[derive(Debug, Clone, Copy)]
96pub struct VirtioPollConfig {
97    /// Host-side gateway MAC visible to the guest.
98    pub gateway_mac: [u8; 6],
99    /// Guest MAC address.
100    pub guest_mac: [u8; 6],
101    /// Gateway IPv4 address.
102    pub gateway_ipv4: Ipv4Addr,
103    /// Guest IPv4 address.
104    pub guest_ipv4: Ipv4Addr,
105    /// Gateway IPv6 (ULA) address.
106    pub gateway_ipv6: Ipv6Addr,
107    /// Guest IPv6 (ULA) address.
108    pub guest_ipv6: Ipv6Addr,
109    /// IPv6 prefix length for the virtual link.
110    pub prefix_len6: u8,
111    /// IP-level MTU.
112    pub mtu: usize,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116enum FrameAction {
117    TcpSyn {
118        source: SocketAddr,
119        destination: SocketAddr,
120    },
121    DnsQuery,
122    /// Non-DNS guest UDP, relayed to a host socket (see `udp_relay`).
123    UdpFlow {
124        destination: SocketAddr,
125    },
126    Passthrough,
127}
128
129/// Start the dedicated smoltcp poll thread for the virtio-net backend.
130///
131/// This creates one long-lived poll loop thread per guest NIC. That thread owns
132/// the smoltcp `Interface`, its socket set, and the TCP relay table.
133///
134/// Ownership boundary:
135/// - this thread owns all smoltcp state
136/// - relay threads never touch smoltcp sockets directly
137/// - frame bridge threads never parse protocols beyond raw Ethernet framing
138pub fn start_network_stack(
139    queues: Arc<NetworkFrameQueues>,
140    config: VirtioPollConfig,
141    tcp_receiver: Option<Receiver<AcceptedTcpConnection>>,
142    egress: EgressPolicy,
143) -> std::io::Result<JoinHandle<()>> {
144    virtio_net_log!(
145        "virtio-net: spawning poll thread guest_ip={} gateway_ip={} mtu={}",
146        config.guest_ipv4,
147        config.gateway_ipv4,
148        config.mtu
149    );
150    thread::Builder::new()
151        .name("smolvm-net-poll".into())
152        .spawn(move || run_network_stack(queues, config, tcp_receiver, egress))
153}
154
155fn run_network_stack(
156    queues: Arc<NetworkFrameQueues>,
157    config: VirtioPollConfig,
158    mut tcp_receiver: Option<Receiver<AcceptedTcpConnection>>,
159    egress: EgressPolicy,
160) {
161    // Poll loop overview:
162    //
163    // 1. Drain staged guest Ethernet frames from the guest_to_host queue.
164    // 2. Pre-classify them so we can create relay/socket state before smoltcp
165    //    consumes the frame.
166    // 3. Poll smoltcp ingress/egress.
167    // 4. Forward DNS and relay TCP payloads.
168    // 5. Sleep in poll(2) on wake pipes until guest frames, relay activity, or
169    //    timers require more work.
170    //
171    // A useful mental model is:
172    //
173    //   queue input -> classify -> smoltcp -> protocol handling -> queue output
174    virtio_net_log!(
175        "virtio-net: poll loop started guest_ip={} gateway_ip={}",
176        config.guest_ipv4,
177        config.gateway_ipv4
178    );
179    let clock = StdInstant::now();
180    let mut device = VirtioNetworkDevice::new(queues.clone(), config.mtu);
181    let mut interface = create_interface(&mut device, &config);
182    let mut sockets = SocketSet::new(vec![]);
183    let dns_socket_handle = add_dns_socket(&mut sockets);
184    let (icmp4_handle, icmp6_handle) = add_icmp_raw_sockets(&mut sockets);
185    // Gateway addresses answer their own pings locally; everything else is
186    // relayed out to real host ICMP sockets.
187    let gateway_addrs = [
188        IpAddr::V4(config.gateway_ipv4),
189        IpAddr::V6(config.gateway_ipv6),
190        IpAddr::V6(link_local_from_mac(config.gateway_mac)),
191    ];
192    let relay_wake = Arc::new(queues.relay_wake.clone());
193    let mut relays = TcpRelayTable::new(None, egress.clone());
194    let mut udp_sockets = udp_relay::UdpSocketTable::new();
195    let udp_channels = {
196        let shutdown_queues = queues.clone();
197        udp_relay::start_udp_relay(
198            relay_wake.clone(),
199            Arc::new(move || shutdown_queues.is_shutting_down()),
200        )
201    };
202    let icmp_channels = {
203        let shutdown_queues = queues.clone();
204        icmp_relay::start_icmp_relay(
205            relay_wake.clone(),
206            Arc::new(move || shutdown_queues.is_shutting_down()),
207        )
208    };
209
210    // The smoltcp loop is driven by fd-based wakeups rather than busy spinning.
211    // guest_wake  -> new guest frame or shutdown
212    // relay_wake  -> host TCP relay thread produced data or shutdown
213    let mut poll_fds = [
214        libc::pollfd {
215            fd: queues.guest_wake.as_raw_fd(),
216            events: libc::POLLIN,
217            revents: 0,
218        },
219        libc::pollfd {
220            fd: queues.relay_wake.as_raw_fd(),
221            events: libc::POLLIN,
222            revents: 0,
223        },
224    ];
225
226    loop {
227        if queues.is_shutting_down() {
228            return;
229        }
230        let now = smoltcp_now(clock);
231
232        while let Some(frame) = device.stage_next_frame() {
233            // We inspect the frame before giving it to smoltcp because certain
234            // flows need side effects first:
235            // - TCP SYN: pre-create a matching smoltcp socket + relay entry
236            // - DNS UDP: allow through for gateway-side forwarding
237            // - other UDP: pre-create the destination-keyed relay socket
238            match classify_guest_frame(frame) {
239                FrameAction::TcpSyn {
240                    source,
241                    destination,
242                } => {
243                    virtio_net_log!(
244                        "virtio-net: guest TCP SYN source={} destination={}",
245                        source,
246                        destination
247                    );
248                    if !relays.has_socket_for(&source, &destination) {
249                        relays.create_tcp_socket(source, destination, &mut sockets);
250                    }
251                    if matches!(
252                        interface.poll_ingress_single(now, &mut device, &mut sockets),
253                        PollIngressSingleResult::None
254                    ) {
255                        device.drop_staged_frame();
256                    }
257                }
258                FrameAction::DnsQuery | FrameAction::Passthrough => {
259                    if matches!(
260                        interface.poll_ingress_single(now, &mut device, &mut sockets),
261                        PollIngressSingleResult::None
262                    ) {
263                        device.drop_staged_frame();
264                    }
265                }
266                FrameAction::UdpFlow { destination } => {
267                    // Same egress policy as TCP; a denied destination's datagram
268                    // is silently dropped (a guest sees a normal UDP black hole).
269                    if udp_relay::should_relay_udp(destination, &egress)
270                        && udp_sockets.ensure_socket(destination, &mut sockets)
271                    {
272                        if matches!(
273                            interface.poll_ingress_single(now, &mut device, &mut sockets),
274                            PollIngressSingleResult::None
275                        ) {
276                            device.drop_staged_frame();
277                        }
278                    } else {
279                        device.drop_staged_frame();
280                    }
281                }
282            }
283        }
284
285        relay_accepted_tcp_connection(
286            &mut tcp_receiver,
287            &mut relays,
288            &mut interface,
289            &mut sockets,
290            config.gateway_ipv4,
291            config.guest_ipv4,
292        );
293
294        // First egress pass: let smoltcp emit any packets caused by the most
295        // recent ingress work before we service higher-level relays.
296        flush_interface_egress(&mut interface, &mut device, &mut sockets, now);
297        interface.poll_maintenance(now);
298        wake_guest_if_needed(&queues, &device);
299
300        // Move payloads between established smoltcp TCP sockets and host relay
301        // threads, and service the DNS gateway socket.
302        relays.relay_data(&mut sockets);
303        process_dns_queries(dns_socket_handle, &mut sockets, &egress);
304
305        // General UDP: forward staged guest datagrams to the relay thread,
306        // deliver any replies it produced, and expire idle destination sockets.
307        if udp_sockets.drain_to_relay(&mut sockets, &udp_channels.to_relay) {
308            udp_channels.relay_thread_wake.wake();
309        }
310        udp_sockets.deliver_replies(&mut sockets, &udp_channels.from_relay);
311        udp_sockets.expire_idle(&mut sockets);
312
313        // ICMP echo: the raw sockets captured any guest echo requests during
314        // ingress above. Forward external pings to the relay (answering gateway
315        // pings locally), then send back any replies it produced.
316        let mut woke_icmp = false;
317        woke_icmp |= drain_icmp_echo(
318            &mut sockets,
319            icmp4_handle,
320            false,
321            &egress,
322            &gateway_addrs,
323            &icmp_channels.to_relay,
324        );
325        woke_icmp |= drain_icmp_echo(
326            &mut sockets,
327            icmp6_handle,
328            true,
329            &egress,
330            &gateway_addrs,
331            &icmp_channels.to_relay,
332        );
333        if woke_icmp {
334            icmp_channels.relay_thread_wake.wake();
335        }
336        deliver_icmp_replies(
337            &mut sockets,
338            icmp4_handle,
339            icmp6_handle,
340            &icmp_channels.from_relay,
341        );
342
343        // Once the guest-side TCP handshake is established inside smoltcp, we
344        // can spawn the corresponding host relay thread.
345        for connection in relays.take_new_connections(&mut sockets) {
346            spawn_tcp_relay(
347                connection.destination,
348                connection.relay_target,
349                connection.from_smoltcp,
350                connection.to_smoltcp,
351                relay_wake.clone(),
352                connection.exit_state,
353            );
354        }
355
356        relays.cleanup_closed(&mut sockets);
357
358        // Second egress pass: DNS responses or relay data may have queued more
359        // packets for the guest.
360        flush_interface_egress(&mut interface, &mut device, &mut sockets, now);
361        wake_guest_if_needed(&queues, &device);
362
363        let timeout_ms = interface
364            .poll_delay(now, &sockets)
365            .map(|duration| duration.total_millis().min(i32::MAX as u64) as i32)
366            .unwrap_or(DEFAULT_IDLE_TIMEOUT_MS);
367
368        // SAFETY: both pollfds contain valid wake-pipe descriptors.
369        unsafe {
370            libc::poll(
371                poll_fds.as_mut_ptr(),
372                poll_fds.len() as libc::nfds_t,
373                timeout_ms,
374            );
375        }
376
377        if poll_fds[0].revents & libc::POLLIN != 0 {
378            queues.guest_wake.drain();
379        }
380        if poll_fds[1].revents & libc::POLLIN != 0 {
381            queues.relay_wake.drain();
382        }
383    }
384}
385
386fn create_interface(device: &mut VirtioNetworkDevice, config: &VirtioPollConfig) -> Interface {
387    // This interface models the host-side gateway endpoint, not the guest NIC.
388    //
389    // Equivalent conceptual state:
390    //   MAC: config.gateway_mac
391    //   IP : config.gateway_ipv4/30
392    //        config.gateway_ipv6/64 (ULA) + fe80 link-local
393    //
394    // The guest IP exists as a peer on the same virtual link; it is not an
395    // address owned by this interface.
396    let mut interface = Interface::new(
397        Config::new(HardwareAddress::Ethernet(EthernetAddress(
398            config.gateway_mac,
399        ))),
400        device,
401        Instant::ZERO,
402    );
403    interface.update_ip_addrs(|addresses| {
404        addresses
405            .push(IpCidr::new(IpAddress::Ipv4(config.gateway_ipv4), 30))
406            .expect("failed to add gateway IPv4 address");
407        addresses
408            .push(IpCidr::new(
409                IpAddress::Ipv6(config.gateway_ipv6),
410                config.prefix_len6,
411            ))
412            .expect("failed to add gateway IPv6 address");
413        // RFC-clean NDP wants a link-local peer on the segment; derive the
414        // standard EUI-64 link-local from the gateway MAC so the guest kernel
415        // can talk NDP to fe80::… as well as to the ULA.
416        addresses
417            .push(IpCidr::new(
418                IpAddress::Ipv6(link_local_from_mac(config.gateway_mac)),
419                64,
420            ))
421            .expect("failed to add gateway IPv6 link-local address");
422    });
423    // The interface acts as the gateway and may need to answer packets for
424    // destinations other than its directly assigned IP, so the route table and
425    // "any IP" mode are opened up accordingly.
426    interface
427        .routes_mut()
428        .add_default_ipv4_route(config.gateway_ipv4)
429        .expect("failed to add default IPv4 route");
430    interface
431        .routes_mut()
432        .add_default_ipv6_route(config.gateway_ipv6)
433        .expect("failed to add default IPv6 route");
434    interface.set_any_ip(true);
435    interface
436}
437
438/// Derive the EUI-64 IPv6 link-local address for a MAC (RFC 4291 appendix A):
439/// flip the universal/local bit, insert `ff:fe` in the middle.
440fn link_local_from_mac(mac: [u8; 6]) -> Ipv6Addr {
441    Ipv6Addr::new(
442        0xfe80,
443        0,
444        0,
445        0,
446        u16::from_be_bytes([mac[0] ^ 0x02, mac[1]]),
447        u16::from_be_bytes([mac[2], 0xff]),
448        u16::from_be_bytes([0xfe, mac[3]]),
449        u16::from_be_bytes([mac[4], mac[5]]),
450    )
451}
452
453/// add_dns_socket is adding an UDP socket inside smoltcp, so that the guest DNS packet will
454/// hit this socket first. It is then proxied to the resolver. Note that this will not cause
455/// a host side :53 collesion, because the smoltcp Interface, SocketSet is per VM, and the
456/// gateway:53 is for that set of Interface and SocketSet, it is not bind to a host-kernel UDP socket.
457///
458/// The bind is wildcard (port-only) on purpose: combined with `set_any_ip`, every
459/// guest UDP datagram to port 53 — whatever its destination address or family
460/// (the v4 gateway, the v6 gateway, or an external resolver IP) — lands on this
461/// socket and is answered from that same destination address. That transparently
462/// intercepts hardcoded external resolvers too, matching TSI's DNS handling.
463fn add_dns_socket(sockets: &mut SocketSet<'_>) -> SocketHandle {
464    let rx_meta = vec![PacketMetadata::EMPTY; DNS_PACKET_SLOTS];
465    let tx_meta = vec![PacketMetadata::EMPTY; DNS_PACKET_SLOTS];
466    let rx_buffer = PacketBuffer::new(rx_meta, vec![0u8; DNS_BUFFER_BYTES]);
467    let tx_buffer = PacketBuffer::new(tx_meta, vec![0u8; DNS_BUFFER_BYTES]);
468    let mut socket = UdpSocket::new(rx_buffer, tx_buffer);
469    socket
470        .bind(smoltcp::wire::IpListenEndpoint {
471            addr: None,
472            port: DNS_SOCKET_PORT,
473        })
474        .expect("failed to bind gateway DNS socket");
475    sockets.add(socket)
476}
477
478/// Add the two raw IP sockets that capture guest ICMP echo traffic.
479///
480/// A `raw::Socket` receives a copy of every matching IP packet *before* the
481/// interface's "is this addressed to me?" check, so these capture the guest's
482/// echo requests even though their destination is some external host. The same
483/// sockets carry the relayed echo *replies* back out, fully addressed (source =
484/// the pinged host), letting smoltcp own the Ethernet framing and ARP/NDP.
485fn add_icmp_raw_sockets(sockets: &mut SocketSet<'_>) -> (SocketHandle, SocketHandle) {
486    fn raw_socket(version: IpVersion, protocol: IpProtocol) -> RawSocket<'static> {
487        let rx = RawPacketBuffer::new(
488            vec![RawPacketMetadata::EMPTY; ICMP_PACKET_SLOTS],
489            vec![0u8; ICMP_BUFFER_BYTES],
490        );
491        let tx = RawPacketBuffer::new(
492            vec![RawPacketMetadata::EMPTY; ICMP_PACKET_SLOTS],
493            vec![0u8; ICMP_BUFFER_BYTES],
494        );
495        RawSocket::new(Some(version), Some(protocol), rx, tx)
496    }
497
498    let v4 = sockets.add(raw_socket(IpVersion::Ipv4, IpProtocol::Icmp));
499    let v6 = sockets.add(raw_socket(IpVersion::Ipv6, IpProtocol::Icmpv6));
500    (v4, v6)
501}
502
503/// Drain guest echo requests captured on one ICMP raw socket. Gateway-destined
504/// pings are answered locally (the gateway *is* the source), external ones are
505/// forwarded to the relay thread subject to egress policy, and denied
506/// destinations are dropped. Returns true if anything was sent to the relay.
507fn drain_icmp_echo(
508    sockets: &mut SocketSet<'_>,
509    handle: SocketHandle,
510    is_ipv6: bool,
511    egress: &EgressPolicy,
512    gateway_addrs: &[IpAddr],
513    to_relay: &SyncSender<icmp_relay::IcmpEcho>,
514) -> bool {
515    // Phase 1: drain received requests into owned values so the socket can be
516    // re-borrowed below to emit local gateway replies.
517    let mut echoes = Vec::new();
518    {
519        let socket = sockets.get_mut::<RawSocket>(handle);
520        while socket.can_recv() {
521            let Ok(packet) = socket.recv() else {
522                break;
523            };
524            let parsed = if is_ipv6 {
525                icmp_relay::parse_guest_echo_v6(packet)
526            } else {
527                icmp_relay::parse_guest_echo_v4(packet)
528            };
529            if let Some(echo) = parsed {
530                echoes.push(echo);
531            }
532        }
533    }
534
535    // Phase 2: route each echo.
536    let mut woke = false;
537    let mut local_replies = Vec::new();
538    for echo in echoes {
539        if gateway_addrs.contains(&echo.destination) {
540            local_replies.push(echo);
541        } else if icmp_relay::should_relay_icmp(echo.destination, egress) {
542            match to_relay.try_send(echo) {
543                Ok(()) => woke = true,
544                Err(TrySendError::Full(_)) => {
545                    virtio_net_log!("virtio-net: dropping guest ICMP echo (relay queue full)");
546                }
547                Err(TrySendError::Disconnected(_)) => return woke,
548            }
549        }
550        // else: egress policy denies the destination — silent black hole.
551    }
552
553    // Phase 3: answer gateway pings straight back out the raw socket.
554    if !local_replies.is_empty() {
555        let socket = sockets.get_mut::<RawSocket>(handle);
556        for reply in local_replies {
557            let frame = if is_ipv6 {
558                icmp_relay::build_echo_reply_v6(&reply)
559            } else {
560                icmp_relay::build_echo_reply_v4(&reply)
561            };
562            if let Some(frame) = frame {
563                let _ = socket.send_slice(&frame);
564            }
565        }
566    }
567    woke
568}
569
570/// Deliver echo replies produced by the relay thread, sending each as a
571/// fully-addressed IP packet (source = the pinged host) out the matching raw
572/// socket so smoltcp frames it back to the guest.
573fn deliver_icmp_replies(
574    sockets: &mut SocketSet<'_>,
575    icmp4_handle: SocketHandle,
576    icmp6_handle: SocketHandle,
577    from_relay: &Receiver<icmp_relay::IcmpEcho>,
578) {
579    while let Ok(reply) = from_relay.try_recv() {
580        let (handle, frame) = match reply.guest {
581            IpAddr::V4(_) => (icmp4_handle, icmp_relay::build_echo_reply_v4(&reply)),
582            IpAddr::V6(_) => (icmp6_handle, icmp_relay::build_echo_reply_v6(&reply)),
583        };
584        let Some(frame) = frame else {
585            continue;
586        };
587        let socket = sockets.get_mut::<RawSocket>(handle);
588        if socket.send_slice(&frame).is_err() {
589            virtio_net_log!(
590                "virtio-net: dropping ICMP reply to {} (raw socket buffer full)",
591                reply.guest
592            );
593        }
594    }
595}
596
597/// Receive the accepted TCP connection from the tcp_channel, and then relay it to
598/// the TcpRelayTable where the TCP network packets will be relayed to the guest.
599fn relay_accepted_tcp_connection(
600    tcp_receiver: &mut Option<Receiver<AcceptedTcpConnection>>,
601    relays: &mut TcpRelayTable,
602    interface: &mut Interface,
603    sockets: &mut SocketSet<'_>,
604    gateway_ipv4: Ipv4Addr,
605    guest_ipv4: Ipv4Addr,
606) {
607    // Published-port model:
608    //
609    // host client -> accepted host TcpStream
610    //             -> create guest-facing smoltcp socket from gateway_ip:ephemeral
611    //             -> guest sees a normal inbound TCP connection to guest_port
612    //             -> once Established, the relay thread bridges payloads
613    //
614    // The guest does not see the original host peer address here. This path is
615    // effectively a small userspace TCP proxy/NAT at the gateway boundary.
616    let mut disconnected = false;
617
618    if let Some(receiver) = tcp_receiver.as_mut() {
619        loop {
620            match receiver.try_recv() {
621                Ok(connection) => {
622                    let guest_destination =
623                        SocketAddr::new(std::net::IpAddr::V4(guest_ipv4), connection.guest_port);
624                    virtio_net_log!(
625                        "virtio-net: accepted published TCP connection peer={} host_port={} guest_destination={}",
626                        connection.peer_addr,
627                        connection.host_port,
628                        guest_destination
629                    );
630                    if !relays.create_published_socket(
631                        interface,
632                        gateway_ipv4,
633                        guest_destination,
634                        connection.stream,
635                        sockets,
636                    ) {
637                        tracing::warn!(
638                            host_port = connection.host_port,
639                            guest_port = connection.guest_port,
640                            peer_addr = %connection.peer_addr,
641                            "dropping published TCP connection because the guest relay path could not be created"
642                        );
643                    }
644                }
645                Err(TryRecvError::Empty) => break,
646                Err(TryRecvError::Disconnected) => {
647                    disconnected = true;
648                    break;
649                }
650            }
651        }
652    }
653
654    if disconnected {
655        *tcp_receiver = None;
656    }
657}
658
659fn process_dns_queries(
660    dns_socket_handle: SocketHandle,
661    sockets: &mut SocketSet<'_>,
662    egress: &EgressPolicy,
663) {
664    // Phase 1 DNS model:
665    // guest UDP/53 -> smoltcp gateway socket -> host UDP socket -> upstream DNS
666    //               <-               response bytes               <-
667    let upstream_dns = match DEFAULT_DNS_ADDR {
668        std::net::IpAddr::V4(ip) => ip,
669        std::net::IpAddr::V6(_) => return,
670    };
671
672    let socket = sockets.get_mut::<UdpSocket>(dns_socket_handle);
673    while socket.can_recv() {
674        let (query, metadata) = match socket.recv() {
675            Ok(result) => result,
676            Err(_) => break,
677        };
678        virtio_net_log!(
679            "virtio-net: forwarding guest DNS query guest={} local_address={:?} query_len={} upstream_dns={}",
680            metadata.endpoint,
681            metadata.local_address,
682            query.len(),
683            upstream_dns
684        );
685        // allow-host filtering: only forward queries whose name is allow-listed;
686        // others get NXDOMAIN. A/AAAA records of allowed answers are learned as
687        // temporary egress IPs so the follow-up connection passes the filter.
688        // Mirrors libkrun's TSI DNS filter.
689        let response = if egress.dns_filter_active() {
690            match dns::question_name(query) {
691                Some(name) if egress.hostname_allowed(&name) => {
692                    match forward_dns_query(upstream_dns, query) {
693                        Ok(response) => {
694                            egress.learn_ip_records(&dns::answer_ip_records(&response));
695                            response
696                        }
697                        Err(err) => {
698                            virtio_net_log!("virtio-net: host DNS forwarding failed error={}", err);
699                            continue;
700                        }
701                    }
702                }
703                Some(name) => {
704                    virtio_net_log!(
705                        "virtio-net: blocking DNS query by allow-host policy name={}",
706                        name
707                    );
708                    dns::error_response(query, dns::DNS_RCODE_NXDOMAIN)
709                }
710                None => dns::error_response(query, dns::DNS_RCODE_SERVFAIL),
711            }
712        } else {
713            match forward_dns_query(upstream_dns, query) {
714                Ok(response) => response,
715                Err(err) => {
716                    virtio_net_log!("virtio-net: host DNS forwarding failed error={}", err);
717                    continue;
718                }
719            }
720        };
721        virtio_net_log!(
722            "virtio-net: forwarded DNS response back to guest guest={} response_len={}",
723            metadata.endpoint,
724            response.len()
725        );
726
727        let response_meta = UdpMetadata {
728            endpoint: metadata.endpoint,
729            local_address: metadata.local_address,
730            meta: Default::default(),
731        };
732        let _ = socket.send_slice(&response, response_meta);
733    }
734}
735
736fn forward_dns_query(upstream_dns: Ipv4Addr, query: &[u8]) -> std::io::Result<Vec<u8>> {
737    // This is intentionally a plain host UDP exchange rather than a smoltcp
738    // socket-to-socket relay. Once the guest packet reaches the gateway, the
739    // simplest MVP path is to proxy it with the host kernel's UDP stack.
740    //
741    // Rough shell equivalent:
742    //   send raw DNS message to `<upstream_dns>:53`
743    //   wait up to 2 seconds for one reply
744    let socket = HostUdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
745    socket.set_read_timeout(Some(Duration::from_secs(2)))?;
746    let local_addr = socket.local_addr()?;
747    virtio_net_log!(
748        "virtio-net: sending DNS query to upstream resolver local_addr={} upstream_dns={} query_len={}",
749        local_addr,
750        upstream_dns,
751        query.len()
752    );
753    socket.send_to(query, (upstream_dns, DNS_SOCKET_PORT))?;
754
755    let mut buffer = vec![0u8; DNS_BUFFER_BYTES];
756    let (bytes_read, _) = socket.recv_from(&mut buffer)?;
757    buffer.truncate(bytes_read);
758    virtio_net_log!(
759        "virtio-net: received DNS response from upstream resolver upstream_dns={} response_len={}",
760        upstream_dns,
761        buffer.len()
762    );
763    Ok(buffer)
764}
765
766fn flush_interface_egress(
767    interface: &mut Interface,
768    device: &mut VirtioNetworkDevice,
769    sockets: &mut SocketSet<'_>,
770    now: Instant,
771) {
772    // smoltcp may have multiple pending egress packets after a single ingress
773    // event or timeout. Keep polling until the interface reports there is no
774    // more immediate work.
775    loop {
776        let result = interface.poll_egress(now, device, sockets);
777        if matches!(result, PollResult::None) {
778            break;
779        }
780    }
781}
782
783fn wake_guest_if_needed(queues: &NetworkFrameQueues, device: &VirtioNetworkDevice) {
784    // The device records only that "some frame was emitted". We convert that
785    // sticky bit into one wake for the writer thread and let the writer drain
786    // the entire host_to_guest queue.
787    if device.frames_emitted.swap(false, Ordering::Relaxed) {
788        queues.host_wake.wake();
789    }
790}
791
792fn smoltcp_now(clock: StdInstant) -> Instant {
793    let elapsed = clock.elapsed();
794    Instant::from_millis(elapsed.as_millis() as i64)
795}
796
797fn classify_guest_frame(frame: &[u8]) -> FrameAction {
798    let ethernet = match EthernetFrame::new_checked(frame) {
799        Ok(frame) => frame,
800        Err(_) => return FrameAction::Passthrough,
801    };
802
803    // Extract (src, dst, transport protocol, transport payload) from either IP
804    // family. Anything that isn't plain IPv4/IPv6 — ARP, and IPv6 packets with
805    // extension headers (which guest TCP/UDP traffic doesn't use) — passes
806    // through to smoltcp untouched; that also covers ICMPv6/NDP.
807    let (src_ip, dst_ip, protocol, transport): (IpAddr, IpAddr, _, _) = match ethernet.ethertype() {
808        EthernetProtocol::Ipv4 => {
809            let ipv4 = match Ipv4Packet::new_checked(ethernet.payload()) {
810                Ok(packet) => packet,
811                Err(_) => return FrameAction::Passthrough,
812            };
813            (
814                IpAddr::V4(ipv4.src_addr()),
815                IpAddr::V4(ipv4.dst_addr()),
816                ipv4.next_header(),
817                ipv4.payload(),
818            )
819        }
820        EthernetProtocol::Ipv6 => {
821            let ipv6 = match Ipv6Packet::new_checked(ethernet.payload()) {
822                Ok(packet) => packet,
823                Err(_) => return FrameAction::Passthrough,
824            };
825            (
826                IpAddr::V6(ipv6.src_addr()),
827                IpAddr::V6(ipv6.dst_addr()),
828                ipv6.next_header(),
829                ipv6.payload(),
830            )
831        }
832        _ => return FrameAction::Passthrough,
833    };
834
835    match protocol {
836        smoltcp::wire::IpProtocol::Tcp => {
837            let tcp = match TcpPacket::new_checked(transport) {
838                Ok(packet) => packet,
839                Err(_) => return FrameAction::Passthrough,
840            };
841
842            if tcp.syn() && !tcp.ack() {
843                FrameAction::TcpSyn {
844                    source: SocketAddr::new(src_ip, tcp.src_port()),
845                    destination: SocketAddr::new(dst_ip, tcp.dst_port()),
846                }
847            } else {
848                FrameAction::Passthrough
849            }
850        }
851        smoltcp::wire::IpProtocol::Udp => {
852            let udp = match UdpPacket::new_checked(transport) {
853                Ok(packet) => packet,
854                Err(_) => return FrameAction::Passthrough,
855            };
856
857            if udp.dst_port() == DNS_SOCKET_PORT {
858                FrameAction::DnsQuery
859            } else {
860                FrameAction::UdpFlow {
861                    destination: SocketAddr::new(dst_ip, udp.dst_port()),
862                }
863            }
864        }
865        _ => FrameAction::Passthrough,
866    }
867}
868
869/// Fuzz-only entrypoint for `classify_guest_frame`.
870///
871/// A malicious guest sends arbitrary ethernet frames over virtio-net, and the
872/// host parses every one here — so this MUST NOT panic on any input. Gated
873/// behind the `fuzzing` feature so it never ships in a normal build.
874#[cfg(feature = "fuzzing")]
875pub fn fuzz_classify_guest_frame(frame: &[u8]) {
876    let _ = classify_guest_frame(frame);
877}