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
  [<fips0-addr>]: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 <npub>.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.
This commit is contained in:
Johnathan Corgan
2026-04-21 18:17:56 -07:00
committed by GitHub
parent 03f6db58e8
commit ed312ac6f2
7 changed files with 538 additions and 60 deletions

View File

@@ -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<f64>` and
returns `None` for saturated filters, preventing `f64::INFINITY`
from propagating into mesh-size estimates. The node-level
`estimated_mesh_size` field (already `Option<u64>`) 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<f64>`, returning `None` for
saturated filters; this propagates through `compute_mesh_size`
into `estimated_mesh_size` (already `Option<u64>`)
### 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

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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::<std::net::IpAddr>() {
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<tokio::net::UdpSocket, std::io::Error> {
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::<libc::c_int>() 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<u32> {
#[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

View File

@@ -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)
}

View File

@@ -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<u32>, mesh_ifindex: Option<u32>) -> 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<u32>,
) {
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<u32>)> {
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<u32>)> {
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<u32>)> {
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::<libc::sockaddr_storage>() 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<u32> {
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<std::net::SocketAddr> {
match storage.ss_family as i32 {
libc::AF_INET => {
if (len as usize) < std::mem::size_of::<libc::sockaddr_in>() {
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::<libc::sockaddr_in6>() {
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::<libc::c_int>() 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<u8> {
use simple_dns::Question;