Skip to main content

fips_core/upper/
dns.rs

1//! FIPS DNS Responder
2//!
3//! Resolves `.fips` queries to FipsAddress IPv6 addresses. Two resolution
4//! paths are supported:
5//!
6//! 1. **Hostname**: `<hostname>.fips` — looked up in the [`HostMap`] to get
7//!    an npub, then resolved to IPv6.
8//! 2. **Direct npub**: `<npub>.fips` — pure computation from public key.
9//!
10//! As a side effect, resolved identities are sent to the Node for identity
11//! cache population, enabling subsequent TUN packet routing.
12
13use crate::upper::hosts::{HostMap, HostMapReloader};
14use crate::{NodeAddr, PeerIdentity};
15use simple_dns::rdata::{AAAA, RData};
16use simple_dns::{CLASS, Name, Packet, PacketFlag, QTYPE, RCODE, ResourceRecord, TYPE};
17use std::net::Ipv6Addr;
18use tracing::{debug, trace, warn};
19
20/// Identity resolved by the DNS responder, sent to Node for cache population.
21pub struct DnsResolvedIdentity {
22    pub node_addr: NodeAddr,
23    pub pubkey: secp256k1::PublicKey,
24}
25
26/// Channel sender for DNS → Node identity registration.
27pub type DnsIdentityTx = tokio::sync::mpsc::Sender<DnsResolvedIdentity>;
28
29/// Channel receiver consumed by the Node RX event loop.
30pub type DnsIdentityRx = tokio::sync::mpsc::Receiver<DnsResolvedIdentity>;
31
32/// Extract the label before `.fips` from a DNS query name.
33///
34/// Handles trailing dots and case-insensitive `.fips` suffix matching.
35fn extract_fips_label(name: &str) -> Option<&str> {
36    let name = name.strip_suffix('.').unwrap_or(name);
37    name.strip_suffix(".fips")
38        .or_else(|| name.strip_suffix(".FIPS"))
39        .or_else(|| {
40            let lower = name.to_ascii_lowercase();
41            if lower.ends_with(".fips") {
42                Some(&name[..name.len() - 5])
43            } else {
44                None
45            }
46        })
47}
48
49/// Resolve a `.fips` domain name to an IPv6 address and identity.
50///
51/// The name should be `<npub>.fips` (with optional trailing dot).
52/// Returns the FipsAddress IPv6, NodeAddr, and full PublicKey on success.
53pub fn resolve_fips_query(name: &str) -> Option<(Ipv6Addr, NodeAddr, secp256k1::PublicKey)> {
54    let npub = extract_fips_label(name)?;
55    let peer = PeerIdentity::from_npub(npub).ok()?;
56    let ipv6 = peer.address().to_ipv6();
57    let node_addr = *peer.node_addr();
58    let pubkey = peer.pubkey_full();
59
60    Some((ipv6, node_addr, pubkey))
61}
62
63/// Resolve a `.fips` domain name with host map lookup.
64///
65/// Resolution order:
66/// 1. Extract the label before `.fips`
67/// 2. If the label matches a hostname in the host map, use the mapped npub
68/// 3. Otherwise, treat the label as a direct npub
69/// 4. Resolve the npub to IPv6 via `PeerIdentity`
70pub fn resolve_fips_query_with_hosts(
71    name: &str,
72    hosts: &HostMap,
73) -> Option<(Ipv6Addr, NodeAddr, secp256k1::PublicKey)> {
74    let label = extract_fips_label(name)?;
75
76    // Try host map first, then direct npub
77    let npub_owned;
78    let npub = if let Some(mapped) = hosts.lookup_npub(label) {
79        npub_owned = mapped.to_string();
80        &npub_owned
81    } else {
82        label
83    };
84
85    let peer = PeerIdentity::from_npub(npub).ok()?;
86    let ipv6 = peer.address().to_ipv6();
87    let node_addr = *peer.node_addr();
88    let pubkey = peer.pubkey_full();
89
90    Some((ipv6, node_addr, pubkey))
91}
92
93/// Handle a raw DNS query packet and produce a response.
94///
95/// Returns the response bytes and an optional resolved identity (for AAAA queries
96/// that successfully resolved a `.fips` name). The host map is consulted first
97/// for hostname resolution before falling back to direct npub resolution.
98pub fn handle_dns_packet(
99    query_bytes: &[u8],
100    ttl: u32,
101    hosts: &HostMap,
102) -> Option<(Vec<u8>, Option<DnsResolvedIdentity>)> {
103    let query = Packet::parse(query_bytes).ok()?;
104    let question = query.questions.first()?;
105
106    let qname = question.qname.to_string();
107    let is_aaaa = matches!(question.qtype, QTYPE::TYPE(TYPE::AAAA));
108
109    let mut response = query.into_reply();
110    response.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
111
112    if is_aaaa && let Some((ipv6, node_addr, pubkey)) = resolve_fips_query_with_hosts(&qname, hosts)
113    {
114        let name = Name::new_unchecked(&qname).into_owned();
115        let record = ResourceRecord::new(name, CLASS::IN, ttl, RData::AAAA(AAAA::from(ipv6)));
116        response.answers.push(record);
117
118        let identity = DnsResolvedIdentity { node_addr, pubkey };
119        let bytes = response.build_bytes_vec_compressed().ok()?;
120        return Some((bytes, Some(identity)));
121    }
122
123    // Non-AAAA query (e.g. A) for a resolvable .fips name: return NOERROR
124    // with empty answers.  NXDOMAIN would tell the client the name doesn't
125    // exist, causing resolvers like nslookup to give up without trying AAAA.
126    if !is_aaaa && resolve_fips_query_with_hosts(&qname, hosts).is_some() {
127        let bytes = response.build_bytes_vec_compressed().ok()?;
128        return Some((bytes, None));
129    }
130
131    // Unresolvable name: NXDOMAIN
132    *response.rcode_mut() = RCODE::NameError;
133    let bytes = response.build_bytes_vec_compressed().ok()?;
134    Some((bytes, None))
135}
136
137/// Decide whether a received DNS query should be dropped as mesh-originated.
138///
139/// A query is dropped iff we have a configured mesh interface index
140/// (`mesh_ifindex`) and the packet arrived on that interface
141/// (`arrival_ifindex`). Queries arriving on any other interface — loopback,
142/// LAN, or unknown (no PKTINFO cmsg) — are not dropped.
143///
144/// The arrival-interface check is robust regardless of source address. LAN
145/// segments using RFC 4193 ULA prefixes (`fd00::/8`, common with OpenWrt
146/// `odhcpd` and NetworkManager ULA auto-generation) would collide with the
147/// FIPS mesh prefix under a source-prefix filter; this filter is immune.
148fn is_mesh_interface_query(arrival_ifindex: Option<u32>, mesh_ifindex: Option<u32>) -> bool {
149    match (arrival_ifindex, mesh_ifindex) {
150        (Some(arrival), Some(mesh)) => arrival == mesh,
151        _ => false,
152    }
153}
154
155/// Run the DNS responder UDP server loop.
156///
157/// Listens for DNS queries, resolves `.fips` names, and sends resolved
158/// identities to the Node via the identity channel. The host map reloader
159/// checks the hosts file modification time on each request and reloads
160/// automatically when changes are detected.
161///
162/// When `mesh_ifindex` is `Some`, queries arriving on that interface are
163/// dropped silently. This closes the fips0-exposure side-channel created
164/// by the `::` bind: mesh peers can reach the listener over fips0 and
165/// probe `/etc/fips/hosts` aliases via dictionary attack. The check
166/// requires `IPV6_RECVPKTINFO` to be enabled on the socket (done in
167/// `Node::bind_dns_socket`); if it is not, arrival ifindex is unknown
168/// and no filter is applied.
169pub async fn run_dns_responder(
170    socket: tokio::net::UdpSocket,
171    identity_tx: DnsIdentityTx,
172    ttl: u32,
173    mut reloader: HostMapReloader,
174    mesh_ifindex: Option<u32>,
175) {
176    let mut buf = [0u8; 512]; // Standard DNS UDP max
177
178    loop {
179        let (len, src, arrival_ifindex) = match recv_with_pktinfo(&socket, &mut buf).await {
180            Ok(result) => result,
181            Err(e) => {
182                warn!(error = %e, "DNS socket recv error");
183                continue;
184            }
185        };
186
187        if is_mesh_interface_query(arrival_ifindex, mesh_ifindex) {
188            trace!(
189                src = %src,
190                ifindex = ?arrival_ifindex,
191                "DNS query arrived on mesh interface, dropping"
192            );
193            continue;
194        }
195
196        let query_bytes = &buf[..len];
197
198        // Check for hosts file changes on each request (cheap stat call)
199        reloader.check_reload();
200
201        match handle_dns_packet(query_bytes, ttl, reloader.hosts()) {
202            Some((response_bytes, identity)) => {
203                if let Some(id) = identity {
204                    debug!(
205                        node_addr = %id.node_addr,
206                        "DNS resolved .fips name, registering identity"
207                    );
208                    let _ = identity_tx.send(id).await;
209                }
210
211                if let Err(e) = socket.send_to(&response_bytes, src).await {
212                    debug!(error = %e, "DNS send error");
213                }
214            }
215            None => {
216                debug!(len, "Failed to parse DNS query, dropping");
217            }
218        }
219    }
220}
221
222/// Receive a UDP datagram with arrival-interface info via `IPV6_PKTINFO`.
223///
224/// Returns `(len, src, arrival_ifindex)`. The ifindex is `Some` when the
225/// kernel delivered an `IPV6_PKTINFO` control message; `None` otherwise
226/// (IPv4 arrival on a dual-stack socket without `IP_PKTINFO` set, or
227/// `IPV6_RECVPKTINFO` not enabled). A `None` ifindex disables filtering
228/// for that packet — fail-open on unknown arrival.
229#[cfg(unix)]
230async fn recv_with_pktinfo(
231    socket: &tokio::net::UdpSocket,
232    buf: &mut [u8],
233) -> std::io::Result<(usize, std::net::SocketAddr, Option<u32>)> {
234    use std::os::fd::AsRawFd;
235    loop {
236        socket.readable().await?;
237        let fd = socket.as_raw_fd();
238        match socket.try_io(tokio::io::Interest::READABLE, || {
239            recvmsg_with_pktinfo(fd, buf)
240        }) {
241            Ok(result) => return Ok(result),
242            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
243            Err(e) => return Err(e),
244        }
245    }
246}
247
248#[cfg(not(unix))]
249async fn recv_with_pktinfo(
250    socket: &tokio::net::UdpSocket,
251    buf: &mut [u8],
252) -> std::io::Result<(usize, std::net::SocketAddr, Option<u32>)> {
253    let (len, src) = socket.recv_from(buf).await?;
254    Ok((len, src, None))
255}
256
257/// Blocking `recvmsg` wrapper that extracts `IPV6_PKTINFO` ifindex.
258///
259/// Returns `Err(WouldBlock)` when the socket has no data (caller should
260/// await readability again).
261#[cfg(unix)]
262fn recvmsg_with_pktinfo(
263    fd: std::os::fd::RawFd,
264    buf: &mut [u8],
265) -> std::io::Result<(usize, std::net::SocketAddr, Option<u32>)> {
266    let mut iov = libc::iovec {
267        iov_base: buf.as_mut_ptr() as *mut _,
268        iov_len: buf.len(),
269    };
270
271    let mut src_store: libc::sockaddr_storage = unsafe { std::mem::zeroed() };
272    // 128 bytes is ample: IPV6_PKTINFO cmsg is ~36 bytes aligned.
273    let mut cmsg_buf = [0u8; 128];
274
275    let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
276    msg.msg_name = &mut src_store as *mut _ as *mut _;
277    msg.msg_namelen = std::mem::size_of::<libc::sockaddr_storage>() as u32;
278    msg.msg_iov = &mut iov;
279    msg.msg_iovlen = 1;
280    msg.msg_control = cmsg_buf.as_mut_ptr() as *mut _;
281    msg.msg_controllen = cmsg_buf.len() as _;
282
283    let n = unsafe { libc::recvmsg(fd, &mut msg, libc::MSG_DONTWAIT) };
284    if n < 0 {
285        return Err(std::io::Error::last_os_error());
286    }
287    let n = n as usize;
288
289    let src = sockaddr_storage_to_socket_addr(&src_store, msg.msg_namelen)?;
290    let ifindex = extract_pktinfo_ifindex(&msg);
291
292    Ok((n, src, ifindex))
293}
294
295/// Walk the cmsg chain and return the `IPV6_PKTINFO` ifindex, if present.
296#[cfg(unix)]
297fn extract_pktinfo_ifindex(msg: &libc::msghdr) -> Option<u32> {
298    let mut cmsg_ptr = unsafe { libc::CMSG_FIRSTHDR(msg) };
299    while !cmsg_ptr.is_null() {
300        let cmsg = unsafe { &*cmsg_ptr };
301        if cmsg.cmsg_level == libc::IPPROTO_IPV6 && cmsg.cmsg_type == libc::IPV6_PKTINFO {
302            let data_ptr = unsafe { libc::CMSG_DATA(cmsg_ptr) } as *const libc::in6_pktinfo;
303            let pktinfo: libc::in6_pktinfo = unsafe { std::ptr::read_unaligned(data_ptr) };
304            return Some(pktinfo.ipi6_ifindex as u32);
305        }
306        cmsg_ptr = unsafe { libc::CMSG_NXTHDR(msg, cmsg_ptr) };
307    }
308    None
309}
310
311/// Convert a populated `sockaddr_storage` to `SocketAddr`.
312#[cfg(unix)]
313fn sockaddr_storage_to_socket_addr(
314    storage: &libc::sockaddr_storage,
315    len: libc::socklen_t,
316) -> std::io::Result<std::net::SocketAddr> {
317    match storage.ss_family as i32 {
318        libc::AF_INET => {
319            if (len as usize) < std::mem::size_of::<libc::sockaddr_in>() {
320                return Err(std::io::Error::new(
321                    std::io::ErrorKind::InvalidData,
322                    "sockaddr_in too small",
323                ));
324            }
325            let sin = unsafe { &*(storage as *const _ as *const libc::sockaddr_in) };
326            let ip = std::net::Ipv4Addr::from(u32::from_be(sin.sin_addr.s_addr));
327            let port = u16::from_be(sin.sin_port);
328            Ok(std::net::SocketAddr::V4(std::net::SocketAddrV4::new(
329                ip, port,
330            )))
331        }
332        libc::AF_INET6 => {
333            if (len as usize) < std::mem::size_of::<libc::sockaddr_in6>() {
334                return Err(std::io::Error::new(
335                    std::io::ErrorKind::InvalidData,
336                    "sockaddr_in6 too small",
337                ));
338            }
339            let sin6 = unsafe { &*(storage as *const _ as *const libc::sockaddr_in6) };
340            let ip = std::net::Ipv6Addr::from(sin6.sin6_addr.s6_addr);
341            let port = u16::from_be(sin6.sin6_port);
342            Ok(std::net::SocketAddr::V6(std::net::SocketAddrV6::new(
343                ip,
344                port,
345                sin6.sin6_flowinfo,
346                sin6.sin6_scope_id,
347            )))
348        }
349        af => Err(std::io::Error::new(
350            std::io::ErrorKind::InvalidData,
351            format!("unexpected address family: {}", af),
352        )),
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::Identity;
360
361    #[test]
362    fn test_resolve_valid_npub() {
363        let identity = Identity::generate();
364        let npub = identity.npub();
365        let expected_ipv6 = identity.address().to_ipv6();
366
367        let query = format!("{}.fips", npub);
368        let result = resolve_fips_query(&query);
369
370        assert!(result.is_some(), "should resolve valid npub.fips");
371        let (ipv6, node_addr, _pubkey) = result.unwrap();
372        assert_eq!(ipv6, expected_ipv6);
373        assert_eq!(node_addr, *identity.node_addr());
374    }
375
376    #[test]
377    fn test_resolve_trailing_dot() {
378        let identity = Identity::generate();
379        let npub = identity.npub();
380        let expected_ipv6 = identity.address().to_ipv6();
381
382        let query = format!("{}.fips.", npub);
383        let result = resolve_fips_query(&query);
384
385        assert!(result.is_some(), "should handle trailing dot");
386        let (ipv6, _, _) = result.unwrap();
387        assert_eq!(ipv6, expected_ipv6);
388    }
389
390    #[test]
391    fn test_resolve_case_insensitive() {
392        let identity = Identity::generate();
393        let npub = identity.npub();
394
395        // .FIPS
396        let result = resolve_fips_query(&format!("{}.FIPS", npub));
397        assert!(result.is_some(), "should handle .FIPS");
398
399        // .Fips
400        let result = resolve_fips_query(&format!("{}.Fips", npub));
401        assert!(result.is_some(), "should handle .Fips");
402    }
403
404    #[test]
405    fn test_resolve_invalid_npub() {
406        let result = resolve_fips_query("not-a-valid-npub.fips");
407        assert!(result.is_none());
408    }
409
410    #[test]
411    fn test_resolve_wrong_suffix() {
412        let identity = Identity::generate();
413        let npub = identity.npub();
414
415        let result = resolve_fips_query(&format!("{}.com", npub));
416        assert!(result.is_none());
417    }
418
419    #[test]
420    fn test_resolve_empty_name() {
421        assert!(resolve_fips_query("").is_none());
422        assert!(resolve_fips_query(".fips").is_none());
423        assert!(resolve_fips_query("fips").is_none());
424    }
425
426    // --- resolve_fips_query_with_hosts tests ---
427
428    #[test]
429    fn test_resolve_hostname_via_hosts() {
430        let identity = Identity::generate();
431        let expected_ipv6 = identity.address().to_ipv6();
432
433        let mut hosts = HostMap::new();
434        hosts.insert("gateway", &identity.npub()).unwrap();
435
436        let result = resolve_fips_query_with_hosts("gateway.fips", &hosts);
437        assert!(result.is_some(), "should resolve hostname via host map");
438        let (ipv6, node_addr, _) = result.unwrap();
439        assert_eq!(ipv6, expected_ipv6);
440        assert_eq!(node_addr, *identity.node_addr());
441    }
442
443    #[test]
444    fn test_resolve_hostname_case_insensitive() {
445        let identity = Identity::generate();
446
447        let mut hosts = HostMap::new();
448        hosts.insert("gateway", &identity.npub()).unwrap();
449
450        assert!(resolve_fips_query_with_hosts("Gateway.FIPS", &hosts).is_some());
451        assert!(resolve_fips_query_with_hosts("GATEWAY.fips", &hosts).is_some());
452    }
453
454    #[test]
455    fn test_resolve_hostname_trailing_dot() {
456        let identity = Identity::generate();
457
458        let mut hosts = HostMap::new();
459        hosts.insert("gateway", &identity.npub()).unwrap();
460
461        assert!(resolve_fips_query_with_hosts("gateway.fips.", &hosts).is_some());
462    }
463
464    #[test]
465    fn test_resolve_npub_with_empty_hosts() {
466        let identity = Identity::generate();
467        let expected_ipv6 = identity.address().to_ipv6();
468        let hosts = HostMap::new();
469
470        let query = format!("{}.fips", identity.npub());
471        let result = resolve_fips_query_with_hosts(&query, &hosts);
472        assert!(result.is_some(), "should fall through to npub resolution");
473        let (ipv6, _, _) = result.unwrap();
474        assert_eq!(ipv6, expected_ipv6);
475    }
476
477    #[test]
478    fn test_resolve_unknown_hostname_returns_none() {
479        let hosts = HostMap::new();
480        assert!(resolve_fips_query_with_hosts("unknown.fips", &hosts).is_none());
481    }
482
483    // --- handle_dns_packet tests ---
484
485    #[test]
486    fn test_handle_aaaa_query() {
487        let identity = Identity::generate();
488        let npub = identity.npub();
489        let expected_ipv6 = identity.address().to_ipv6();
490        let hosts = HostMap::new();
491
492        let query_name = format!("{}.fips", npub);
493        let query_packet = build_test_query(&query_name, TYPE::AAAA);
494
495        let result = handle_dns_packet(&query_packet, 300, &hosts);
496        assert!(result.is_some(), "should handle AAAA query");
497
498        let (response_bytes, identity_opt) = result.unwrap();
499        assert!(identity_opt.is_some(), "should produce identity");
500
501        let response = Packet::parse(&response_bytes).unwrap();
502        assert_eq!(response.answers.len(), 1);
503
504        if let RData::AAAA(aaaa) = &response.answers[0].rdata {
505            let addr = Ipv6Addr::from(aaaa.address);
506            assert_eq!(addr, expected_ipv6);
507        } else {
508            panic!("expected AAAA record");
509        }
510    }
511
512    #[test]
513    fn test_handle_aaaa_query_hostname() {
514        let identity = Identity::generate();
515        let expected_ipv6 = identity.address().to_ipv6();
516
517        let mut hosts = HostMap::new();
518        hosts.insert("gateway", &identity.npub()).unwrap();
519
520        let query_packet = build_test_query("gateway.fips", TYPE::AAAA);
521
522        let result = handle_dns_packet(&query_packet, 300, &hosts);
523        assert!(result.is_some(), "should handle hostname AAAA query");
524
525        let (response_bytes, identity_opt) = result.unwrap();
526        assert!(
527            identity_opt.is_some(),
528            "should produce identity for hostname"
529        );
530
531        let response = Packet::parse(&response_bytes).unwrap();
532        assert_eq!(response.answers.len(), 1);
533
534        if let RData::AAAA(aaaa) = &response.answers[0].rdata {
535            assert_eq!(Ipv6Addr::from(aaaa.address), expected_ipv6);
536        } else {
537            panic!("expected AAAA record");
538        }
539    }
540
541    #[test]
542    fn test_handle_nxdomain_for_unknown() {
543        let hosts = HostMap::new();
544        let query_packet = build_test_query("unknown.fips", TYPE::AAAA);
545
546        let result = handle_dns_packet(&query_packet, 300, &hosts);
547        assert!(result.is_some());
548
549        let (response_bytes, identity_opt) = result.unwrap();
550        assert!(
551            identity_opt.is_none(),
552            "should not produce identity for unknown"
553        );
554
555        let response = Packet::parse(&response_bytes).unwrap();
556        assert_eq!(response.rcode(), RCODE::NameError);
557        assert!(response.answers.is_empty());
558    }
559
560    #[test]
561    fn test_handle_non_aaaa_query() {
562        let identity = Identity::generate();
563        let hosts = HostMap::new();
564        let query_name = format!("{}.fips", identity.npub());
565        let query_packet = build_test_query(&query_name, TYPE::A);
566
567        let result = handle_dns_packet(&query_packet, 300, &hosts);
568        assert!(result.is_some());
569
570        let (response_bytes, identity_opt) = result.unwrap();
571        assert!(identity_opt.is_none(), "A query should not resolve .fips");
572
573        // Valid .fips name but unsupported record type: NOERROR with empty
574        // answers (not NXDOMAIN, which would stop resolvers from trying AAAA)
575        let response = Packet::parse(&response_bytes).unwrap();
576        assert_eq!(response.rcode(), RCODE::NoError);
577        assert!(response.answers.is_empty());
578    }
579
580    #[tokio::test]
581    async fn test_dns_responder_udp() {
582        let identity = Identity::generate();
583        let npub = identity.npub();
584        let expected_ipv6 = identity.address().to_ipv6();
585
586        // Use a nonexistent path — reloader handles missing file gracefully
587        let reloader = HostMapReloader::new(
588            HostMap::new(),
589            std::path::PathBuf::from("/nonexistent/hosts"),
590        );
591
592        // Bind responder on ephemeral port
593        let server_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
594        let server_addr = server_socket.local_addr().unwrap();
595
596        let (identity_tx, mut identity_rx) = tokio::sync::mpsc::channel(16);
597
598        // Spawn the responder
599        let responder_handle = tokio::spawn(run_dns_responder(
600            server_socket,
601            identity_tx,
602            300,
603            reloader,
604            None,
605        ));
606
607        // Send a query
608        let client_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
609        let query = build_test_query(&format!("{}.fips", npub), TYPE::AAAA);
610        client_socket.send_to(&query, server_addr).await.unwrap();
611
612        // Receive response
613        let mut buf = [0u8; 512];
614        let (len, _) = tokio::time::timeout(
615            std::time::Duration::from_secs(2),
616            client_socket.recv_from(&mut buf),
617        )
618        .await
619        .unwrap()
620        .unwrap();
621
622        let response = Packet::parse(&buf[..len]).unwrap();
623        assert_eq!(response.answers.len(), 1);
624        if let RData::AAAA(aaaa) = &response.answers[0].rdata {
625            assert_eq!(Ipv6Addr::from(aaaa.address), expected_ipv6);
626        } else {
627            panic!("expected AAAA record");
628        }
629
630        // Verify identity was sent through channel
631        let resolved = tokio::time::timeout(std::time::Duration::from_secs(1), identity_rx.recv())
632            .await
633            .unwrap()
634            .unwrap();
635        assert_eq!(resolved.node_addr, *identity.node_addr());
636
637        responder_handle.abort();
638    }
639
640    #[tokio::test]
641    async fn test_dns_responder_with_hosts() {
642        let identity = Identity::generate();
643        let expected_ipv6 = identity.address().to_ipv6();
644
645        // Write a hosts file with our test entry
646        let dir = tempfile::tempdir().unwrap();
647        let hosts_path = dir.path().join("hosts");
648        std::fs::write(&hosts_path, format!("gateway   {}\n", identity.npub())).unwrap();
649
650        let reloader = HostMapReloader::new(HostMap::new(), hosts_path);
651
652        let server_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
653        let server_addr = server_socket.local_addr().unwrap();
654
655        let (identity_tx, mut identity_rx) = tokio::sync::mpsc::channel(16);
656
657        let responder_handle = tokio::spawn(run_dns_responder(
658            server_socket,
659            identity_tx,
660            300,
661            reloader,
662            None,
663        ));
664
665        // Query by hostname instead of npub
666        let client_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
667        let query = build_test_query("gateway.fips", TYPE::AAAA);
668        client_socket.send_to(&query, server_addr).await.unwrap();
669
670        let mut buf = [0u8; 512];
671        let (len, _) = tokio::time::timeout(
672            std::time::Duration::from_secs(2),
673            client_socket.recv_from(&mut buf),
674        )
675        .await
676        .unwrap()
677        .unwrap();
678
679        let response = Packet::parse(&buf[..len]).unwrap();
680        assert_eq!(response.answers.len(), 1);
681        if let RData::AAAA(aaaa) = &response.answers[0].rdata {
682            assert_eq!(Ipv6Addr::from(aaaa.address), expected_ipv6);
683        } else {
684            panic!("expected AAAA record");
685        }
686
687        // Verify identity registration
688        let resolved = tokio::time::timeout(std::time::Duration::from_secs(1), identity_rx.recv())
689            .await
690            .unwrap()
691            .unwrap();
692        assert_eq!(resolved.node_addr, *identity.node_addr());
693
694        responder_handle.abort();
695    }
696
697    #[tokio::test]
698    async fn test_dns_responder_auto_reload() {
699        let id1 = Identity::generate();
700        let id2 = Identity::generate();
701        let expected_ipv6_2 = id2.address().to_ipv6();
702
703        // Start with hosts file containing only id1
704        let dir = tempfile::tempdir().unwrap();
705        let hosts_path = dir.path().join("hosts");
706        std::fs::write(&hosts_path, format!("gateway   {}\n", id1.npub())).unwrap();
707
708        let reloader = HostMapReloader::new(HostMap::new(), hosts_path.clone());
709
710        let server_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
711        let server_addr = server_socket.local_addr().unwrap();
712        let (identity_tx, _identity_rx) = tokio::sync::mpsc::channel(16);
713
714        let responder_handle = tokio::spawn(run_dns_responder(
715            server_socket,
716            identity_tx,
717            300,
718            reloader,
719            None,
720        ));
721
722        let client_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
723
724        // "server2" should not resolve yet
725        let query = build_test_query("server2.fips", TYPE::AAAA);
726        client_socket.send_to(&query, server_addr).await.unwrap();
727        let mut buf = [0u8; 512];
728        let (len, _) = tokio::time::timeout(
729            std::time::Duration::from_secs(2),
730            client_socket.recv_from(&mut buf),
731        )
732        .await
733        .unwrap()
734        .unwrap();
735        let response = Packet::parse(&buf[..len]).unwrap();
736        assert!(
737            response.answers.is_empty(),
738            "server2 should not resolve before reload"
739        );
740
741        // Update the hosts file to add server2
742        std::thread::sleep(std::time::Duration::from_millis(50));
743        std::fs::write(
744            &hosts_path,
745            format!("gateway   {}\nserver2   {}\n", id1.npub(), id2.npub()),
746        )
747        .unwrap();
748
749        // Next query should trigger reload — query server2 again
750        let query = build_test_query("server2.fips", TYPE::AAAA);
751        client_socket.send_to(&query, server_addr).await.unwrap();
752        let (len, _) = tokio::time::timeout(
753            std::time::Duration::from_secs(2),
754            client_socket.recv_from(&mut buf),
755        )
756        .await
757        .unwrap()
758        .unwrap();
759        let response = Packet::parse(&buf[..len]).unwrap();
760        assert_eq!(
761            response.answers.len(),
762            1,
763            "server2 should resolve after reload"
764        );
765        if let RData::AAAA(aaaa) = &response.answers[0].rdata {
766            assert_eq!(Ipv6Addr::from(aaaa.address), expected_ipv6_2);
767        } else {
768            panic!("expected AAAA record");
769        }
770
771        responder_handle.abort();
772    }
773
774    // --- mesh-interface filter tests ---
775
776    #[test]
777    fn test_is_mesh_interface_query_matching() {
778        assert!(
779            is_mesh_interface_query(Some(7), Some(7)),
780            "arrival == mesh ifindex should drop"
781        );
782    }
783
784    #[test]
785    fn test_is_mesh_interface_query_non_matching() {
786        assert!(
787            !is_mesh_interface_query(Some(1), Some(7)),
788            "lo arrival should pass when mesh is fips0"
789        );
790    }
791
792    #[test]
793    fn test_is_mesh_interface_query_no_arrival() {
794        assert!(
795            !is_mesh_interface_query(None, Some(7)),
796            "unknown arrival (no PKTINFO cmsg) should fail-open"
797        );
798    }
799
800    #[test]
801    fn test_is_mesh_interface_query_no_filter() {
802        assert!(
803            !is_mesh_interface_query(Some(7), None),
804            "unconfigured mesh ifindex disables the filter"
805        );
806    }
807
808    /// Look up loopback ifindex for tests. Returns 0 if lookup fails,
809    /// which causes the calling test to skip.
810    #[cfg(unix)]
811    fn loopback_ifindex_for_test() -> u32 {
812        let name = if cfg!(target_os = "macos") {
813            "lo0"
814        } else {
815            "lo"
816        };
817        let c = std::ffi::CString::new(name).unwrap();
818        unsafe { libc::if_nametoindex(c.as_ptr()) }
819    }
820
821    /// Build a socket bound to `[::1]:0` with `IPV6_RECVPKTINFO` enabled,
822    /// mirroring the setup done in `Node::bind_dns_socket`.
823    #[cfg(unix)]
824    fn bind_loopback_v6_with_pktinfo() -> tokio::net::UdpSocket {
825        use socket2::{Domain, Protocol, Socket, Type};
826        use std::os::fd::AsRawFd;
827        let sock = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP)).unwrap();
828        sock.set_only_v6(false).unwrap();
829        let enable: libc::c_int = 1;
830        let ret = unsafe {
831            libc::setsockopt(
832                sock.as_raw_fd(),
833                libc::IPPROTO_IPV6,
834                libc::IPV6_RECVPKTINFO,
835                &enable as *const _ as *const libc::c_void,
836                std::mem::size_of::<libc::c_int>() as libc::socklen_t,
837            )
838        };
839        assert_eq!(ret, 0, "setsockopt IPV6_RECVPKTINFO failed");
840        sock.set_nonblocking(true).unwrap();
841        let addr: std::net::SocketAddr = "[::1]:0".parse().unwrap();
842        sock.bind(&addr.into()).unwrap();
843        tokio::net::UdpSocket::from_std(sock.into()).unwrap()
844    }
845
846    #[cfg(unix)]
847    #[tokio::test]
848    async fn test_recv_with_pktinfo_returns_loopback_ifindex() {
849        let lo = loopback_ifindex_for_test();
850        if lo == 0 {
851            // Lookup failed — skip rather than misreport a problem with the
852            // filter for an environment issue.
853            return;
854        }
855
856        let server = bind_loopback_v6_with_pktinfo();
857        let server_addr = server.local_addr().unwrap();
858
859        let client = tokio::net::UdpSocket::bind("[::1]:0").await.unwrap();
860        client.send_to(b"hello", server_addr).await.unwrap();
861
862        let mut buf = [0u8; 32];
863        let (len, src, ifindex) = tokio::time::timeout(
864            std::time::Duration::from_secs(2),
865            recv_with_pktinfo(&server, &mut buf),
866        )
867        .await
868        .unwrap()
869        .unwrap();
870
871        assert_eq!(&buf[..len], b"hello");
872        assert!(src.ip().is_loopback(), "source should be loopback");
873        assert_eq!(
874            ifindex,
875            Some(lo),
876            "IPV6_PKTINFO should report loopback ifindex"
877        );
878    }
879
880    #[cfg(unix)]
881    #[tokio::test]
882    async fn test_dns_responder_drops_mesh_interface_query() {
883        let lo = loopback_ifindex_for_test();
884        if lo == 0 {
885            return;
886        }
887
888        let server_socket = bind_loopback_v6_with_pktinfo();
889        let server_addr = server_socket.local_addr().unwrap();
890
891        let reloader = HostMapReloader::new(
892            HostMap::new(),
893            std::path::PathBuf::from("/nonexistent/hosts"),
894        );
895        let (identity_tx, _identity_rx) = tokio::sync::mpsc::channel(16);
896
897        // Treat loopback as the "mesh" interface so queries from ::1 are
898        // dropped. This exercises the real filter path end-to-end without
899        // needing a TUN.
900        let responder_handle = tokio::spawn(run_dns_responder(
901            server_socket,
902            identity_tx,
903            300,
904            reloader,
905            Some(lo),
906        ));
907
908        let identity = Identity::generate();
909        let query = build_test_query(&format!("{}.fips", identity.npub()), TYPE::AAAA);
910        let client = tokio::net::UdpSocket::bind("[::1]:0").await.unwrap();
911        client.send_to(&query, server_addr).await.unwrap();
912
913        let mut buf = [0u8; 512];
914        let result = tokio::time::timeout(
915            std::time::Duration::from_millis(300),
916            client.recv_from(&mut buf),
917        )
918        .await;
919
920        assert!(
921            result.is_err(),
922            "response arrived from server ({:?}) — filter did not drop mesh-interface query",
923            result
924        );
925
926        responder_handle.abort();
927    }
928
929    /// Build a test DNS query packet for a given name and record type.
930    fn build_test_query(name: &str, rtype: TYPE) -> Vec<u8> {
931        use simple_dns::Question;
932
933        let mut packet = Packet::new_query(0x1234);
934        let question = Question::new(
935            Name::new_unchecked(name).into_owned(),
936            QTYPE::TYPE(rtype),
937            simple_dns::QCLASS::CLASS(CLASS::IN),
938            false,
939        );
940        packet.questions.push(question);
941        packet.build_bytes_vec().unwrap()
942    }
943}