Files
fips/packaging/common/fips-dns-setup
T
Johnathan Corgan bf77ececad Fix DNS responder silent-drop on systemd-resolved deployments
The previous default configured systemd-resolved with `resolvectl dns
fips0 [<fips0_addr>]:5354`, intended to bypass an Ubuntu 22 systemd 249
interface-scoping bug. That target collides with the daemon's
mesh-interface filter on Linux: when an IPv6 packet's destination
belongs to a non-loopback interface, the kernel attributes the packet
to that interface in IPV6_PKTINFO (ipi6_ifindex == fips0) even though
loopback delivery is used (tcpdump shows lo). The mesh-interface filter
sees arrival_ifindex == mesh_ifindex and silently drops every query at
trace level — invisible to operators at the default debug level.

Net effect on stock deployments: every .fips query on systemd-resolved
hosts was silently dropped.

Daemon side
-----------

- Default `dns.bind_addr` changes from "::" to "::1" (IPv6 loopback
  only). The mesh-interface filter is then defanged on the default
  path because loopback isn't reachable from mesh peers. The filter
  remains in place defensively for operators who explicitly bind "::"
  to expose a mesh-reachable responder.

fips-dns-setup backend unification
----------------------------------

- New `try_global_drop_in` backend writes
  /etc/systemd/resolved.conf.d/fips.conf with DNS=[::1]:5354 and
  Domains=~fips. Inserted ahead of `try_resolvectl` in the dispatch
  chain. The standard loopback path has no interface scoping, so
  ipi6_ifindex reports lo and the filter passes.
- All other backends now target [::1]:5354 to match the daemon's
  default IPv6-loopback bind:
  - try_dns_delegate writes DNS=[::1]:5354
  - try_dnsmasq writes server=/fips/::1#5354
  - try_nm_dnsmasq writes server=/fips/::1#5354
- Fixed dns-delegate file path: was /etc/systemd/dns-delegate/, must
  be /etc/systemd/dns-delegate.d/ (with .d suffix). systemd-resolved
  silently ignored the previous path.
- fips-dns-teardown handles the new global-drop-in backend in cleanup.
- The legacy resolvectl per-link backend stays as a fallback,
  documented to require careful daemon bind_addr coordination.

fips-gateway upstream pairing
-----------------------------

- gateway.dns.upstream default changes from 127.0.0.1:5354 to
  [::1]:5354 to match the daemon's default bind. Linux IPv6 sockets
  bound to explicit ::1 do not accept v4-mapped traffic, so the old
  default would have caused the gateway's startup DNS reachability
  probe to time out and systemd to restart-loop the service.
- Operators who set a non-default daemon `dns.bind_addr` must also
  set `gateway.dns.upstream` to match — documented inline.

Documentation
-------------

- packaging/common/fips.yaml and packaging/openwrt-ipk fips.yaml
  examples updated; rationale for the bind_addr choice and the
  daemon/gateway pairing recorded inline.

Test coverage
-------------

- testing/dns-resolver/test.sh: real-fipsd end-to-end scenario added.
  Builds fipsd in a Debian 12 builder image (cached), runs the daemon
  with a real TUN in a privileged container, configures DNS via the
  setup script, and asserts `dig @127.0.0.53 AAAA <npub>.fips` returns
  AAAA. Refactored as a parameterized helper running across Debian
  12/13 and Ubuntu 22/24/26 (5 e2e scenarios). Backend-aware
  assertions: on systemd >= 258 the expected backend is dns-delegate;
  on older systemd it's global-drop-in. Strict content checks fail CI
  on any [::1]:5354 drift. fips-gateway also exercised in the
  debian12 scenario to lock the gateway-upstream pairing. Renamed all
  "fipsd" references to "fips" (project convention).

- testing/deb-install/ (new harness): builds the actual .deb via
  cargo-deb in a Debian 12 builder image (cached), installs via apt
  across each target distro, verifies maintainer scripts, conffile
  placement, binary placement, and end-to-end .fips resolution after
  start. Also exercises fips-gateway against the installed daemon to
  verify the gateway/daemon default pairing on a real .deb path.

- This is the test layer that was missing — the previous harness only
  verified config files were written, never that queries reached the
  daemon.

Verified: dns-resolver 78/78 assertions, deb-install 55/55 assertions
across all 5 distros (debian:12, debian:trixie, ubuntu:22.04,
ubuntu:24.04, ubuntu:26.04).
2026-04-29 12:50:11 +00:00

