Skip to main content

smolvm_network/
dns_relay.rs

1//! Off-poll-thread DNS resolver for the virtio-net backend.
2//!
3//! Context
4//! =======
5//!
6//! Guest DNS (UDP/TCP :53) is intercepted by the gateway and forwarded to an
7//! upstream resolver (`stack.rs::process_dns_queries` / `process_dns_tcp`). The
8//! upstream exchange is a *blocking* host socket round trip with a 2-second
9//! timeout. It used to run inline on the single virtio-net poll thread — the
10//! thread that owns the smoltcp interface and every one of that VM's TCP/UDP/
11//! ICMP sockets. A slow or dead resolver therefore stalled *all* of the guest's
12//! traffic for up to 2 seconds (head-of-line block).
13//!
14//! This module moves that blocking resolution off the poll thread, mirroring
15//! the UDP/ICMP relay offload ([`crate::udp_relay`]). The poll loop only:
16//!   1. reads a guest query out of its smoltcp DNS socket,
17//!   2. applies the egress allow-host policy itself (cheap, no host I/O),
18//!   3. hands allowed queries to this relay over a channel, and
19//!   4. later picks the answer back up via the shared relay wake + channel and
20//!      writes it into the guest socket — never blocking on the resolver.
21//!
22//! Egress policy (allow/deny, `learn_ip_records`) stays on the poll thread so
23//! [`EgressPolicy`](crate::egress::EgressPolicy) is never shared across threads;
24//! only the raw upstream forward is offloaded.
25//!
26//! ```text
27//! guest :53 query -> smoltcp gateway socket
28//!   -> poll loop: classify (allow-host) -> allowed?
29//!        no  -> answer NXDOMAIN/SERVFAIL immediately (no relay)
30//!        yes -> assign id, remember reply context, channel (id, query) to relay
31//!   -> relay thread: UDP -> non-blocking connected host socket + poller
32//!                    TCP -> bounded detached worker (rare path)
33//!   -> answer bytes -> channel back -> reply_wake
34//!   -> poll loop: learn A/AAAA records, write answer into the guest socket
35//! ```
36//!
37//! DNS is low-volume and its answers are quick, so the tables here are small and
38//! loss under saturation just makes a guest see a normal DNS timeout.
39
40use crate::queues::WakePipe;
41use crate::virtio_net_log;
42use polling::{Event, Events};
43use std::io::{Read, Write};
44use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream, UdpSocket as HostUdpSocket};
45use std::sync::atomic::{AtomicUsize, Ordering};
46use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError, TrySendError};
47use std::sync::Arc;
48use std::thread;
49use std::time::{Duration, Instant};
50
51/// DNS service port.
52const DNS_PORT: u16 = 53;
53/// Upstream exchange timeout, matching the previous inline behaviour.
54const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(2);
55/// Largest DNS message we accept back (EDNS0 / DNS-over-TCP bound).
56const DNS_MAX_MSG: usize = 4096;
57/// Max in-flight queries buffered in each channel direction.
58const CHANNEL_CAPACITY: usize = 256;
59/// Max concurrent in-flight UDP queries with a live host socket.
60const MAX_INFLIGHT_UDP: usize = 256;
61/// Max concurrent in-flight DNS-over-TCP worker threads.
62const MAX_INFLIGHT_TCP: usize = 64;
63/// Relay thread poll ceiling so shutdown and deadlines are noticed promptly.
64const RELAY_POLL_MAX_MS: u64 = 250;
65
66/// Which upstream transport a query must use.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum DnsTransport {
69    Udp,
70    Tcp,
71}
72
73/// A query handed from the poll loop to the relay thread.
74pub struct DnsQuery {
75    /// Correlation id chosen by the poll loop; echoed back in the response.
76    pub id: u64,
77    /// Transport to reach the upstream resolver on.
78    pub transport: DnsTransport,
79    /// Upstream resolver address.
80    pub upstream: Ipv4Addr,
81    /// Raw DNS query message (no TCP length prefix).
82    pub query: Vec<u8>,
83}
84
85/// A resolved answer handed back to the poll loop.
86pub struct DnsResponse {
87    /// The id of the originating [`DnsQuery`].
88    pub id: u64,
89    /// Raw DNS answer message, or `None` on error/timeout (guest sees a normal
90    /// DNS timeout — identical to the old inline `forward` error path).
91    pub answer: Option<Vec<u8>>,
92}
93
94/// Channel pair connecting the poll loop and the DNS relay thread.
95pub struct DnsRelayChannels {
96    /// Poll loop -> relay thread.
97    pub to_relay: SyncSender<DnsQuery>,
98    /// Relay thread -> poll loop.
99    pub from_relay: Receiver<DnsResponse>,
100    /// Wakes the relay thread after `to_relay` sends.
101    pub relay_thread_wake: WakePipe,
102}
103
104/// Start the DNS relay thread. Returns the poll-loop-side channel endpoints.
105///
106/// `reply_wake` is the smoltcp poll loop's existing relay wake pipe — pulsed
107/// whenever an answer is queued so the loop wakes to deliver it. The thread
108/// exits when `shutdown` reports true (checked at least once per
109/// [`RELAY_POLL_MAX_MS`]).
110pub fn start_dns_relay(
111    reply_wake: Arc<WakePipe>,
112    shutdown: Arc<dyn Fn() -> bool + Send + Sync>,
113) -> DnsRelayChannels {
114    let (to_relay_tx, to_relay_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
115    let (from_relay_tx, from_relay_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
116    let relay_thread_wake = WakePipe::new();
117    let thread_wake = relay_thread_wake.clone();
118
119    let _ = thread::Builder::new()
120        .name("smolvm-dns-relay".into())
121        .spawn(move || {
122            run_dns_relay(
123                to_relay_rx,
124                from_relay_tx,
125                thread_wake,
126                reply_wake,
127                shutdown,
128            );
129        });
130
131    DnsRelayChannels {
132        to_relay: to_relay_tx,
133        from_relay: from_relay_rx,
134        relay_thread_wake,
135    }
136}
137
138/// One in-flight UDP query: a non-blocking connected host socket awaiting its
139/// single response, and the deadline after which we give up.
140struct InflightUdp {
141    socket: HostUdpSocket,
142    deadline: Instant,
143}
144
145/// Send one answer back to the poll loop. Returns `true` if the caller should
146/// wake the poll loop, `false` if the channel is closed (relay should exit).
147fn deliver_answer(inbound: &SyncSender<DnsResponse>, id: u64, answer: Option<Vec<u8>>) -> bool {
148    match inbound.try_send(DnsResponse { id, answer }) {
149        Ok(()) => true,
150        Err(TrySendError::Full(_)) => {
151            virtio_net_log!(
152                "virtio-net: dropping DNS answer id={} (inbound queue full)",
153                id
154            );
155            true
156        }
157        Err(TrySendError::Disconnected(_)) => false,
158    }
159}
160
161fn run_dns_relay(
162    outbound: Receiver<DnsQuery>,
163    inbound: SyncSender<DnsResponse>,
164    wake: WakePipe,
165    reply_wake: Arc<WakePipe>,
166    shutdown: Arc<dyn Fn() -> bool + Send + Sync>,
167) {
168    let mut inflight: std::collections::HashMap<u64, InflightUdp> =
169        std::collections::HashMap::new();
170    let mut recv_buf = vec![0u8; DNS_MAX_MSG];
171    let tcp_inflight = Arc::new(AtomicUsize::new(0));
172
173    loop {
174        if shutdown() {
175            return;
176        }
177
178        let mut woke_reply = false;
179
180        // Outbound: queries handed over by the poll loop.
181        loop {
182            match outbound.try_recv() {
183                Ok(query) => match query.transport {
184                    DnsTransport::Udp => {
185                        if inflight.len() >= MAX_INFLIGHT_UDP {
186                            virtio_net_log!(
187                                "virtio-net: dropping DNS query id={} (in-flight UDP table full)",
188                                query.id
189                            );
190                            woke_reply |= deliver_answer(&inbound, query.id, None);
191                            continue;
192                        }
193                        let upstream = SocketAddr::new(IpAddr::V4(query.upstream), DNS_PORT);
194                        match start_udp_query(upstream, &query.query) {
195                            Ok(socket) => {
196                                inflight.insert(
197                                    query.id,
198                                    InflightUdp {
199                                        socket,
200                                        deadline: Instant::now() + UPSTREAM_TIMEOUT,
201                                    },
202                                );
203                            }
204                            Err(err) => {
205                                virtio_net_log!(
206                                    "virtio-net: DNS/UDP upstream send failed id={} error={}",
207                                    query.id,
208                                    err
209                                );
210                                woke_reply |= deliver_answer(&inbound, query.id, None);
211                            }
212                        }
213                    }
214                    DnsTransport::Tcp => {
215                        spawn_tcp_query(&inbound, &reply_wake, &tcp_inflight, query);
216                    }
217                },
218                Err(TryRecvError::Empty) => break,
219                Err(TryRecvError::Disconnected) => return,
220            }
221        }
222
223        // Inbound: block on "wake OR any in-flight UDP socket readable", exactly
224        // like the UDP relay. Each socket is registered at key `slot + 1`.
225        let poller = wake.poller();
226        let ids: Vec<u64> = inflight.keys().copied().collect();
227        for (slot, id) in ids.iter().enumerate() {
228            // SAFETY: the socket is owned by `inflight` and is deleted from the
229            // poller below before it can be dropped.
230            let _ = unsafe { poller.add(&inflight[id].socket, Event::readable(slot + 1)) };
231        }
232
233        // Wake no later than the nearest deadline so timeouts fire on time.
234        let now = Instant::now();
235        let mut wait = Duration::from_millis(RELAY_POLL_MAX_MS);
236        for f in inflight.values() {
237            let remaining = f.deadline.saturating_duration_since(now);
238            if remaining < wait {
239                wait = remaining;
240            }
241        }
242
243        let mut events = Events::new();
244        let _ = poller.wait(&mut events, Some(wait));
245
246        let mut ready: Vec<bool> = vec![false; ids.len()];
247        for event in events.iter() {
248            if event.key >= 1 && event.key - 1 < ready.len() {
249                ready[event.key - 1] = true;
250            }
251        }
252
253        // Deregister every socket before mutating `inflight`.
254        for id in &ids {
255            let _ = poller.delete(&inflight[id].socket);
256        }
257
258        // Deliver ready answers; expire the rest past their deadline.
259        let now = Instant::now();
260        for (slot, id) in ids.iter().enumerate() {
261            if ready[slot] {
262                let answer = inflight
263                    .get(id)
264                    .and_then(|f| match f.socket.recv(&mut recv_buf) {
265                        Ok(len) => Some(recv_buf[..len].to_vec()),
266                        Err(_) => None,
267                    });
268                match answer {
269                    Some(bytes) => {
270                        inflight.remove(id);
271                        woke_reply |= deliver_answer(&inbound, *id, Some(bytes));
272                    }
273                    // Spurious readiness with nothing to read: leave it in flight
274                    // to be retried or expired.
275                    None if inflight.get(id).is_some_and(|f| f.deadline <= now) => {
276                        inflight.remove(id);
277                        woke_reply |= deliver_answer(&inbound, *id, None);
278                    }
279                    None => {}
280                }
281            } else if inflight.get(id).is_some_and(|f| f.deadline <= now) {
282                inflight.remove(id);
283                woke_reply |= deliver_answer(&inbound, *id, None);
284            }
285        }
286
287        if woke_reply {
288            reply_wake.wake();
289        }
290    }
291}
292
293/// Open a non-blocking host UDP socket connected to the upstream resolver and
294/// send the query. The single response is collected later by the poll section.
295fn start_udp_query(upstream: SocketAddr, query: &[u8]) -> std::io::Result<HostUdpSocket> {
296    let bind: SocketAddr = if upstream.is_ipv4() {
297        (Ipv4Addr::UNSPECIFIED, 0).into()
298    } else {
299        (std::net::Ipv6Addr::UNSPECIFIED, 0).into()
300    };
301    let socket = HostUdpSocket::bind(bind)?;
302    socket.connect(upstream)?;
303    socket.set_nonblocking(true)?;
304    socket.send(query)?;
305    Ok(socket)
306}
307
308/// Resolve a DNS-over-TCP query on a bounded, detached worker thread.
309///
310/// DNS/TCP is the rare fallback path (truncated/large answers). Rather than
311/// build a non-blocking length-prefixed TCP state machine, each query gets its
312/// own short-lived worker so a slow TCP resolver never blocks the UDP fast path
313/// or the poll loop. The worker count is capped; over the cap the query is
314/// answered as a timeout.
315fn spawn_tcp_query(
316    inbound: &SyncSender<DnsResponse>,
317    reply_wake: &Arc<WakePipe>,
318    tcp_inflight: &Arc<AtomicUsize>,
319    query: DnsQuery,
320) {
321    if tcp_inflight.load(Ordering::Relaxed) >= MAX_INFLIGHT_TCP {
322        virtio_net_log!(
323            "virtio-net: dropping DNS/TCP query id={} (worker cap reached)",
324            query.id
325        );
326        if deliver_answer(inbound, query.id, None) {
327            reply_wake.wake();
328        }
329        return;
330    }
331    tcp_inflight.fetch_add(1, Ordering::Relaxed);
332    let worker_inbound = inbound.clone();
333    let worker_wake = reply_wake.clone();
334    let worker_inflight = tcp_inflight.clone();
335    let id = query.id;
336    let spawned = thread::Builder::new()
337        .name("smolvm-dns-tcp".into())
338        .spawn(move || {
339            let upstream = SocketAddr::new(IpAddr::V4(query.upstream), DNS_PORT);
340            let answer = forward_dns_query_tcp(upstream, &query.query).ok();
341            if deliver_answer(&worker_inbound, id, answer) {
342                worker_wake.wake();
343            }
344            worker_inflight.fetch_sub(1, Ordering::Relaxed);
345        });
346    if spawned.is_err() {
347        // Could not spawn: undo the reservation and answer as a timeout.
348        tcp_inflight.fetch_sub(1, Ordering::Relaxed);
349        if deliver_answer(inbound, id, None) {
350            reply_wake.wake();
351        }
352    }
353}
354
355/// Forward one DNS query to the upstream resolver over TCP (length-prefixed, per
356/// RFC 1035 §4.2.2) and return the raw response message. Blocking host TCP
357/// exchange with a short timeout — runs only on a detached worker, never the
358/// poll thread.
359fn forward_dns_query_tcp(upstream: SocketAddr, query: &[u8]) -> std::io::Result<Vec<u8>> {
360    use std::io::{Error, ErrorKind};
361    let len = u16::try_from(query.len())
362        .map_err(|_| Error::new(ErrorKind::InvalidInput, "DNS query too large for TCP"))?;
363    let mut stream = TcpStream::connect_timeout(&upstream, UPSTREAM_TIMEOUT)?;
364    stream.set_read_timeout(Some(UPSTREAM_TIMEOUT))?;
365    stream.set_write_timeout(Some(UPSTREAM_TIMEOUT))?;
366    stream.write_all(&len.to_be_bytes())?;
367    stream.write_all(query)?;
368    stream.flush()?;
369
370    let mut len_buf = [0u8; 2];
371    stream.read_exact(&mut len_buf)?;
372    let resp_len = u16::from_be_bytes(len_buf) as usize;
373    if resp_len == 0 || resp_len > DNS_MAX_MSG {
374        return Err(Error::new(
375            ErrorKind::InvalidData,
376            "upstream DNS/TCP response length out of range",
377        ));
378    }
379    let mut response = vec![0u8; resp_len];
380    stream.read_exact(&mut response)?;
381    Ok(response)
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use std::sync::atomic::AtomicBool;
388
389    /// `start_udp_query` sends off a non-blocking connected socket; its single
390    /// response is collected later without blocking. Exercises the real fn
391    /// against a loopback echo "resolver" (arbitrary port, so no port-53 dep).
392    #[test]
393    fn start_udp_query_sends_and_receives_nonblocking() {
394        let upstream = HostUdpSocket::bind("127.0.0.1:0").unwrap();
395        let upstream_addr = upstream.local_addr().unwrap();
396        let resolver = thread::spawn(move || {
397            let mut buf = [0u8; 512];
398            upstream
399                .set_read_timeout(Some(Duration::from_secs(2)))
400                .unwrap();
401            let (n, peer) = upstream.recv_from(&mut buf).unwrap();
402            upstream.send_to(&buf[..n], peer).unwrap();
403        });
404
405        let sock = start_udp_query(upstream_addr, b"\x12\x34hello-dns").unwrap();
406
407        let deadline = Instant::now() + Duration::from_secs(2);
408        let mut buf = [0u8; 512];
409        loop {
410            match sock.recv(&mut buf) {
411                Ok(n) => {
412                    assert_eq!(&buf[..n], b"\x12\x34hello-dns");
413                    break;
414                }
415                Err(_) if Instant::now() < deadline => {
416                    thread::sleep(Duration::from_millis(5));
417                }
418                Err(e) => panic!("no DNS answer: {e}"),
419            }
420        }
421        resolver.join().unwrap();
422    }
423
424    /// End-to-end through the relay thread: a live upstream on port 53 needs
425    /// privileges, so this proves the *offload contract* — the poll thread only
426    /// sends/receives channel messages and never blocks — using an unreachable
427    /// upstream that must resolve to a timeout answer, promptly and off-thread.
428    #[test]
429    fn poll_thread_never_blocks_on_dead_resolver() {
430        let reply_wake = Arc::new(WakePipe::new());
431        let stop = Arc::new(AtomicBool::new(false));
432        let stop_flag = stop.clone();
433        let channels = start_dns_relay(
434            reply_wake.clone(),
435            Arc::new(move || stop_flag.load(Ordering::Relaxed)),
436        );
437
438        // 192.0.2.1 is TEST-NET-1 (RFC 5737): guaranteed unroutable, so the
439        // upstream never answers and the relay must time it out for us.
440        let started = Instant::now();
441        channels
442            .to_relay
443            .send(DnsQuery {
444                id: 7,
445                transport: DnsTransport::Udp,
446                upstream: Ipv4Addr::new(192, 0, 2, 1),
447                query: b"\x00\x00query".to_vec(),
448            })
449            .unwrap();
450        channels.relay_thread_wake.wake();
451
452        // The send returned immediately (offloaded); this thread is free.
453        assert!(started.elapsed() < Duration::from_millis(50));
454
455        // The answer (a timeout -> None) arrives via the channel, driven by the
456        // relay thread, within a bit over the 2s upstream timeout.
457        let resp = channels
458            .from_relay
459            .recv_timeout(Duration::from_secs(4))
460            .expect("relay must always answer, even on timeout");
461        assert_eq!(resp.id, 7);
462        assert!(resp.answer.is_none());
463
464        stop.store(true, Ordering::Relaxed);
465        channels.relay_thread_wake.wake();
466    }
467
468    /// The real `forward_dns_query_tcp` resolves against a length-prefixed host
469    /// TCP resolver (arbitrary loopback port) and returns the raw, unprefixed
470    /// answer message. This is the code the detached DNS/TCP worker runs.
471    #[test]
472    fn forward_dns_query_tcp_round_trips() {
473        use std::net::TcpListener;
474        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
475        let addr = listener.local_addr().unwrap();
476
477        // Minimal DNS-over-TCP resolver: read the length-prefixed query, reply
478        // with a length-prefixed canned answer.
479        let server = thread::spawn(move || {
480            let (mut sock, _) = listener.accept().unwrap();
481            let mut len_buf = [0u8; 2];
482            sock.read_exact(&mut len_buf).unwrap();
483            let qlen = u16::from_be_bytes(len_buf) as usize;
484            let mut q = vec![0u8; qlen];
485            sock.read_exact(&mut q).unwrap();
486            assert_eq!(&q, b"tcp-query");
487            let answer = b"answer-bytes";
488            sock.write_all(&(answer.len() as u16).to_be_bytes())
489                .unwrap();
490            sock.write_all(answer).unwrap();
491        });
492
493        let resp = forward_dns_query_tcp(addr, b"tcp-query").unwrap();
494        assert_eq!(resp, b"answer-bytes");
495        server.join().unwrap();
496    }
497}