From ed312ac6f2c1a93ec043c39bc6b8dc87fc628644 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Tue, 21 Apr 2026 18:17:56 -0700 Subject: [PATCH] Fix DNS resolution on Ubuntu 22 with systemd-resolved (#77) On Ubuntu 22 (systemd 249), systemd-resolved applies interface-scoped routing to per-link DNS servers. Configuring `resolvectl dns fips0 127.0.0.1:5354` caused resolved to attempt reaching 127.0.0.1 through fips0 (a TUN with only fd00::/8 routes), silently failing. The DNS responder never received queries. Newer systemd versions (250+) have explicit handling for loopback servers on non-loopback interfaces. Changes: - DNS responder default bind_addr changed from "127.0.0.1" to "::" so it listens on all interfaces, including fips0. Bind logic in lifecycle.rs now parses bind_addr as IpAddr and constructs a SocketAddr, handling IPv6 literal formatting. Factored into Node::bind_dns_socket with explicit IPV6_V6ONLY=0 via socket2, so IPv4 clients on 127.0.0.1:5354 still reach the responder regardless of the kernel's net.ipv6.bindv6only sysctl. - fips-dns-setup resolvectl backend now waits for fips0 to have a global IPv6 address, then configures resolved with []:5354. That address is locally delivered by the kernel regardless of which interface resolved tries to route through. The dnsmasq and NetworkManager backends still use 127.0.0.1 (they don't have the interface-scoping issue). - Dropped hardcoded `bind_addr: "127.0.0.1"` from the packaged fips.yaml (Debian + OpenWrt). The shipped config was overriding the new default. - DNS queries are only accepted from the localhost. Verified end-to-end in a privileged Ubuntu 22.04 systemd container: dig @127.0.0.53 AAAA .fips resolves cleanly through systemd-resolved. The dns-delegate backend (systemd 258+) still uses 127.0.0.1; it has not been verified whether that backend has the same routing issue. --- CHANGELOG.md | 37 +- packaging/common/fips-dns-setup | 34 +- packaging/common/fips.yaml | 5 +- .../openwrt-ipk/files/etc/fips/fips.yaml | 3 +- src/node/lifecycle.rs | 155 ++++++-- src/upper/config.rs | 8 +- src/upper/dns.rs | 356 +++++++++++++++++- 7 files changed, 538 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eee2bc..ac49649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,29 +138,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 updated. In-tree `fipstop` is adjusted to the new schema. The control socket interface is still pre-1.0 and not covered by stability guarantees - -### Security - -- Bloom filter poisoning defense. Reject inbound FilterAnnounce - messages whose false-positive rate exceeds a configurable cap - (`node.bloom.max_inbound_fpr`, default 0.05). Previously a peer - running a modified build could send an all-ones filter, causing - (1) lookup attraction / black-hole routing for unknown targets, - (2) aggregation contamination as the poisoned bits propagated one - hop per announce tick via strict-OR merging, and (3) mesh-size - estimate blowup to `f64::INFINITY`. Rejection is silent on the - wire; rejected announces log at WARN and increment a new - `bloom.fill_exceeded` counter. The peer's prior accepted filter - and sequence number are preserved on rejection so a single bad - announce cannot wipe a peer's contribution to aggregation. - An independent self-plausibility WARN fires (rate-limited to once - per 60s) if our own outgoing filter ever exceeds the cap, - surfacing aggregation drift or ingress-check bypasses. - `BloomFilter::estimated_count` now returns `Option` and - returns `None` for saturated filters, preventing `f64::INFINITY` - from propagating into mesh-size estimates. The node-level - `estimated_mesh_size` field (already `Option`) propagates - `None` when any contributing filter is above cap. +- Validate bloom filter fill ratio on FilterAnnounce ingress. + Inbound FilterAnnounce messages whose derived false-positive + rate exceeds `node.bloom.max_inbound_fpr` (new config field, + default 0.05) are rejected silently on the wire, logged at WARN, + and counted in a new `bloom.fill_exceeded` counter. A + rate-limited WARN also fires if our own outgoing filter's FPR + exceeds the cap. `BloomFilter::estimated_count` now takes + `max_fpr` and returns `Option`, returning `None` for + saturated filters; this propagates through `compute_mesh_size` + into `estimated_mesh_size` (already `Option`) ### Fixed @@ -209,6 +196,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 tree specification. The receive path now verifies that the ancestry is structurally consistent with the signed parent declaration before mutating tree state. +- Fix DNS resolution on Ubuntu 22 with systemd-resolved. The DNS + responder now binds `::` (dual-stack) instead of `127.0.0.1` so + systemd-resolved's interface-scoped routing via fips0 reaches + it. DNS queries are accepted only from the localhost. ## [0.2.0] - 2026-03-22 diff --git a/packaging/common/fips-dns-setup b/packaging/common/fips-dns-setup index a7fd26c..2e8c059 100755 --- a/packaging/common/fips-dns-setup +++ b/packaging/common/fips-dns-setup @@ -43,6 +43,22 @@ wait_for_interface() { return 1 } +# Wait for fips0 to have a global IPv6 address assigned (up to 30s). +# Returns the address on stdout, or empty string if timeout. +wait_for_fips_addr() { + for i in $(seq 1 30); do + local addr + addr=$(ip -6 -o addr show dev "$FIPS_INTERFACE" scope global 2>/dev/null \ + | awk '{print $4}' | cut -d/ -f1 | head -1) + if [ -n "$addr" ]; then + echo "$addr" + return 0 + fi + sleep 1 + done + return 1 +} + # Get systemd version number. systemd_version() { systemctl --version 2>/dev/null | head -1 | grep -oE '[0-9]+' | head -1 @@ -74,12 +90,26 @@ EOF } # Backend 2: resolvectl +# +# systemd-resolved applies interface-scoped routing to per-link DNS servers. +# On Ubuntu 22 (systemd 249), configuring `resolvectl dns fips0 127.0.0.1:5354` +# causes resolved to route the DNS query through fips0 and fail (127.0.0.1 is +# not reachable via a TUN with only fd00::/8 routes). We must use fips0's own +# global IPv6 address, which is locally delivered by the kernel regardless of +# which interface resolved tries to route through. try_resolvectl() { command -v resolvectl >/dev/null 2>&1 || return 1 is_active systemd-resolved.service || return 1 - log "Configuring via resolvectl" - resolvectl dns "$FIPS_INTERFACE" "${FIPS_DNS_ADDR}:${FIPS_DNS_PORT}" + local fips_addr + fips_addr=$(wait_for_fips_addr) + if [ -z "$fips_addr" ]; then + log "ERROR: $FIPS_INTERFACE has no global IPv6 address after 30s" + return 1 + fi + + log "Configuring via resolvectl (DNS=[${fips_addr}]:${FIPS_DNS_PORT})" + resolvectl dns "$FIPS_INTERFACE" "[${fips_addr}]:${FIPS_DNS_PORT}" resolvectl domain "$FIPS_INTERFACE" "~fips" save_backend "resolvectl" return 0 diff --git a/packaging/common/fips.yaml b/packaging/common/fips.yaml index 00ae684..8b3ce89 100644 --- a/packaging/common/fips.yaml +++ b/packaging/common/fips.yaml @@ -18,7 +18,10 @@ tun: dns: enabled: true - bind_addr: "127.0.0.1" + # bind_addr defaults to "::" (all interfaces, dual-stack). Required so + # systemd-resolved can reach the responder via fips0 when resolvectl + # configures per-interface DNS forwarding. + # bind_addr: "::" port: 5354 transports: diff --git a/packaging/openwrt-ipk/files/etc/fips/fips.yaml b/packaging/openwrt-ipk/files/etc/fips/fips.yaml index cfccbcb..bed6214 100644 --- a/packaging/openwrt-ipk/files/etc/fips/fips.yaml +++ b/packaging/openwrt-ipk/files/etc/fips/fips.yaml @@ -23,7 +23,8 @@ tun: dns: enabled: true - bind_addr: "127.0.0.1" + # bind_addr defaults to "::" (all interfaces, dual-stack). + # bind_addr: "::" port: 5354 transports: diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index 600c8e0..2a9161c 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -688,32 +688,65 @@ impl Node { } } - // Initialize DNS responder (independent of TUN) + // Initialize DNS responder (independent of TUN). + // + // The default bind_addr is "::" (all interfaces, dual-stack). This + // matters on Ubuntu 22 (systemd 249): systemd-resolved applies + // interface-scoped routing to per-link DNS servers — when resolvectl + // points fips0 at an address, resolved tries to reach it through + // fips0. Binding to "::" ensures the responder is reachable via fips0 + // as well as loopback (v4 and v6). `IPV6_V6ONLY=0` is set explicitly + // so IPv4 clients on 127.0.0.1 still reach us regardless of kernel + // sysctl defaults. if self.config.dns.enabled { - let bind = format!("{}:{}", self.config.dns.bind_addr(), self.config.dns.port()); - match tokio::net::UdpSocket::bind(&bind).await { - Ok(socket) => { - let dns_channel_size = self.config.node.buffers.dns_channel; - let (identity_tx, identity_rx) = tokio::sync::mpsc::channel(dns_channel_size); - let dns_ttl = self.config.dns.ttl(); - let base_hosts = - crate::upper::hosts::HostMap::from_peer_configs(self.config.peers()); - let hosts_path = - std::path::PathBuf::from(crate::upper::hosts::DEFAULT_HOSTS_PATH); - let reloader = - crate::upper::hosts::HostMapReloader::new(base_hosts, hosts_path); - info!(bind = %bind, hosts = reloader.hosts().len(), "DNS responder started for .fips domain (auto-reload enabled)"); - let handle = tokio::spawn(crate::upper::dns::run_dns_responder( - socket, - identity_tx, - dns_ttl, - reloader, - )); - self.dns_identity_rx = Some(identity_rx); - self.dns_task = Some(handle); + let addr_str = self.config.dns.bind_addr(); + match addr_str.parse::() { + Ok(ip) => { + let bind = std::net::SocketAddr::new(ip, self.config.dns.port()); + match Self::bind_dns_socket(bind) { + Ok(socket) => { + let dns_channel_size = self.config.node.buffers.dns_channel; + let (identity_tx, identity_rx) = + tokio::sync::mpsc::channel(dns_channel_size); + let dns_ttl = self.config.dns.ttl(); + let base_hosts = crate::upper::hosts::HostMap::from_peer_configs( + self.config.peers(), + ); + let hosts_path = + std::path::PathBuf::from(crate::upper::hosts::DEFAULT_HOSTS_PATH); + let reloader = + crate::upper::hosts::HostMapReloader::new(base_hosts, hosts_path); + // Resolve the TUN ifindex so the responder can + // drop queries arriving on the mesh interface + // (fips0). Without this, the `::` bind exposes + // /etc/fips/hosts alias probing to any mesh peer. + // When TUN isn't enabled or the name can't be + // resolved, `None` disables the filter (there + // is no mesh surface to defend anyway). + let mesh_ifindex = Self::lookup_mesh_ifindex(self.config.tun.name()); + info!( + bind = %bind, + hosts = reloader.hosts().len(), + mesh_ifindex = ?mesh_ifindex, + "DNS responder started for .fips domain (auto-reload enabled)" + ); + let handle = tokio::spawn(crate::upper::dns::run_dns_responder( + socket, + identity_tx, + dns_ttl, + reloader, + mesh_ifindex, + )); + self.dns_identity_rx = Some(identity_rx); + self.dns_task = Some(handle); + } + Err(e) => { + warn!(bind = %bind, error = %e, "Failed to start DNS responder"); + } + } } Err(e) => { - warn!(bind = %bind, error = %e, "Failed to start DNS responder"); + warn!(addr = %addr_str, error = %e, "Invalid dns.bind_addr; DNS responder not started"); } } } @@ -726,6 +759,82 @@ impl Node { Ok(()) } + /// Bind a UDP socket for the DNS responder. + /// + /// For IPv6 binds (including `::`), sets `IPV6_V6ONLY=0` so the socket + /// also accepts IPv4-mapped addresses. This guarantees dual-stack + /// delivery regardless of `net.ipv6.bindv6only` sysctl on the host — + /// v4 clients on 127.0.0.1 and v6 clients on the fips0 address both + /// land on the same socket. + /// + /// Also enables `IPV6_RECVPKTINFO` on IPv6 sockets so the responder + /// can learn the arrival interface per packet. The responder uses that + /// to drop queries arriving on the mesh TUN, closing the hosts-file + /// probing side-channel created by the `::` bind. + fn bind_dns_socket( + addr: std::net::SocketAddr, + ) -> Result { + use socket2::{Domain, Protocol, Socket, Type}; + let domain = if addr.is_ipv4() { + Domain::IPV4 + } else { + Domain::IPV6 + }; + let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?; + if addr.is_ipv6() { + sock.set_only_v6(false)?; + #[cfg(unix)] + Self::set_recv_pktinfo_v6(&sock)?; + } + sock.set_nonblocking(true)?; + sock.bind(&addr.into())?; + tokio::net::UdpSocket::from_std(sock.into()) + } + + /// Enable `IPV6_RECVPKTINFO` on an IPv6 UDP socket. + /// + /// After this setsockopt, each `recvmsg()` call on the socket receives + /// an `IPV6_PKTINFO` control message containing the arrival interface + /// index, which the DNS responder uses for its mesh-interface filter. + #[cfg(unix)] + fn set_recv_pktinfo_v6(sock: &socket2::Socket) -> Result<(), std::io::Error> { + use std::os::fd::AsRawFd; + let enable: libc::c_int = 1; + let ret = unsafe { + libc::setsockopt( + sock.as_raw_fd(), + libc::IPPROTO_IPV6, + libc::IPV6_RECVPKTINFO, + &enable as *const _ as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if ret < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + } + + /// Resolve the mesh TUN interface index by name. + /// + /// Returns `None` if the interface does not exist (e.g. TUN disabled + /// or not yet created). A `None` result disables the DNS responder's + /// mesh-interface filter — safe, because if there is no fips0 there + /// is no mesh exposure to defend against. + fn lookup_mesh_ifindex(name: &str) -> Option { + #[cfg(unix)] + { + let c_name = std::ffi::CString::new(name).ok()?; + let idx = unsafe { libc::if_nametoindex(c_name.as_ptr()) }; + if idx == 0 { None } else { Some(idx) } + } + #[cfg(not(unix))] + { + let _ = name; + None + } + } + /// Stop the node. /// /// Shuts down TUN interface, stops I/O threads, and transitions to diff --git a/src/upper/config.rs b/src/upper/config.rs index c1e77aa..2d2c7f2 100644 --- a/src/upper/config.rs +++ b/src/upper/config.rs @@ -12,7 +12,11 @@ const DEFAULT_TUN_NAME: &str = "fips0"; const DEFAULT_TUN_MTU: u16 = 1280; /// Default DNS responder bind address. -const DEFAULT_DNS_BIND_ADDR: &str = "127.0.0.1"; +/// +/// Binds to all interfaces so systemd-resolved (configured per-interface +/// on fips0) can reach the responder regardless of interface scoping. +/// See `packaging/common/fips-dns-setup` for how resolvectl is configured. +const DEFAULT_DNS_BIND_ADDR: &str = "::"; /// Default DNS responder port. const DEFAULT_DNS_PORT: u16 = 5354; @@ -56,7 +60,7 @@ impl Default for DnsConfig { } impl DnsConfig { - /// Get the bind address (default: 127.0.0.1). + /// Get the bind address (default: `::`, all interfaces). pub fn bind_addr(&self) -> &str { self.bind_addr.as_deref().unwrap_or(DEFAULT_DNS_BIND_ADDR) } diff --git a/src/upper/dns.rs b/src/upper/dns.rs index bdb2ff9..e29d866 100644 --- a/src/upper/dns.rs +++ b/src/upper/dns.rs @@ -15,7 +15,7 @@ use crate::{NodeAddr, PeerIdentity}; use simple_dns::rdata::{AAAA, RData}; use simple_dns::{CLASS, Name, Packet, PacketFlag, QTYPE, RCODE, ResourceRecord, TYPE}; use std::net::Ipv6Addr; -use tracing::{debug, warn}; +use tracing::{debug, trace, warn}; /// Identity resolved by the DNS responder, sent to Node for cache population. pub struct DnsResolvedIdentity { @@ -134,22 +134,49 @@ pub fn handle_dns_packet( Some((bytes, None)) } +/// Decide whether a received DNS query should be dropped as mesh-originated. +/// +/// A query is dropped iff we have a configured mesh interface index +/// (`mesh_ifindex`) and the packet arrived on that interface +/// (`arrival_ifindex`). Queries arriving on any other interface — loopback, +/// LAN, or unknown (no PKTINFO cmsg) — are not dropped. +/// +/// The arrival-interface check is robust regardless of source address. LAN +/// segments using RFC 4193 ULA prefixes (`fd00::/8`, common with OpenWrt +/// `odhcpd` and NetworkManager ULA auto-generation) would collide with the +/// FIPS mesh prefix under a source-prefix filter; this filter is immune. +fn is_mesh_interface_query(arrival_ifindex: Option, mesh_ifindex: Option) -> bool { + match (arrival_ifindex, mesh_ifindex) { + (Some(arrival), Some(mesh)) => arrival == mesh, + _ => false, + } +} + /// Run the DNS responder UDP server loop. /// /// Listens for DNS queries, resolves `.fips` names, and sends resolved /// identities to the Node via the identity channel. The host map reloader /// checks the hosts file modification time on each request and reloads /// automatically when changes are detected. +/// +/// When `mesh_ifindex` is `Some`, queries arriving on that interface are +/// dropped silently. This closes the fips0-exposure side-channel created +/// by the `::` bind: mesh peers can reach the listener over fips0 and +/// probe `/etc/fips/hosts` aliases via dictionary attack. The check +/// requires `IPV6_RECVPKTINFO` to be enabled on the socket (done in +/// `Node::bind_dns_socket`); if it is not, arrival ifindex is unknown +/// and no filter is applied. pub async fn run_dns_responder( socket: tokio::net::UdpSocket, identity_tx: DnsIdentityTx, ttl: u32, mut reloader: HostMapReloader, + mesh_ifindex: Option, ) { let mut buf = [0u8; 512]; // Standard DNS UDP max loop { - let (len, src) = match socket.recv_from(&mut buf).await { + let (len, src, arrival_ifindex) = match recv_with_pktinfo(&socket, &mut buf).await { Ok(result) => result, Err(e) => { warn!(error = %e, "DNS socket recv error"); @@ -157,6 +184,15 @@ pub async fn run_dns_responder( } }; + if is_mesh_interface_query(arrival_ifindex, mesh_ifindex) { + trace!( + src = %src, + ifindex = ?arrival_ifindex, + "DNS query arrived on mesh interface, dropping" + ); + continue; + } + let query_bytes = &buf[..len]; // Check for hosts file changes on each request (cheap stat call) @@ -183,6 +219,140 @@ pub async fn run_dns_responder( } } +/// Receive a UDP datagram with arrival-interface info via `IPV6_PKTINFO`. +/// +/// Returns `(len, src, arrival_ifindex)`. The ifindex is `Some` when the +/// kernel delivered an `IPV6_PKTINFO` control message; `None` otherwise +/// (IPv4 arrival on a dual-stack socket without `IP_PKTINFO` set, or +/// `IPV6_RECVPKTINFO` not enabled). A `None` ifindex disables filtering +/// for that packet — fail-open on unknown arrival. +#[cfg(unix)] +async fn recv_with_pktinfo( + socket: &tokio::net::UdpSocket, + buf: &mut [u8], +) -> std::io::Result<(usize, std::net::SocketAddr, Option)> { + use std::os::fd::AsRawFd; + loop { + socket.readable().await?; + let fd = socket.as_raw_fd(); + match socket.try_io(tokio::io::Interest::READABLE, || { + recvmsg_with_pktinfo(fd, buf) + }) { + Ok(result) => return Ok(result), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(e) => return Err(e), + } + } +} + +#[cfg(not(unix))] +async fn recv_with_pktinfo( + socket: &tokio::net::UdpSocket, + buf: &mut [u8], +) -> std::io::Result<(usize, std::net::SocketAddr, Option)> { + let (len, src) = socket.recv_from(buf).await?; + Ok((len, src, None)) +} + +/// Blocking `recvmsg` wrapper that extracts `IPV6_PKTINFO` ifindex. +/// +/// Returns `Err(WouldBlock)` when the socket has no data (caller should +/// await readability again). +#[cfg(unix)] +fn recvmsg_with_pktinfo( + fd: std::os::fd::RawFd, + buf: &mut [u8], +) -> std::io::Result<(usize, std::net::SocketAddr, Option)> { + let mut iov = libc::iovec { + iov_base: buf.as_mut_ptr() as *mut _, + iov_len: buf.len(), + }; + + let mut src_store: libc::sockaddr_storage = unsafe { std::mem::zeroed() }; + // 128 bytes is ample: IPV6_PKTINFO cmsg is ~36 bytes aligned. + let mut cmsg_buf = [0u8; 128]; + + let mut msg: libc::msghdr = unsafe { std::mem::zeroed() }; + msg.msg_name = &mut src_store as *mut _ as *mut _; + msg.msg_namelen = std::mem::size_of::() as u32; + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = cmsg_buf.as_mut_ptr() as *mut _; + msg.msg_controllen = cmsg_buf.len() as _; + + let n = unsafe { libc::recvmsg(fd, &mut msg, libc::MSG_DONTWAIT) }; + if n < 0 { + return Err(std::io::Error::last_os_error()); + } + let n = n as usize; + + let src = sockaddr_storage_to_socket_addr(&src_store, msg.msg_namelen)?; + let ifindex = extract_pktinfo_ifindex(&msg); + + Ok((n, src, ifindex)) +} + +/// Walk the cmsg chain and return the `IPV6_PKTINFO` ifindex, if present. +#[cfg(unix)] +fn extract_pktinfo_ifindex(msg: &libc::msghdr) -> Option { + let mut cmsg_ptr = unsafe { libc::CMSG_FIRSTHDR(msg) }; + while !cmsg_ptr.is_null() { + let cmsg = unsafe { &*cmsg_ptr }; + if cmsg.cmsg_level == libc::IPPROTO_IPV6 && cmsg.cmsg_type == libc::IPV6_PKTINFO { + let data_ptr = unsafe { libc::CMSG_DATA(cmsg_ptr) } as *const libc::in6_pktinfo; + let pktinfo: libc::in6_pktinfo = unsafe { std::ptr::read_unaligned(data_ptr) }; + return Some(pktinfo.ipi6_ifindex); + } + cmsg_ptr = unsafe { libc::CMSG_NXTHDR(msg, cmsg_ptr) }; + } + None +} + +/// Convert a populated `sockaddr_storage` to `SocketAddr`. +#[cfg(unix)] +fn sockaddr_storage_to_socket_addr( + storage: &libc::sockaddr_storage, + len: libc::socklen_t, +) -> std::io::Result { + match storage.ss_family as i32 { + libc::AF_INET => { + if (len as usize) < std::mem::size_of::() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "sockaddr_in too small", + )); + } + let sin = unsafe { &*(storage as *const _ as *const libc::sockaddr_in) }; + let ip = std::net::Ipv4Addr::from(u32::from_be(sin.sin_addr.s_addr)); + let port = u16::from_be(sin.sin_port); + Ok(std::net::SocketAddr::V4(std::net::SocketAddrV4::new( + ip, port, + ))) + } + libc::AF_INET6 => { + if (len as usize) < std::mem::size_of::() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "sockaddr_in6 too small", + )); + } + let sin6 = unsafe { &*(storage as *const _ as *const libc::sockaddr_in6) }; + let ip = std::net::Ipv6Addr::from(sin6.sin6_addr.s6_addr); + let port = u16::from_be(sin6.sin6_port); + Ok(std::net::SocketAddr::V6(std::net::SocketAddrV6::new( + ip, + port, + sin6.sin6_flowinfo, + sin6.sin6_scope_id, + ))) + } + af => Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("unexpected address family: {}", af), + )), + } +} + #[cfg(test)] mod tests { use super::*; @@ -426,8 +596,13 @@ mod tests { let (identity_tx, mut identity_rx) = tokio::sync::mpsc::channel(16); // Spawn the responder - let responder_handle = - tokio::spawn(run_dns_responder(server_socket, identity_tx, 300, reloader)); + let responder_handle = tokio::spawn(run_dns_responder( + server_socket, + identity_tx, + 300, + reloader, + None, + )); // Send a query let client_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); @@ -479,8 +654,13 @@ mod tests { let (identity_tx, mut identity_rx) = tokio::sync::mpsc::channel(16); - let responder_handle = - tokio::spawn(run_dns_responder(server_socket, identity_tx, 300, reloader)); + let responder_handle = tokio::spawn(run_dns_responder( + server_socket, + identity_tx, + 300, + reloader, + None, + )); // Query by hostname instead of npub let client_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); @@ -531,8 +711,13 @@ mod tests { let server_addr = server_socket.local_addr().unwrap(); let (identity_tx, _identity_rx) = tokio::sync::mpsc::channel(16); - let responder_handle = - tokio::spawn(run_dns_responder(server_socket, identity_tx, 300, reloader)); + let responder_handle = tokio::spawn(run_dns_responder( + server_socket, + identity_tx, + 300, + reloader, + None, + )); let client_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); @@ -586,6 +771,161 @@ mod tests { responder_handle.abort(); } + // --- mesh-interface filter tests --- + + #[test] + fn test_is_mesh_interface_query_matching() { + assert!( + is_mesh_interface_query(Some(7), Some(7)), + "arrival == mesh ifindex should drop" + ); + } + + #[test] + fn test_is_mesh_interface_query_non_matching() { + assert!( + !is_mesh_interface_query(Some(1), Some(7)), + "lo arrival should pass when mesh is fips0" + ); + } + + #[test] + fn test_is_mesh_interface_query_no_arrival() { + assert!( + !is_mesh_interface_query(None, Some(7)), + "unknown arrival (no PKTINFO cmsg) should fail-open" + ); + } + + #[test] + fn test_is_mesh_interface_query_no_filter() { + assert!( + !is_mesh_interface_query(Some(7), None), + "unconfigured mesh ifindex disables the filter" + ); + } + + /// Look up loopback ifindex for tests. Returns 0 if lookup fails, + /// which causes the calling test to skip. + #[cfg(unix)] + fn loopback_ifindex_for_test() -> u32 { + let name = if cfg!(target_os = "macos") { + "lo0" + } else { + "lo" + }; + let c = std::ffi::CString::new(name).unwrap(); + unsafe { libc::if_nametoindex(c.as_ptr()) } + } + + /// Build a socket bound to `[::1]:0` with `IPV6_RECVPKTINFO` enabled, + /// mirroring the setup done in `Node::bind_dns_socket`. + #[cfg(unix)] + fn bind_loopback_v6_with_pktinfo() -> tokio::net::UdpSocket { + use socket2::{Domain, Protocol, Socket, Type}; + use std::os::fd::AsRawFd; + let sock = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP)).unwrap(); + sock.set_only_v6(false).unwrap(); + let enable: libc::c_int = 1; + let ret = unsafe { + libc::setsockopt( + sock.as_raw_fd(), + libc::IPPROTO_IPV6, + libc::IPV6_RECVPKTINFO, + &enable as *const _ as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; + assert_eq!(ret, 0, "setsockopt IPV6_RECVPKTINFO failed"); + sock.set_nonblocking(true).unwrap(); + let addr: std::net::SocketAddr = "[::1]:0".parse().unwrap(); + sock.bind(&addr.into()).unwrap(); + tokio::net::UdpSocket::from_std(sock.into()).unwrap() + } + + #[cfg(unix)] + #[tokio::test] + async fn test_recv_with_pktinfo_returns_loopback_ifindex() { + let lo = loopback_ifindex_for_test(); + if lo == 0 { + // Lookup failed — skip rather than misreport a problem with the + // filter for an environment issue. + return; + } + + let server = bind_loopback_v6_with_pktinfo(); + let server_addr = server.local_addr().unwrap(); + + let client = tokio::net::UdpSocket::bind("[::1]:0").await.unwrap(); + client.send_to(b"hello", server_addr).await.unwrap(); + + let mut buf = [0u8; 32]; + let (len, src, ifindex) = tokio::time::timeout( + std::time::Duration::from_secs(2), + recv_with_pktinfo(&server, &mut buf), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(&buf[..len], b"hello"); + assert!(src.ip().is_loopback(), "source should be loopback"); + assert_eq!( + ifindex, + Some(lo), + "IPV6_PKTINFO should report loopback ifindex" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_dns_responder_drops_mesh_interface_query() { + let lo = loopback_ifindex_for_test(); + if lo == 0 { + return; + } + + let server_socket = bind_loopback_v6_with_pktinfo(); + let server_addr = server_socket.local_addr().unwrap(); + + let reloader = HostMapReloader::new( + HostMap::new(), + std::path::PathBuf::from("/nonexistent/hosts"), + ); + let (identity_tx, _identity_rx) = tokio::sync::mpsc::channel(16); + + // Treat loopback as the "mesh" interface so queries from ::1 are + // dropped. This exercises the real filter path end-to-end without + // needing a TUN. + let responder_handle = tokio::spawn(run_dns_responder( + server_socket, + identity_tx, + 300, + reloader, + Some(lo), + )); + + let identity = Identity::generate(); + let query = build_test_query(&format!("{}.fips", identity.npub()), TYPE::AAAA); + let client = tokio::net::UdpSocket::bind("[::1]:0").await.unwrap(); + client.send_to(&query, server_addr).await.unwrap(); + + let mut buf = [0u8; 512]; + let result = tokio::time::timeout( + std::time::Duration::from_millis(300), + client.recv_from(&mut buf), + ) + .await; + + assert!( + result.is_err(), + "response arrived from server ({:?}) — filter did not drop mesh-interface query", + result + ); + + responder_handle.abort(); + } + /// Build a test DNS query packet for a given name and record type. fn build_test_query(name: &str, rtype: TYPE) -> Vec { use simple_dns::Question;