mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
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:
37
CHANGELOG.md
37
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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
356
src/upper/dns.rs
356
src/upper/dns.rs
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user