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 updated. In-tree `fipstop` is adjusted to the new schema. The
control socket interface is still pre-1.0 and not covered by control socket interface is still pre-1.0 and not covered by
stability guarantees stability guarantees
- Validate bloom filter fill ratio on FilterAnnounce ingress.
### Security Inbound FilterAnnounce messages whose derived false-positive
rate exceeds `node.bloom.max_inbound_fpr` (new config field,
- Bloom filter poisoning defense. Reject inbound FilterAnnounce default 0.05) are rejected silently on the wire, logged at WARN,
messages whose false-positive rate exceeds a configurable cap and counted in a new `bloom.fill_exceeded` counter. A
(`node.bloom.max_inbound_fpr`, default 0.05). Previously a peer rate-limited WARN also fires if our own outgoing filter's FPR
running a modified build could send an all-ones filter, causing exceeds the cap. `BloomFilter::estimated_count` now takes
(1) lookup attraction / black-hole routing for unknown targets, `max_fpr` and returns `Option<f64>`, returning `None` for
(2) aggregation contamination as the poisoned bits propagated one saturated filters; this propagates through `compute_mesh_size`
hop per announce tick via strict-OR merging, and (3) mesh-size into `estimated_mesh_size` (already `Option<u64>`)
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.
### Fixed ### 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 tree specification. The receive path now verifies that the
ancestry is structurally consistent with the signed parent ancestry is structurally consistent with the signed parent
declaration before mutating tree state. 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 ## [0.2.0] - 2026-03-22

View File