232 lines
8.2 KiB
Bash
Executable File

#!/bin/bash
# fips-dns-setup — Configure DNS routing for the .fips domain.
#
# Detects the system's DNS resolver and configures it to forward .fips
# queries to the FIPS DNS responder on 127.0.0.1:5354 (or [::1]:5354
# for the global drop-in backend).
#
# Backends (tried in order):
# 1. systemd dns-delegate (systemd >= 258, declarative drop-in)
# 2. systemd-resolved global drop-in via /etc/systemd/resolved.conf.d/
# (preferred for systemd-resolved hosts — DNS=[::1]:5354 routes
# via standard loopback with no interface scoping, sidestepping
# the IPV6_PKTINFO ifindex behaviour that causes self-destined
# traffic to fips0's address to be silently dropped by the
# mesh-interface filter)
# 3. systemd-resolved via per-link resolvectl (legacy fallback —
# historically failed silently due to the issue described above;
# kept for hosts where the global drop-in is unsuitable)
# 4. dnsmasq (standalone)
# 5. NetworkManager with dnsmasq plugin
# 6. Warning with manual instructions
set -e
FIPS_DNS_PORT="5354"
FIPS_DNS_LOOPBACK_V6="::1"
FIPS_INTERFACE="fips0"
DNS_DELEGATE_DIR="/etc/systemd/dns-delegate.d"
DNS_DELEGATE_FILE="${DNS_DELEGATE_DIR}/fips.dns-delegate"
RESOLVED_DROPIN_DIR="/etc/systemd/resolved.conf.d"
RESOLVED_DROPIN_FILE="${RESOLVED_DROPIN_DIR}/fips.conf"
DNSMASQ_CONF="/etc/dnsmasq.d/fips.conf"
NM_DNSMASQ_CONF="/etc/NetworkManager/dnsmasq.d/fips.conf"
# Record which backend was configured for teardown.
STATE_DIR="/run/fips"
STATE_FILE="${STATE_DIR}/dns-backend"
log() { echo "fips-dns: $*"; }
save_backend() {
mkdir -p "$STATE_DIR"
echo "$1" > "$STATE_FILE"
}
# Wait for fips0 interface (up to 30s).
wait_for_interface() {
for i in $(seq 1 30); do
ip link show "$FIPS_INTERFACE" >/dev/null 2>&1 && return 0
sleep 1
done
log "ERROR: $FIPS_INTERFACE interface not found after 30s"
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
}
# Check if a systemd service is active.
is_active() {
systemctl is-active --quiet "$1" 2>/dev/null
}
# Backend 1: systemd dns-delegate (>= 258)
#
# Targets [::1]:5354 to match the daemon's default IPv6 loopback
# bind. v4-targeted DNS= lines do not reach a daemon bound on
# explicit `::1` (Linux v6 sockets bound to ::1 don't accept
# v4-mapped traffic).
try_dns_delegate() {
local ver
ver=$(systemd_version)
[ -z "$ver" ] && return 1
[ "$ver" -lt 258 ] && return 1
is_active systemd-resolved.service || return 1
log "Configuring via dns-delegate (systemd $ver)"
mkdir -p "$DNS_DELEGATE_DIR"
cat > "$DNS_DELEGATE_FILE" <<EOF
[Delegate]
DNS=[${FIPS_DNS_LOOPBACK_V6}]:${FIPS_DNS_PORT}
Domains=fips
EOF
systemctl restart systemd-resolved
save_backend "dns-delegate"
return 0
}
# Backend 2: systemd-resolved global drop-in (preferred for systemd-resolved hosts)
#
# Writes /etc/systemd/resolved.conf.d/fips.conf with DNS=[::1]:5354 and
# Domains=~fips. systemd-resolved routes the .fips zone via standard
# loopback (no per-link interface scoping), which sidesteps two issues:
#
# - Ubuntu 22 (systemd 249) interface-scoped routing of per-link DNS
# servers, which would otherwise cause `resolvectl dns fips0
# 127.0.0.1:5354` to route through fips0 and fail.
# - The IPV6_PKTINFO ifindex attribution behaviour where Linux reports
# a packet to fips0's own IPv6 address as arriving on fips0 (despite
# loopback delivery), which would otherwise be silently dropped by
# the daemon's mesh-interface filter — see ISSUE-2026-0002 in the
# project tracker.
#
# This backend is the recommended path on every systemd-resolved host
# that doesn't have native dns-delegate support (systemd >= 258).
try_global_drop_in() {
is_active systemd-resolved.service || return 1
log "Configuring via global resolved.conf.d drop-in (DNS=[${FIPS_DNS_LOOPBACK_V6}]:${FIPS_DNS_PORT})"
mkdir -p "$RESOLVED_DROPIN_DIR"
cat > "$RESOLVED_DROPIN_FILE" <<EOF
# Managed by fips-dns.service (fips-dns-setup, global-drop-in backend).
# Routes .fips queries to the fips daemon's local DNS responder.
[Resolve]
DNS=[${FIPS_DNS_LOOPBACK_V6}]:${FIPS_DNS_PORT}
Domains=~fips
EOF
systemctl restart systemd-resolved \
|| log "WARNING: systemd-resolved restart failed (config written, may need manual reload)"
save_backend "global-drop-in"
return 0
}
# Backend 3: resolvectl (legacy fallback)
#
# 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). Using fips0's own global
# IPv6 address (locally delivered by the kernel) sidesteps that — but on hosts
# where the daemon binds wildcard `::`, IPV6_PKTINFO reports `ipi6_ifindex ==
# fips0` for those packets and the daemon's mesh-interface filter drops every
# query silently. The global-drop-in backend above avoids both issues; this
# resolvectl path is retained only as a fallback for environments where the
# global drop-in is unsuitable.
try_resolvectl() {
command -v resolvectl >/dev/null 2>&1 || return 1
is_active systemd-resolved.service || return 1
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
}
# Backend 4: standalone dnsmasq
#
# dnsmasq's `server=/<domain>/<addr>#<port>` syntax accepts an IPv6
# literal directly (no brackets). Targets ::1#5354 to match the
# daemon's default IPv6 loopback bind.
try_dnsmasq() {
is_active dnsmasq.service || return 1
[ -d /etc/dnsmasq.d ] || return 1
log "Configuring via dnsmasq"
cat > "$DNSMASQ_CONF" <<EOF
# FIPS .fips domain forwarding (managed by fips-dns.service)
server=/fips/${FIPS_DNS_LOOPBACK_V6}#${FIPS_DNS_PORT}
EOF
systemctl reload dnsmasq || log "WARNING: dnsmasq reload failed (config written, may need manual reload)"
save_backend "dnsmasq"
return 0
}
# Backend 5: NetworkManager with dnsmasq plugin
try_nm_dnsmasq() {
is_active NetworkManager.service || return 1
# Check if NM is configured to use dnsmasq
local nm_dns
nm_dns=$(grep -sh '^dns=' /etc/NetworkManager/NetworkManager.conf \
/etc/NetworkManager/conf.d/*.conf 2>/dev/null | tail -1 | cut -d= -f2)
[ "$nm_dns" = "dnsmasq" ] || return 1
[ -d /etc/NetworkManager/dnsmasq.d ] || return 1
log "Configuring via NetworkManager dnsmasq plugin"
cat > "$NM_DNSMASQ_CONF" <<EOF
# FIPS .fips domain forwarding (managed by fips-dns.service)
server=/fips/${FIPS_DNS_LOOPBACK_V6}#${FIPS_DNS_PORT}
EOF
nmcli general reload || log "WARNING: NetworkManager reload failed (config written, may need manual reload)"
save_backend "nm-dnsmasq"
return 0
}
# --- Main ---
wait_for_interface || exit 1
try_dns_delegate && exit 0
try_global_drop_in && exit 0
try_resolvectl && exit 0
try_dnsmasq && exit 0
try_nm_dnsmasq && exit 0
log "WARNING: No supported DNS resolver detected."
log "To resolve .fips domains, configure your DNS resolver to forward"
log "the .fips domain to [${FIPS_DNS_LOOPBACK_V6}]:${FIPS_DNS_PORT} (matches the daemon's default bind)."
log ""
log "Examples:"
log " systemd-resolved: sudo apt install systemd-resolved"
log " dnsmasq: echo 'server=/fips/${FIPS_DNS_LOOPBACK_V6}#${FIPS_DNS_PORT}' | sudo tee /etc/dnsmasq.d/fips.conf"
save_backend "none"
exit 0