@@ -43,6 +43,22 @@ wait_for_interface() {
return 1 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. # Get systemd version number.
systemd_version() { systemd_version() {
systemctl --version 2>/dev/null | head -1 | grep -oE '[0-9]+' | head -1 systemctl --version 2>/dev/null | head -1 | grep -oE '[0-9]+' | head -1
@@ -74,12 +90,26 @@ EOF
} }
# Backend 2: resolvectl # 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() { try_resolvectl() {
command -v resolvectl >/dev/null 2>&1 || return 1 command -v resolvectl >/dev/null 2>&1 || return 1
is_active systemd-resolved.service || return 1 is_active systemd-resolved.service || return 1
log "Configuring via resolvectl" local fips_addr
resolvectl dns "$FIPS_INTERFACE" "${FIPS_DNS_ADDR}:${FIPS_DNS_PORT}" 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" resolvectl domain "$FIPS_INTERFACE" "~fips"
save_backend "resolvectl" save_backend "resolvectl"
return 0 return 0

View File

@@ -18,7 +18,10 @@ tun:
dns: dns:
enabled: true 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 port: 5354
transports: transports:

View File

@@ -23,7 +23,8 @@ tun:
dns: dns:
enabled: true enabled: true
bind_addr: "127.0.0.1" # bind_addr defaults to "::" (all interfaces, dual-stack).
# bind_addr: "::"
port: 5354 port: 5354
transports: 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 { if self.config.dns.enabled {
let bind = format!("{}:{}", self.config.dns.bind_addr(), self.config.dns.port()); let addr_str = self.config.dns.bind_addr();
match tokio::net::UdpSocket::bind(&bind).await { match addr_str.parse::<std::net::IpAddr>() {
Ok(socket) => { Ok(ip) => {
let dns_channel_size = self.config.node.buffers.dns_channel; let bind = std::net::SocketAddr::new(ip, self.config.dns.port());
let (identity_tx, identity_rx) = tokio::sync::mpsc::channel(dns_channel_size); match Self::bind_dns_socket(bind) {
let dns_ttl = self.config.dns.ttl(); Ok(socket) => {
let base_hosts = let dns_channel_size = self.config.node.buffers.dns_channel;
crate::upper::hosts::HostMap::from_peer_configs(self.config.peers()); let (identity_tx, identity_rx) =
let hosts_path = tokio::sync::mpsc::channel(dns_channel_size);
std::path::PathBuf::from(crate::upper::hosts::DEFAULT_HOSTS_PATH); let dns_ttl = self.config.dns.ttl();
let reloader = let base_hosts = crate::upper::hosts::HostMap::from_peer_configs(
crate::upper::hosts::HostMapReloader::new(base_hosts, hosts_path); self.config.peers(),
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( let hosts_path =
socket, std::path::PathBuf::from(crate::upper::hosts::DEFAULT_HOSTS_PATH);
identity_tx, let reloader =
dns_ttl, crate::upper::hosts::HostMapReloader::new(base_hosts, hosts_path);
reloader, // Resolve the TUN ifindex so the responder can
)); // drop queries arriving on the mesh interface
self.dns_identity_rx = Some(identity_rx); // (fips0). Without this, the `::` bind exposes
self.dns_task = Some(handle); // /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) => { 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(()) 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. /// Stop the node.
/// ///
/// Shuts down TUN interface, stops I/O threads, and transitions to /// 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; const DEFAULT_TUN_MTU: u16 = 1280;
/// Default DNS responder bind address. /// 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. /// Default DNS responder port.
const DEFAULT_DNS_PORT: u16 = 5354; const DEFAULT_DNS_PORT: u16 = 5354;
@@ -56,7 +60,7 @@ impl Default for DnsConfig {
} }
impl 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 { pub fn bind_addr(&self) -> &str {
self.bind_addr.as_deref().unwrap_or(DEFAULT_DNS_BIND_ADDR) 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::rdata::{AAAA, RData};
use simple_dns::{CLASS, Name, Packet, PacketFlag, QTYPE, RCODE, ResourceRecord, TYPE}; use simple_dns::{CLASS, Name, Packet, PacketFlag, QTYPE, RCODE, ResourceRecord, TYPE};
use std::net::Ipv6Addr; 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. /// Identity resolved by the DNS responder, sent to Node for cache population.
pub struct DnsResolvedIdentity { pub struct DnsResolvedIdentity {
@@ -134,22 +134,49 @@ pub fn handle_dns_packet(
Some((bytes, None)) 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. /// Run the DNS responder UDP server loop.
/// ///
/// Listens for DNS queries, resolves `.fips` names, and sends resolved /// Listens for DNS queries, resolves `.fips` names, and sends resolved
/// identities to the Node via the identity channel. The host map reloader /// identities to the Node via the identity channel. The host map reloader
/// checks the hosts file modification time on each request and reloads /// checks the hosts file modification time on each request and reloads
/// automatically when changes are detected. /// 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( pub async fn run_dns_responder(
socket: tokio::net::UdpSocket, socket: tokio::net::UdpSocket,
identity_tx: DnsIdentityTx, identity_tx: DnsIdentityTx,
ttl: u32, ttl: u32,
mut reloader: HostMapReloader, mut reloader: HostMapReloader,
mesh_ifindex: Option<u32>,
) { ) {
let mut buf = [0u8; 512]; // Standard DNS UDP max let mut buf = [0u8; 512]; // Standard DNS UDP max
loop { 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, Ok(result) => result,
Err(e) => { Err(e) => {
warn!(error = %e, "DNS socket recv error"); 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]; let query_bytes = &buf[..len];
// Check for hosts file changes on each request (cheap stat call) // 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -426,8 +596,13 @@ mod tests {
let (identity_tx, mut identity_rx) = tokio::sync::mpsc::channel(16); let (identity_tx, mut identity_rx) = tokio::sync::mpsc::channel(16);
// Spawn the responder // Spawn the responder
let responder_handle = let responder_handle = tokio::spawn(run_dns_responder(
tokio::spawn(run_dns_responder(server_socket, identity_tx, 300, reloader)); server_socket,
identity_tx,
300,
reloader,
None,
));
// Send a query // Send a query
let client_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); 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 (identity_tx, mut identity_rx) = tokio::sync::mpsc::channel(16);
let responder_handle = let responder_handle = tokio::spawn(run_dns_responder(
tokio::spawn(run_dns_responder(server_socket, identity_tx, 300, reloader)); server_socket,
identity_tx,
300,
reloader,
None,
));
// Query by hostname instead of npub // Query by hostname instead of npub
let client_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); 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 server_addr = server_socket.local_addr().unwrap();
let (identity_tx, _identity_rx) = tokio::sync::mpsc::channel(16); let (identity_tx, _identity_rx) = tokio::sync::mpsc::channel(16);
let responder_handle = let responder_handle = tokio::spawn(run_dns_responder(
tokio::spawn(run_dns_responder(server_socket, identity_tx, 300, reloader)); server_socket,
identity_tx,
300,
reloader,
None,
));
let client_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); let client_socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
@@ -586,6 +771,161 @@ mod tests {
responder_handle.abort(); 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. /// Build a test DNS query packet for a given name and record type.
fn build_test_query(name: &str, rtype: TYPE) -> Vec<u8> { fn build_test_query(name: &str, rtype: TYPE) -> Vec<u8> {
use simple_dns::Question; use simple_dns::Question;