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).
This commit is contained in:
Johnathan Corgan
2026-04-29 12:50:11 +00:00
parent 34e00b9f6e
commit bf77ececad
11 changed files with 1150 additions and 87 deletions
+78 -18
View File
@@ -2,23 +2,34 @@
# fips-dns-setup — Configure DNS routing for the .fips domain. # fips-dns-setup — Configure DNS routing for the .fips domain.
# #
# Detects the system's DNS resolver and configures it to forward .fips # Detects the system's DNS resolver and configures it to forward .fips
# queries to the FIPS DNS responder on 127.0.0.1:5354. # 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): # Backends (tried in order):
# 1. systemd dns-delegate (systemd >= 258, declarative drop-in) # 1. systemd dns-delegate (systemd >= 258, declarative drop-in)
# 2. systemd-resolved via resolvectl (most systemd distros) # 2. systemd-resolved global drop-in via /etc/systemd/resolved.conf.d/
# 3. dnsmasq (standalone) # (preferred for systemd-resolved hosts — DNS=[::1]:5354 routes
# 4. NetworkManager with dnsmasq plugin # via standard loopback with no interface scoping, sidestepping
# 5. Warning with manual instructions # 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 set -e
FIPS_DNS_ADDR="127.0.0.1"
FIPS_DNS_PORT="5354" FIPS_DNS_PORT="5354"
FIPS_DNS_LOOPBACK_V6="::1"
FIPS_INTERFACE="fips0" FIPS_INTERFACE="fips0"
DNS_DELEGATE_DIR="/etc/systemd/dns-delegate" DNS_DELEGATE_DIR="/etc/systemd/dns-delegate.d"
DNS_DELEGATE_FILE="${DNS_DELEGATE_DIR}/fips.dns-delegate" 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" DNSMASQ_CONF="/etc/dnsmasq.d/fips.conf"
NM_DNSMASQ_CONF="/etc/NetworkManager/dnsmasq.d/fips.conf" NM_DNSMASQ_CONF="/etc/NetworkManager/dnsmasq.d/fips.conf"
@@ -70,6 +81,11 @@ is_active() {
} }
# Backend 1: systemd dns-delegate (>= 258) # 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() { try_dns_delegate() {
local ver local ver
ver=$(systemd_version) ver=$(systemd_version)
@@ -81,7 +97,7 @@ try_dns_delegate() {
mkdir -p "$DNS_DELEGATE_DIR" mkdir -p "$DNS_DELEGATE_DIR"
cat > "$DNS_DELEGATE_FILE" <<EOF cat > "$DNS_DELEGATE_FILE" <<EOF
[Delegate] [Delegate]
DNS=${FIPS_DNS_ADDR}:${FIPS_DNS_PORT} DNS=[${FIPS_DNS_LOOPBACK_V6}]:${FIPS_DNS_PORT}
Domains=fips Domains=fips
EOF EOF
systemctl restart systemd-resolved systemctl restart systemd-resolved
@@ -89,14 +105,53 @@ EOF
return 0 return 0
} }
# Backend 2: resolvectl # 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. # 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` # 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 # 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 # not reachable via a TUN with only fd00::/8 routes). Using fips0's own global
# global IPv6 address, which is locally delivered by the kernel regardless of # IPv6 address (locally delivered by the kernel) sidesteps that — but on hosts
# which interface resolved tries to route through. # 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() { 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
@@ -115,7 +170,11 @@ try_resolvectl() {
return 0 return 0
} }
# Backend 3: standalone dnsmasq # 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() { try_dnsmasq() {
is_active dnsmasq.service || return 1 is_active dnsmasq.service || return 1
[ -d /etc/dnsmasq.d ] || return 1 [ -d /etc/dnsmasq.d ] || return 1
@@ -123,14 +182,14 @@ try_dnsmasq() {
log "Configuring via dnsmasq" log "Configuring via dnsmasq"
cat > "$DNSMASQ_CONF" <<EOF cat > "$DNSMASQ_CONF" <<EOF
# FIPS .fips domain forwarding (managed by fips-dns.service) # FIPS .fips domain forwarding (managed by fips-dns.service)
server=/fips/${FIPS_DNS_ADDR}#${FIPS_DNS_PORT} server=/fips/${FIPS_DNS_LOOPBACK_V6}#${FIPS_DNS_PORT}
EOF EOF
systemctl reload dnsmasq || log "WARNING: dnsmasq reload failed (config written, may need manual reload)" systemctl reload dnsmasq || log "WARNING: dnsmasq reload failed (config written, may need manual reload)"
save_backend "dnsmasq" save_backend "dnsmasq"
return 0 return 0
} }
# Backend 4: NetworkManager with dnsmasq plugin # Backend 5: NetworkManager with dnsmasq plugin
try_nm_dnsmasq() { try_nm_dnsmasq() {
is_active NetworkManager.service || return 1 is_active NetworkManager.service || return 1
@@ -144,7 +203,7 @@ try_nm_dnsmasq() {
log "Configuring via NetworkManager dnsmasq plugin" log "Configuring via NetworkManager dnsmasq plugin"
cat > "$NM_DNSMASQ_CONF" <<EOF cat > "$NM_DNSMASQ_CONF" <<EOF
# FIPS .fips domain forwarding (managed by fips-dns.service) # FIPS .fips domain forwarding (managed by fips-dns.service)
server=/fips/${FIPS_DNS_ADDR}#${FIPS_DNS_PORT} server=/fips/${FIPS_DNS_LOOPBACK_V6}#${FIPS_DNS_PORT}
EOF EOF
nmcli general reload || log "WARNING: NetworkManager reload failed (config written, may need manual reload)" nmcli general reload || log "WARNING: NetworkManager reload failed (config written, may need manual reload)"
save_backend "nm-dnsmasq" save_backend "nm-dnsmasq"
@@ -156,16 +215,17 @@ EOF
wait_for_interface || exit 1 wait_for_interface || exit 1
try_dns_delegate && exit 0 try_dns_delegate && exit 0
try_global_drop_in && exit 0
try_resolvectl && exit 0 try_resolvectl && exit 0
try_dnsmasq && exit 0 try_dnsmasq && exit 0
try_nm_dnsmasq && exit 0 try_nm_dnsmasq && exit 0
log "WARNING: No supported DNS resolver detected." log "WARNING: No supported DNS resolver detected."
log "To resolve .fips domains, configure your DNS resolver to forward" log "To resolve .fips domains, configure your DNS resolver to forward"
log "the .fips domain to ${FIPS_DNS_ADDR} port ${FIPS_DNS_PORT}." log "the .fips domain to [${FIPS_DNS_LOOPBACK_V6}]:${FIPS_DNS_PORT} (matches the daemon's default bind)."
log "" log ""
log "Examples:" log "Examples:"
log " systemd-resolved: sudo apt install systemd-resolved" log " systemd-resolved: sudo apt install systemd-resolved"
log " dnsmasq: echo 'server=/fips/${FIPS_DNS_ADDR}#${FIPS_DNS_PORT}' | sudo tee /etc/dnsmasq.d/fips.conf" log " dnsmasq: echo 'server=/fips/${FIPS_DNS_LOOPBACK_V6}#${FIPS_DNS_PORT}' | sudo tee /etc/dnsmasq.d/fips.conf"
save_backend "none" save_backend "none"
exit 0 exit 0
+17 -6
View File
@@ -7,7 +7,8 @@
set -e set -e
FIPS_INTERFACE="fips0" FIPS_INTERFACE="fips0"
DNS_DELEGATE_FILE="/etc/systemd/dns-delegate/fips.dns-delegate" DNS_DELEGATE_FILE="/etc/systemd/dns-delegate.d/fips.dns-delegate"
RESOLVED_DROPIN_FILE="/etc/systemd/resolved.conf.d/fips.conf"
DNSMASQ_CONF="/etc/dnsmasq.d/fips.conf" DNSMASQ_CONF="/etc/dnsmasq.d/fips.conf"
NM_DNSMASQ_CONF="/etc/NetworkManager/dnsmasq.d/fips.conf" NM_DNSMASQ_CONF="/etc/NetworkManager/dnsmasq.d/fips.conf"
STATE_FILE="/run/fips/dns-backend" STATE_FILE="/run/fips/dns-backend"
@@ -35,6 +36,14 @@ teardown_resolvectl() {
return 0 return 0
} }
teardown_global_drop_in() {
[ -f "$RESOLVED_DROPIN_FILE" ] || return 0
log "Removing global resolved.conf.d drop-in"
rm -f "$RESOLVED_DROPIN_FILE"
is_active systemd-resolved.service && systemctl restart systemd-resolved || true
return 0
}
teardown_dnsmasq() { teardown_dnsmasq() {
[ -f "$DNSMASQ_CONF" ] || return 0 [ -f "$DNSMASQ_CONF" ] || return 0
log "Removing dnsmasq config" log "Removing dnsmasq config"
@@ -59,14 +68,16 @@ if [ -f "$STATE_FILE" ]; then
fi fi
case "$backend" in case "$backend" in
dns-delegate) teardown_dns_delegate ;; dns-delegate) teardown_dns_delegate ;;
resolvectl) teardown_resolvectl ;; global-drop-in) teardown_global_drop_in ;;
dnsmasq) teardown_dnsmasq ;; resolvectl) teardown_resolvectl ;;
nm-dnsmasq) teardown_nm_dnsmasq ;; dnsmasq) teardown_dnsmasq ;;
none) ;; # Nothing was configured nm-dnsmasq) teardown_nm_dnsmasq ;;
none) ;; # Nothing was configured
*) *)
# State unknown — clean up everything # State unknown — clean up everything
teardown_dns_delegate teardown_dns_delegate
teardown_global_drop_in
teardown_resolvectl teardown_resolvectl
teardown_dnsmasq teardown_dnsmasq
teardown_nm_dnsmasq teardown_nm_dnsmasq
+16 -5
View File
@@ -42,10 +42,16 @@ tun:
dns: dns:
enabled: true enabled: true
# bind_addr defaults to "::" (all interfaces, dual-stack). Required so # bind_addr defaults to "::1" (IPv6 loopback). The shipped
# systemd-resolved can reach the responder via fips0 when resolvectl # fips-dns-setup script configures systemd-resolved with a global
# configures per-interface DNS forwarding. # /etc/systemd/resolved.conf.d/fips.conf drop-in pointing at
# bind_addr: "::" # [::1]:5354.
#
# Set "::" to expose the responder to mesh peers as well (e.g. for
# gateway hosts that resolve .fips on behalf of LAN clients). The
# mesh-interface filter in src/upper/dns.rs will still defend
# /etc/fips/hosts aliases from cross-mesh enumeration.
# bind_addr: "::1"
port: 5354 port: 5354
transports: transports:
@@ -85,7 +91,12 @@ transports:
# lan_interface: "eth0" # lan_interface: "eth0"
# dns: # dns:
# listen: "[::]:53" # listen: "[::]:53"
# upstream: "127.0.0.1:5354" # # upstream must match the daemon's dns.bind_addr above. The
# # default "[::1]:5354" matches the daemon's default. If you set
# # the daemon to bind on a wildcard ("::") or specific address,
# # update upstream to match — explicit ::1 sockets do not accept
# # v4 traffic.
# upstream: "[::1]:5354"
# ttl: 60 # ttl: 60
# pool_grace_period: 60 # pool_grace_period: 60
# conntrack: # conntrack:
+10 -3
View File
@@ -23,8 +23,15 @@ tun:
dns: dns:
enabled: true enabled: true
# bind_addr defaults to "::" (all interfaces, dual-stack). # bind_addr defaults to "::1" (IPv6 loopback). On OpenWrt, dnsmasq
# bind_addr: "::" # owns port 53; fips-dns-setup adds a forwarder rule pointing at
# 127.0.0.1#5354. Loopback bind is sufficient for that path.
#
# Set "::" to expose the responder to LAN clients directly (rare —
# most installs let dnsmasq forward instead). The mesh-interface
# filter in src/upper/dns.rs defends /etc/fips/hosts aliases from
# cross-mesh enumeration when bound to a wildcard.
# bind_addr: "::1"
port: 5354 port: 5354
transports: transports:
@@ -79,6 +86,6 @@ gateway:
lan_interface: "br-lan" # LAN-facing interface for proxy NDP lan_interface: "br-lan" # LAN-facing interface for proxy NDP
dns: dns:
listen: "[::1]:5353" # gateway DNS listener (dnsmasq forwards here) listen: "[::1]:5353" # gateway DNS listener (dnsmasq forwards here)
upstream: "127.0.0.1:5354" # FIPS daemon DNS resolver upstream: "[::1]:5354" # FIPS daemon DNS resolver (matches daemon default)
ttl: 60 # DNS TTL and mapping lifetime (seconds) ttl: 60 # DNS TTL and mapping lifetime (seconds)
pool_grace_period: 60 # seconds after last session before reclaiming pool_grace_period: 60 # seconds after last session before reclaiming
+12 -4
View File
@@ -11,7 +11,14 @@ use serde::{Deserialize, Serialize};
const DEFAULT_DNS_LISTEN: &str = "[::]:53"; const DEFAULT_DNS_LISTEN: &str = "[::]:53";
/// Default upstream DNS resolver (FIPS daemon). /// Default upstream DNS resolver (FIPS daemon).
const DEFAULT_DNS_UPSTREAM: &str = "127.0.0.1:5354"; ///
/// Must match the daemon's `dns.bind_addr` default (`::1`). Linux
/// IPv6 sockets bound to explicit `::1` do not accept v4-mapped
/// traffic — so a v4 upstream like `127.0.0.1:5354` cannot reach a
/// daemon bound on `[::1]:5354`. Operators who set a non-default
/// `dns.bind_addr` on the daemon must also set this field
/// accordingly.
const DEFAULT_DNS_UPSTREAM: &str = "[::1]:5354";
/// Default DNS TTL in seconds. /// Default DNS TTL in seconds.
const DEFAULT_DNS_TTL: u32 = 60; const DEFAULT_DNS_TTL: u32 = 60;
@@ -115,7 +122,8 @@ pub struct GatewayDnsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub listen: Option<String>, pub listen: Option<String>,
/// Upstream FIPS daemon DNS resolver (default: `127.0.0.1:5354`). /// Upstream FIPS daemon DNS resolver (default: `[::1]:5354`,
/// matching the daemon's `dns.bind_addr` default).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream: Option<String>, pub upstream: Option<String>,
@@ -130,7 +138,7 @@ impl GatewayDnsConfig {
self.listen.as_deref().unwrap_or(DEFAULT_DNS_LISTEN) self.listen.as_deref().unwrap_or(DEFAULT_DNS_LISTEN)
} }
/// Get the upstream resolver address (default: `127.0.0.1:5354`). /// Get the upstream resolver address (default: `[::1]:5354`).
pub fn upstream(&self) -> &str { pub fn upstream(&self) -> &str {
self.upstream.as_deref().unwrap_or(DEFAULT_DNS_UPSTREAM) self.upstream.as_deref().unwrap_or(DEFAULT_DNS_UPSTREAM)
} }
@@ -198,7 +206,7 @@ lan_interface: "eth0"
assert_eq!(config.pool, "fd01::/112"); assert_eq!(config.pool, "fd01::/112");
assert_eq!(config.lan_interface, "eth0"); assert_eq!(config.lan_interface, "eth0");
assert_eq!(config.dns.listen(), "[::]:53"); assert_eq!(config.dns.listen(), "[::]:53");
assert_eq!(config.dns.upstream(), "127.0.0.1:5354"); assert_eq!(config.dns.upstream(), "[::1]:5354");
assert_eq!(config.dns.ttl(), 60); assert_eq!(config.dns.ttl(), 60);
assert_eq!(config.grace_period(), 60); assert_eq!(config.grace_period(), 60);
assert_eq!(config.conntrack.tcp_established(), 432_000); assert_eq!(config.conntrack.tcp_established(), 432_000);
+14 -8
View File
@@ -694,14 +694,20 @@ impl Node {
// Initialize DNS responder (independent of TUN). // Initialize DNS responder (independent of TUN).
// //
// The default bind_addr is "::" (all interfaces, dual-stack). This // Default bind_addr is "::1" (IPv6 loopback). The shipped
// matters on Ubuntu 22 (systemd 249): systemd-resolved applies // fips-dns-setup configures systemd-resolved via a global
// interface-scoped routing to per-link DNS servers — when resolvectl // /etc/systemd/resolved.conf.d/fips.conf drop-in pointing at
// points fips0 at an address, resolved tries to reach it through // [::1]:5354, which sidesteps a Linux IPV6_PKTINFO behaviour
// fips0. Binding to "::" ensures the responder is reachable via fips0 // where self-destined traffic to fips0's address is attributed
// as well as loopback (v4 and v6). `IPV6_V6ONLY=0` is set explicitly // to fips0 in PKTINFO and gets silently dropped by the
// so IPv4 clients on 127.0.0.1 still reach us regardless of kernel // mesh-interface filter in src/upper/dns.rs.
// sysctl defaults. //
// For mesh-reachable resolution (rare), set bind_addr: "::"
// in fips.yaml. The mesh-interface filter remains active to
// prevent hosts-file alias enumeration in that mode.
// `IPV6_V6ONLY=0` is set explicitly so IPv4 clients on
// 127.0.0.1 still reach us regardless of kernel sysctl
// defaults — but only when bind is on a wildcard / IPv6 path.
if self.config.dns.enabled { if self.config.dns.enabled {
let addr_str = self.config.dns.bind_addr(); let addr_str = self.config.dns.bind_addr();
match addr_str.parse::<std::net::IpAddr>() { match addr_str.parse::<std::net::IpAddr>() {
+13 -5
View File
@@ -13,10 +13,18 @@ const DEFAULT_TUN_MTU: u16 = 1280;
/// Default DNS responder bind address. /// Default DNS responder bind address.
/// ///
/// Binds to all interfaces so systemd-resolved (configured per-interface /// Loopback by default. The shipped `fips-dns-setup` configures
/// on fips0) can reach the responder regardless of interface scoping. /// systemd-resolved with a global drop-in pointing at `[::1]:5354`
/// See `packaging/common/fips-dns-setup` for how resolvectl is configured. /// (instead of a per-link `resolvectl dns fips0 [<fips0_addr>]:5354`),
const DEFAULT_DNS_BIND_ADDR: &str = "::"; /// which avoids a Linux IPV6_PKTINFO behaviour where self-destined
/// traffic to a TUN address is attributed to the TUN's ifindex —
/// causing the mesh-interface filter to silently drop every query.
///
/// To expose the responder to mesh peers, set `bind_addr: "::"` in
/// fips.yaml. The `is_mesh_interface_query` filter in `src/upper/dns.rs`
/// is still in place to prevent hosts-file alias enumeration in that
/// mode. See `packaging/common/fips-dns-setup` for backend selection.
const DEFAULT_DNS_BIND_ADDR: &str = "::1";
/// Default DNS responder port. /// Default DNS responder port.
const DEFAULT_DNS_PORT: u16 = 5354; const DEFAULT_DNS_PORT: u16 = 5354;
@@ -60,7 +68,7 @@ impl Default for DnsConfig {
} }
impl DnsConfig { impl DnsConfig {
/// Get the bind address (default: `::`, all interfaces). /// Get the bind address (default: `::1`, IPv6 loopback only).
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)
} }
+1
View File
@@ -0,0 +1 @@
.cache/
+449
View File
@@ -0,0 +1,449 @@
#!/bin/bash
# Test the fips Debian package install path across target distros.
#
# Each scenario builds (or reuses) the .deb in a Debian 12 cargo-deb
# builder image (cached), boots a privileged systemd container with
# TUN access for the target distro, installs the .deb via `apt
# install ./fips_*.deb`, waits for fips.service + fips-dns.service
# to come up, and verifies that `dig @127.0.0.53 AAAA <npub>.fips`
# returns a non-empty AAAA answer through the resolver backend that
# fips-dns-setup configured. Then exercises fips-gateway against the
# same daemon to verify the gateway/daemon default-pairing.
#
# This is the most thorough test surface — it exercises:
# - cargo deb packaging (binary stripping, dependency declaration)
# - dpkg conffile placement (/etc/fips/fips.yaml)
# - postinst maintainer scripts (systemd unit enablement,
# fips-dns.service running fips-dns-setup)
# - The fips, fips-dns, and (optionally) fips-gateway systemd units
# - End-to-end .fips resolution as a real user would experience it
#
# Usage: ./test.sh [scenario ...]
# No args = run all scenarios.
# Named args = run only those (e.g., ./test.sh ubuntu26 debian12)
#
# Requirements: Docker with privileged container support, /dev/net/tun
# on the host (standard).
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
CACHE_DIR="$SCRIPT_DIR/.cache"
DEB_CACHE_DIR="$CACHE_DIR/deb"
# Timeouts
BOOT_TIMEOUT=30
SERVICE_TIMEOUT=20
DAEMON_TIMEOUT=15
PASS=0
FAIL=0
SKIP=0
# ─────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────
log() { echo "=== $*"; }
pass() { echo " PASS: $*"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $*"; FAIL=$((FAIL + 1)); }
skip() { echo " SKIP: $*"; SKIP=$((SKIP + 1)); }
cleanup_container() {
local name="$1"
docker rm -f "$name" >/dev/null 2>&1 || true
}
build_image() {
local tag="$1"
shift
echo "$@" | docker build -t "$tag" -f - "$REPO_ROOT" >/dev/null 2>&1
}
start_systemd_container_with_tun() {
local name="$1" image="$2"
cleanup_container "$name"
docker run -d --name "$name" \
--privileged \
--cgroupns=host \
--device /dev/net/tun \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
--tmpfs /run --tmpfs /run/lock \
"$image" >/dev/null 2>&1
}
wait_for_systemd() {
local name="$1"
for _i in $(seq 1 "$BOOT_TIMEOUT"); do
if docker exec "$name" systemctl is-system-running --wait 2>/dev/null | grep -qE 'running|degraded'; then
return 0
fi
sleep 1
done
echo " WARNING: systemd did not reach running state in ${BOOT_TIMEOUT}s (may still work)"
return 0
}
wait_for_service_active() {
local name="$1" service="$2" timeout="${3:-$SERVICE_TIMEOUT}"
for _i in $(seq 1 "$timeout"); do
if docker exec "$name" systemctl is-active --quiet "$service" 2>/dev/null; then
return 0
fi
sleep 1
done
return 1
}
container_systemd_version() {
local name="$1"
docker exec "$name" systemctl --version 2>/dev/null | head -1 \
| grep -oE '[0-9]+' | head -1
}
# ─────────────────────────────────────────────────────────────────────
# Build the .deb once in a Debian 12 cargo-deb builder image (cached
# between runs). Output cached at testing/deb-install/.cache/deb/.
# Rebuilt if any source/Cargo/packaging file is newer than the cached
# .deb, or if the .deb is missing.
# ─────────────────────────────────────────────────────────────────────
build_deb() {
mkdir -p "$DEB_CACHE_DIR"
local cached_deb
cached_deb=$(ls "$DEB_CACHE_DIR"/fips_*_amd64.deb 2>/dev/null | head -1)
if [ -n "$cached_deb" ] && [ -f "$cached_deb" ]; then
local newest_src
newest_src=$(find "$REPO_ROOT/src" "$REPO_ROOT/Cargo.toml" \
"$REPO_ROOT/Cargo.lock" "$REPO_ROOT/packaging" \
-type f -printf '%T@\n' 2>/dev/null | sort -nr | head -1)
local cached_age
cached_age=$(stat -c '%Y' "$cached_deb" 2>/dev/null || echo 0)
if awk "BEGIN { exit !($cached_age >= $newest_src) }"; then
log "Using cached .deb at $cached_deb"
return 0
fi
log "Cached .deb is stale, rebuilding"
else
log "No cached .deb, building"
fi
local builder_tag="fips-deb-test:builder"
log "Building Debian 12 cargo-deb builder image (slow on first run)"
docker build -t "$builder_tag" -f - "$REPO_ROOT" <<'DOCKERFILE' >/dev/null
FROM debian:12
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config libdbus-1-dev curl ca-certificates \
libclang-dev clang && \
apt-get clean && rm -rf /var/lib/apt/lists/*
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --default-toolchain stable --profile minimal
ENV PATH="/root/.cargo/bin:${PATH}"
RUN cargo install cargo-deb --version 3.6.3 --locked
WORKDIR /src
COPY Cargo.toml Cargo.lock build.rs LICENSE README.md ./
COPY src ./src
COPY packaging ./packaging
RUN cargo build --release && cargo deb --no-build
DOCKERFILE
if [ ! "$(docker images -q "$builder_tag" 2>/dev/null)" ]; then
echo " ERROR: builder image build failed"
return 1
fi
log "Extracting .deb from builder image"
rm -f "$DEB_CACHE_DIR"/*.deb
local cid
cid=$(docker create "$builder_tag")
docker cp "$cid:/src/target/debian/." "$DEB_CACHE_DIR/" >/dev/null 2>&1
docker rm "$cid" >/dev/null
# Cargo-deb leaves intermediate artifacts; keep just the .deb.
find "$DEB_CACHE_DIR" -mindepth 1 -not -name 'fips_*_amd64.deb' -delete 2>/dev/null || true
cached_deb=$(ls "$DEB_CACHE_DIR"/fips_*_amd64.deb 2>/dev/null | head -1)
if [ -n "$cached_deb" ]; then
log "Cached at $cached_deb ($(stat -c %s "$cached_deb") bytes)"
else
echo " ERROR: no .deb produced by cargo-deb"
return 1
fi
}
# ─────────────────────────────────────────────────────────────────────
# Scenario runner
#
# Args: <distro_label> <docker_base_image>
# distro_label: short tag for container/image names (e.g. "debian12")
# docker_base_image: e.g. "debian:12", "ubuntu:26.04"
# ─────────────────────────────────────────────────────────────────────
_run_deb_install_scenario() {
local distro_label="$1"
local base_image="$2"
local name="fips-deb-test-${distro_label}"
local image="fips-deb-test:${distro_label}"
log ".deb install: ${base_image}"
build_deb || { fail ".deb build failed"; return; }
local cached_deb
cached_deb=$(ls "$DEB_CACHE_DIR"/fips_*_amd64.deb 2>/dev/null | head -1)
if [ -z "$cached_deb" ] || [ ! -f "$cached_deb" ]; then
fail "no .deb available at $DEB_CACHE_DIR"
return
fi
local deb_basename
deb_basename=$(basename "$cached_deb")
# Ubuntu 22.04 bundles systemd-resolved into systemd; other
# distros require it as a separate package. Compose the apt
# package list accordingly.
local apt_packages="systemd iproute2 dbus dnsutils procps"
if [ "$base_image" != "ubuntu:22.04" ]; then
apt_packages="systemd systemd-resolved iproute2 dbus dnsutils procps"
fi
log "Building ${base_image} runtime image"
cp "$cached_deb" "$CACHE_DIR/deb-for-image"
# Place the .deb under /opt — systemd remounts /tmp during boot
# (PrivateTmp / tmpfs) which would wipe a .deb COPY'd to /tmp.
build_image "$image" "$(cat <<DOCKERFILE
FROM ${base_image}
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \\
${apt_packages} && \\
apt-get clean && rm -rf /var/lib/apt/lists/* && \\
systemctl enable systemd-resolved && \\
mkdir -p /opt/fips-deb
COPY testing/deb-install/.cache/deb-for-image /opt/fips-deb/${deb_basename}
CMD ["/lib/systemd/systemd"]
DOCKERFILE
)" || { fail "runtime build failed"; rm -f "$CACHE_DIR/deb-for-image"; return; }
rm -f "$CACHE_DIR/deb-for-image"
start_systemd_container_with_tun "$name" "$image"
wait_for_systemd "$name"
# Install the .deb. apt handles dependencies (libc6, systemd,
# libdbus-1-3) and runs the maintainer scripts (postinst →
# systemctl enable fips.service; fips-dns.service starts and
# runs fips-dns-setup).
log "Installing .deb (apt install /opt/fips-deb/${deb_basename})"
local install_output
install_output=$(docker exec "$name" bash -c "
apt-get update >/dev/null 2>&1
cd /opt/fips-deb && apt-get install -y --no-install-recommends ./${deb_basename} 2>&1
") || true
if echo "$install_output" | grep -qE "^E:|errors? were encountered"; then
fail "apt install reported errors"
echo "$install_output" | tail -20
cleanup_container "$name"
return
else
pass "apt install completed"
fi
# Verify shipped files landed where expected.
if docker exec "$name" test -x /usr/bin/fips; then
pass "/usr/bin/fips installed"
else
fail "/usr/bin/fips missing"
fi
if docker exec "$name" test -x /usr/bin/fips-gateway; then
pass "/usr/bin/fips-gateway installed"
else
fail "/usr/bin/fips-gateway missing"
fi
if docker exec "$name" test -f /etc/fips/fips.yaml; then
pass "/etc/fips/fips.yaml conffile installed"
else
fail "/etc/fips/fips.yaml conffile missing"
fi
# Verify fips.service is enabled (postinst enables but does not
# start on fresh install — standard Debian convention).
if docker exec "$name" systemctl is-enabled --quiet fips.service; then
pass "fips.service enabled by postinst"
else
fail "fips.service not enabled after install"
fi
if docker exec "$name" systemctl is-enabled --quiet fips-dns.service; then
pass "fips-dns.service enabled by postinst"
else
fail "fips-dns.service not enabled after install"
fi
# Start the services as a simulated boot. (On a real system,
# they'd come up on next reboot.)
docker exec "$name" systemctl start fips.service 2>&1 || true
docker exec "$name" systemctl start fips-dns.service 2>&1 || true
if wait_for_service_active "$name" fips.service; then
pass "fips.service active after explicit start"
else
fail "fips.service did not become active in ${SERVICE_TIMEOUT}s"
echo " --- fips.service status ---"
docker exec "$name" systemctl status fips.service --no-pager 2>&1 | tail -20
echo " --- fips.service journal ---"
docker exec "$name" journalctl -u fips.service --no-pager 2>&1 | tail -20
cleanup_container "$name"
return
fi
# Wait for the DNS responder to bind on the daemon's [::1]:5354.
local ready=0
for _i in $(seq 1 "$DAEMON_TIMEOUT"); do
if docker exec "$name" ss -uln 2>/dev/null | grep -q ':5354'; then
ready=1
break
fi
sleep 1
done
if [ "$ready" = "1" ]; then
pass "fips DNS listener up on port 5354"
else
fail "fips DNS listener did not appear within ${DAEMON_TIMEOUT}s"
cleanup_container "$name"
return
fi
# Wait for fips-dns.service. This should have run fips-dns-setup
# which configures the resolver backend and writes
# /run/fips/dns-backend.
sleep 2
local backend
backend=$(docker exec "$name" cat /run/fips/dns-backend 2>/dev/null || echo "(missing)")
local ver
ver=$(container_systemd_version "$name")
local expected_backend
if [ -n "$ver" ] && [ "$ver" -ge 258 ]; then
expected_backend="dns-delegate"
else
expected_backend="global-drop-in"
fi
if [ "$backend" = "$expected_backend" ]; then
pass "fips-dns.service picked $expected_backend backend (systemd $ver)"
else
fail "expected $expected_backend (systemd $ver), got: $backend"
echo " --- fips-dns.service journal ---"
docker exec "$name" journalctl -u fips-dns.service --no-pager 2>&1 | tail -20
fi
# Get the daemon's npub via fipsctl. Works for both ephemeral
# and persistent identity (no need to override the conffile).
local npub
npub=$(docker exec "$name" fipsctl show status 2>/dev/null \
| grep -oE 'npub1[a-z0-9]+' | head -1)
if [ -z "$npub" ]; then
fail "could not read npub from fipsctl show status"
cleanup_container "$name"
return
fi
echo " daemon npub: $npub"
# The actual end-to-end test: a stock dpkg-installed deployment
# must successfully resolve a .fips query through the system
# resolver. This covers the full path: maintainer scripts ran,
# service started, DNS responder bound, resolver backend
# configured, query routed and answered.
sleep 1
local stub_output
stub_output=$(docker exec "$name" dig +tries=1 +time=3 @127.0.0.53 AAAA "${npub}.fips" 2>&1)
if echo "$stub_output" | grep -qE '^[a-zA-Z0-9].*\sAAAA\s+[0-9a-f:]+'; then
pass "end-to-end dig @127.0.0.53 returns AAAA on stock .deb install"
else
fail "end-to-end dig @127.0.0.53 did not return AAAA"
echo " --- dig output ---"
echo "$stub_output" | tail -15
echo " --- resolved status ---"
docker exec "$name" resolvectl status 2>&1 | tail -25 || true
echo " --- fips journal ---"
docker exec "$name" journalctl -u fips.service --no-pager 2>&1 | tail -10
fi
# Verify fips-gateway can run against the installed daemon. Tests
# the gateway/daemon default-pairing on a real .deb install (no
# custom config). Requires enabling the unit (it's not in the
# default preset) and ipv6 forwarding (gateway checks before
# the DNS upstream check).
docker exec "$name" sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true
docker exec "$name" bash -c '
systemctl unmask fips-gateway.service 2>/dev/null
# Patch in a minimal gateway config since the shipped fips.yaml
# has gateway disabled by default.
cp /etc/fips/fips.yaml /etc/fips/fips.yaml.orig
cat >> /etc/fips/fips.yaml <<EOF
gateway:
enabled: true
pool: "fd01::/112"
lan_interface: "eth0"
EOF
systemctl restart fips.service
' >/dev/null 2>&1
sleep 3
if wait_for_service_active "$name" fips.service 5; then
:
else
fail "fips.service did not stay up after gateway-enable restart"
fi
docker exec "$name" systemctl start fips-gateway.service >/dev/null 2>&1 || true
sleep 3
if docker exec "$name" journalctl -u fips-gateway.service --no-pager 2>/dev/null \
| grep -q "DNS upstream is reachable"; then
pass "fips-gateway reaches DNS upstream at [::1]:5354 via .deb install"
else
fail "fips-gateway DNS upstream check failed against installed daemon"
echo " --- fips-gateway journal ---"
docker exec "$name" journalctl -u fips-gateway.service --no-pager 2>&1 | tail -15
fi
cleanup_container "$name"
}
# Per-distro wrappers
test_debian12() { _run_deb_install_scenario debian12 debian:12; }
test_debian13() { _run_deb_install_scenario debian13 debian:trixie; }
test_ubuntu22() { _run_deb_install_scenario ubuntu22 ubuntu:22.04; }
test_ubuntu24() { _run_deb_install_scenario ubuntu24 ubuntu:24.04; }
test_ubuntu26() { _run_deb_install_scenario ubuntu26 ubuntu:26.04; }
# ─────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────
ALL_SCENARIOS="debian12 debian13 ubuntu22 ubuntu24 ubuntu26"
if [ $# -eq 0 ]; then
scenarios="$ALL_SCENARIOS"
else
scenarios="$*"
fi
for scenario in $scenarios; do
case "$scenario" in
debian12) test_debian12 ;;
debian13) test_debian13 ;;
ubuntu22) test_ubuntu22 ;;
ubuntu24) test_ubuntu24 ;;
ubuntu26) test_ubuntu26 ;;
*)
echo "Unknown scenario: $scenario"
echo "Available: $ALL_SCENARIOS"
exit 1
;;
esac
echo
done
echo "═══════════════════════════════════════"
echo "Results: $PASS passed, $FAIL failed, $SKIP skipped"
echo "═══════════════════════════════════════"
[ "$FAIL" -eq 0 ]
+1
View File
@@ -0,0 +1 @@
.cache/
+539 -38
View File
@@ -1,15 +1,25 @@
#!/bin/bash #!/bin/bash
# Test fips-dns-setup across different Linux resolver backends. # Test fips-dns-setup across different Linux resolver backends, plus
# an end-to-end scenario that verifies a real fips answers .fips
# queries through the configured backend.
# #
# Each scenario runs a systemd-based Docker container, creates a dummy # Each scenario runs a systemd-based Docker container, creates a dummy
# fips0 interface, runs the setup script, verifies the detected backend # fips0 interface (or a real one for the e2e scenario), runs the setup
# and generated config, runs teardown, and verifies cleanup. # script, verifies the detected backend and generated config, runs
# teardown, and verifies cleanup.
#
# The end-to-end scenario additionally builds fips in a Debian 12
# builder image (cached between runs) so the binary is glibc-compatible
# across all target distros. It then starts the daemon, configures DNS
# via the script, and confirms `dig @127.0.0.53 AAAA <npub>.fips`
# returns a non-empty AAAA answer.
# #
# Usage: ./test.sh [scenario ...] # Usage: ./test.sh [scenario ...]
# No args = run all scenarios. # No args = run all scenarios.
# Named args = run only those (e.g., ./test.sh debian12-resolved dnsmasq) # Named args = run only those (e.g., ./test.sh debian12-resolved e2e-debian12)
# #
# Requirements: Docker with privileged container support. # Requirements: Docker with privileged container support. The e2e
# scenario also needs /dev/net/tun on the host (standard).
set -uo pipefail set -uo pipefail
@@ -17,10 +27,16 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
SETUP_SCRIPT="$REPO_ROOT/packaging/common/fips-dns-setup" SETUP_SCRIPT="$REPO_ROOT/packaging/common/fips-dns-setup"
TEARDOWN_SCRIPT="$REPO_ROOT/packaging/common/fips-dns-teardown" TEARDOWN_SCRIPT="$REPO_ROOT/packaging/common/fips-dns-teardown"
CACHE_DIR="$SCRIPT_DIR/.cache"
FIPS_BIN_CACHE="$CACHE_DIR/fips"
FIPS_GATEWAY_BIN_CACHE="$CACHE_DIR/fips-gateway"
# Timeout for systemd boot inside container # Timeout for systemd boot inside container
BOOT_TIMEOUT=30 BOOT_TIMEOUT=30
# Timeout for fips to start serving DNS in the e2e scenario
DAEMON_TIMEOUT=15
PASS=0 PASS=0
FAIL=0 FAIL=0
SKIP=0 SKIP=0
@@ -58,6 +74,19 @@ start_systemd_container() {
"$image" >/dev/null 2>&1 "$image" >/dev/null 2>&1
} }
# Same, but with TUN device for the e2e scenario.
start_systemd_container_with_tun() {
local name="$1" image="$2"
cleanup_container "$name"
docker run -d --name "$name" \
--privileged \
--cgroupns=host \
--device /dev/net/tun \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
--tmpfs /run --tmpfs /run/lock \
"$image" >/dev/null 2>&1
}
# Wait for systemd to reach a bootable state inside the container. # Wait for systemd to reach a bootable state inside the container.
wait_for_systemd() { wait_for_systemd() {
local name="$1" local name="$1"
@@ -104,14 +133,158 @@ file_exists() {
docker exec "$name" test -f "$path" 2>/dev/null docker exec "$name" test -f "$path" 2>/dev/null
} }
file_contains() {
local name="$1" path="$2" needle="$3"
docker exec "$name" grep -qF "$needle" "$path" 2>/dev/null
}
# Get the major systemd version inside a container.
container_systemd_version() {
local name="$1"
docker exec "$name" systemctl --version 2>/dev/null | head -1 \
| grep -oE '[0-9]+' | head -1
}
# Verify the expected systemd-resolved-flavoured backend was picked
# and that its config file targets the new [::1]:5354 daemon bind.
# On systemd >= 258 the dns-delegate backend wins; otherwise
# global-drop-in. Either way the daemon target must be ::1 — that's
# the regression we're locking in.
verify_resolved_backend() {
local name="$1"
local ver
ver=$(container_systemd_version "$name")
local backend
backend=$(get_backend "$name")
local expected_backend expected_path
if [ -n "$ver" ] && [ "$ver" -ge 258 ]; then
expected_backend="dns-delegate"
expected_path="/etc/systemd/dns-delegate.d/fips.dns-delegate"
else
expected_backend="global-drop-in"
expected_path="/etc/systemd/resolved.conf.d/fips.conf"
fi
if [ "$backend" = "$expected_backend" ]; then
pass "detected backend: $expected_backend (systemd $ver)"
else
fail "expected $expected_backend (systemd $ver), got: $backend"
fi
if file_exists "$name" "$expected_path"; then
pass "config file written at $expected_path"
else
fail "config file missing at $expected_path"
return
fi
# All systemd-flavoured backends must target [::1]:5354 to match
# the daemon's default bind. If they don't, queries silently fail
# — Linux IPv6 sockets bound to ::1 do not accept v4 traffic.
if file_contains "$name" "$expected_path" "[::1]:5354"; then
pass "config DNS target is [::1]:5354 (matches daemon default)"
else
fail "config DNS target wrong — must be [::1]:5354"
echo " contents: $(docker exec "$name" cat "$expected_path")"
fi
# Domain forwarding line: dns-delegate uses 'Domains=fips',
# global-drop-in uses 'Domains=~fips' (wildcard prefix).
local expected_domain_line
if [ "$expected_backend" = "dns-delegate" ]; then
expected_domain_line="Domains=fips"
else
expected_domain_line="Domains=~fips"
fi
if file_contains "$name" "$expected_path" "$expected_domain_line"; then
pass "config Domains line correct ($expected_domain_line)"
else
fail "config Domains line incorrect — expected $expected_domain_line"
fi
run_teardown "$name" >/dev/null 2>&1
if ! file_exists "$name" "$expected_path"; then
pass "teardown removed config file"
else
fail "config file still exists after teardown"
fi
if ! file_exists "$name" /run/fips/dns-backend; then
pass "teardown cleaned state file"
else
fail "state file still exists after teardown"
fi
}
# ───────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────
# Scenarios # Build the fips binary once in a Debian 12 builder image so it's
# glibc-compatible with every target distro (Debian 12/13, Ubuntu 22/24).
# Cached at testing/dns-resolver/.cache/fips between runs; rebuild if
# any source file is newer than the cached binary.
# ─────────────────────────────────────────────────────────────────────
build_fips_for_e2e() {
mkdir -p "$CACHE_DIR"
if [ -f "$FIPS_BIN_CACHE" ] && [ -f "$FIPS_GATEWAY_BIN_CACHE" ]; then
local newest_src
newest_src=$(find "$REPO_ROOT/src" "$REPO_ROOT/Cargo.toml" "$REPO_ROOT/Cargo.lock" \
-type f -printf '%T@\n' 2>/dev/null | sort -nr | head -1)
local cached_age
cached_age=$(stat -c '%Y' "$FIPS_BIN_CACHE" 2>/dev/null || echo 0)
local cached_gateway_age
cached_gateway_age=$(stat -c '%Y' "$FIPS_GATEWAY_BIN_CACHE" 2>/dev/null || echo 0)
local oldest_cached=$((cached_age < cached_gateway_age ? cached_age : cached_gateway_age))
if awk "BEGIN { exit !($oldest_cached >= $newest_src) }"; then
log "Using cached fips + fips-gateway binaries at $CACHE_DIR"
return 0
fi
log "Cached binaries are stale, rebuilding"
else
log "No cached binaries, building"
fi
local builder_tag="fips-dns-test:builder"
log "Building Debian 12 builder image (this may take a few minutes on first run)"
docker build -t "$builder_tag" -f - "$REPO_ROOT" <<'DOCKERFILE' >/dev/null
FROM debian:12
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config libdbus-1-dev curl ca-certificates \
libclang-dev clang && \
apt-get clean && rm -rf /var/lib/apt/lists/*
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --default-toolchain stable --profile minimal
ENV PATH="/root/.cargo/bin:${PATH}"
WORKDIR /src
COPY Cargo.toml Cargo.lock build.rs ./
COPY src ./src
RUN cargo build --release --bin fips --bin fips-gateway
DOCKERFILE
if [ ! "$(docker images -q "$builder_tag" 2>/dev/null)" ]; then
echo " ERROR: builder image build failed"
return 1
fi
log "Extracting fips + fips-gateway binaries from builder image"
local cid
cid=$(docker create "$builder_tag")
docker cp "$cid:/src/target/release/fips" "$FIPS_BIN_CACHE" >/dev/null 2>&1
docker cp "$cid:/src/target/release/fips-gateway" "$FIPS_GATEWAY_BIN_CACHE" >/dev/null 2>&1
docker rm "$cid" >/dev/null
chmod +x "$FIPS_BIN_CACHE" "$FIPS_GATEWAY_BIN_CACHE"
log "Cached fips ($(stat -c %s "$FIPS_BIN_CACHE") bytes) + fips-gateway ($(stat -c %s "$FIPS_GATEWAY_BIN_CACHE") bytes)"
}
# ─────────────────────────────────────────────────────────────────────
# Scenarios — script-behavior tests across distros (no daemon)
# ───────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────
test_debian12_resolved() { test_debian12_resolved() {
local name="fips-dns-test-deb12-resolved" local name="fips-dns-test-deb12-resolved"
local image="fips-dns-test:debian12-resolved" local image="fips-dns-test:debian12-resolved"
log "Debian 12 + systemd-resolved" log "Debian 12 + systemd-resolved (expects global-drop-in)"
build_image "$image" "$(cat <<'DOCKERFILE' build_image "$image" "$(cat <<'DOCKERFILE'
FROM debian:12 FROM debian:12
@@ -132,29 +305,14 @@ DOCKERFILE
output=$(run_setup "$name" 2>&1) output=$(run_setup "$name" 2>&1)
echo " output: $output" echo " output: $output"
local backend verify_resolved_backend "$name"
backend=$(get_backend "$name")
if [ "$backend" = "resolvectl" ]; then
pass "detected backend: resolvectl"
else
fail "expected resolvectl, got: $backend"
fi
# Teardown
run_teardown "$name" >/dev/null 2>&1
if ! file_exists "$name" /run/fips/dns-backend; then
pass "teardown cleaned state file"
else
fail "state file still exists after teardown"
fi
cleanup_container "$name" cleanup_container "$name"
} }
test_debian13_resolved() { test_debian13_resolved() {
local name="fips-dns-test-deb13-resolved" local name="fips-dns-test-deb13-resolved"
local image="fips-dns-test:debian13-resolved" local image="fips-dns-test:debian13-resolved"
log "Debian 13 (trixie) + systemd-resolved" log "Debian 13 (trixie) + systemd-resolved (expects global-drop-in)"
build_image "$image" "$(cat <<'DOCKERFILE' build_image "$image" "$(cat <<'DOCKERFILE'
FROM debian:trixie FROM debian:trixie
@@ -175,21 +333,93 @@ DOCKERFILE
output=$(run_setup "$name" 2>&1) output=$(run_setup "$name" 2>&1)
echo " output: $output" echo " output: $output"
local backend verify_resolved_backend "$name"
backend=$(get_backend "$name") cleanup_container "$name"
if [ "$backend" = "resolvectl" ]; then }
pass "detected backend: resolvectl"
else
fail "expected resolvectl, got: $backend"
fi
run_teardown "$name" >/dev/null 2>&1 test_ubuntu22_resolved() {
if ! file_exists "$name" /run/fips/dns-backend; then local name="fips-dns-test-u22-resolved"
pass "teardown cleaned state file" local image="fips-dns-test:ubuntu22-resolved"
else log "Ubuntu 22.04 + systemd-resolved (expects global-drop-in)"
fail "state file still exists after teardown"
fi
# On Ubuntu 22.04 systemd-resolved is bundled with systemd (not a
# separate package). Just enable the service.
build_image "$image" "$(cat <<'DOCKERFILE'
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
systemd iproute2 dbus && \
apt-get clean && rm -rf /var/lib/apt/lists/* && \
systemctl enable systemd-resolved
CMD ["/lib/systemd/systemd"]
DOCKERFILE
)" || { fail "build failed"; return; }
start_systemd_container "$name" "$image"
wait_for_systemd "$name"
create_fips0 "$name"
local output
output=$(run_setup "$name" 2>&1)
echo " output: $output"
verify_resolved_backend "$name"
cleanup_container "$name"
}
test_ubuntu24_resolved() {
local name="fips-dns-test-u24-resolved"
local image="fips-dns-test:ubuntu24-resolved"
log "Ubuntu 24.04 + systemd-resolved (expects global-drop-in)"
build_image "$image" "$(cat <<'DOCKERFILE'
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
systemd systemd-resolved iproute2 dbus && \
apt-get clean && rm -rf /var/lib/apt/lists/* && \
systemctl enable systemd-resolved
CMD ["/lib/systemd/systemd"]
DOCKERFILE
)" || { fail "build failed"; return; }
start_systemd_container "$name" "$image"
wait_for_systemd "$name"
create_fips0 "$name"
local output
output=$(run_setup "$name" 2>&1)
echo " output: $output"
verify_resolved_backend "$name"
cleanup_container "$name"
}
test_ubuntu26_resolved() {
local name="fips-dns-test-u26-resolved"
local image="fips-dns-test:ubuntu26-resolved"
log "Ubuntu 26.04 + systemd-resolved (expects global-drop-in)"
build_image "$image" "$(cat <<'DOCKERFILE'
FROM ubuntu:26.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
systemd systemd-resolved iproute2 dbus && \
apt-get clean && rm -rf /var/lib/apt/lists/* && \
systemctl enable systemd-resolved
CMD ["/lib/systemd/systemd"]
DOCKERFILE
)" || { fail "build failed"; return; }
start_systemd_container "$name" "$image"
wait_for_systemd "$name"
create_fips0 "$name"
local output
output=$(run_setup "$name" 2>&1)
echo " output: $output"
verify_resolved_backend "$name"
cleanup_container "$name" cleanup_container "$name"
} }
@@ -234,6 +464,15 @@ DOCKERFILE
fail "dnsmasq config not found" fail "dnsmasq config not found"
fi fi
# Verify config targets ::1#5354 (the daemon's default IPv6
# loopback bind). Drift from this constant would silently break
# resolution on hosts using this backend.
if file_contains "$name" /etc/dnsmasq.d/fips.conf "server=/fips/::1#5354"; then
pass "dnsmasq config targets ::1#5354 (matches daemon default)"
else
fail "dnsmasq config target wrong — must be server=/fips/::1#5354"
fi
# Teardown # Teardown
run_teardown "$name" >/dev/null 2>&1 run_teardown "$name" >/dev/null 2>&1
if ! file_exists "$name" /etc/dnsmasq.d/fips.conf; then if ! file_exists "$name" /etc/dnsmasq.d/fips.conf; then
@@ -291,6 +530,12 @@ DOCKERFILE
fail "NM dnsmasq config not found" fail "NM dnsmasq config not found"
fi fi
if file_contains "$name" /etc/NetworkManager/dnsmasq.d/fips.conf "server=/fips/::1#5354"; then
pass "NM dnsmasq config targets ::1#5354 (matches daemon default)"
else
fail "NM dnsmasq config target wrong — must be server=/fips/::1#5354"
fi
# Teardown # Teardown
run_teardown "$name" >/dev/null 2>&1 run_teardown "$name" >/dev/null 2>&1
if ! file_exists "$name" /etc/NetworkManager/dnsmasq.d/fips.conf; then if ! file_exists "$name" /etc/NetworkManager/dnsmasq.d/fips.conf; then
@@ -355,11 +600,259 @@ DOCKERFILE
cleanup_container "$name" cleanup_container "$name"
} }
# ─────────────────────────────────────────────────────────────────────
# End-to-end scenarios — run a real fips + fips-gateway, configure
# DNS via the script, dig through systemd-resolved.
#
# Parameterized across Debian 12/13 and Ubuntu 22/24/26. The fips
# and fips-gateway binaries are built once in a Debian 12 builder
# image (lowest glibc target → forward-compatible with all newer
# distros) and copied into each per-distro runtime image.
# ─────────────────────────────────────────────────────────────────────
# Args: <distro_label> <docker_base_image> <apt_packages>
# distro_label: short tag for container/image names (e.g. "debian12")
# docker_base_image: e.g. "debian:12", "ubuntu:26.04"
# apt_packages: space-separated apt-get install list. Ubuntu 22.04
# bundles systemd-resolved into systemd, so the package list there
# is "systemd iproute2 dbus dnsutils libdbus-1-3 procps" (no
# separate systemd-resolved). Other distros want
# "systemd systemd-resolved iproute2 dbus dnsutils libdbus-1-3 procps".
_run_e2e_scenario() {
local distro_label="$1"
local base_image="$2"
local apt_packages="$3"
local name="fips-dns-test-e2e-${distro_label}"
local image="fips-dns-test:e2e-${distro_label}"
log "End-to-end: ${base_image} + systemd-resolved + real fips + fips-gateway + dig"
build_fips_for_e2e || { fail "fips build failed"; return; }
if [ ! -x "$FIPS_BIN_CACHE" ] || [ ! -x "$FIPS_GATEWAY_BIN_CACHE" ]; then
fail "binaries not available at $CACHE_DIR"
return
fi
log "Building e2e runtime image (${base_image})"
cp "$FIPS_BIN_CACHE" "$CACHE_DIR/fips-bin-for-image"
cp "$FIPS_GATEWAY_BIN_CACHE" "$CACHE_DIR/fips-gateway-bin-for-image"
build_image "$image" "$(cat <<DOCKERFILE
FROM ${base_image}
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \\
${apt_packages} && \\
apt-get clean && rm -rf /var/lib/apt/lists/* && \\
systemctl enable systemd-resolved && \\
mkdir -p /etc/fips
COPY testing/dns-resolver/.cache/fips-bin-for-image /usr/bin/fips
COPY testing/dns-resolver/.cache/fips-gateway-bin-for-image /usr/bin/fips-gateway
RUN chmod +x /usr/bin/fips /usr/bin/fips-gateway
CMD ["/lib/systemd/systemd"]
DOCKERFILE
)" || { fail "runtime build failed"; rm -f "$CACHE_DIR/fips-bin-for-image" "$CACHE_DIR/fips-gateway-bin-for-image"; return; }
rm -f "$CACHE_DIR/fips-bin-for-image" "$CACHE_DIR/fips-gateway-bin-for-image"
start_systemd_container_with_tun "$name" "$image"
wait_for_systemd "$name"
# Write a minimal fips.yaml that exercises the new defaults.
# tun.enabled: true so the daemon creates fips0 itself; identity
# persistent so /etc/fips/fips.pub gives us a stable npub to query.
docker exec "$name" bash -c 'cat > /etc/fips/fips.yaml <<EOF
node:
identity:
persistent: true
log_level: debug
tun:
enabled: true
name: fips0
dns:
enabled: true
port: 5354
EOF'
# Start fips in the background.
log "Starting fips in container"
docker exec -d "$name" bash -c '/usr/bin/fips --config /etc/fips/fips.yaml >/var/log/fips.log 2>&1'
# Wait for the DNS responder to bind.
local ready=0
for _i in $(seq 1 "$DAEMON_TIMEOUT"); do
if docker exec "$name" ss -uln 2>/dev/null | grep -q ':5354'; then
ready=1
break
fi
sleep 1
done
if [ "$ready" = "1" ]; then
pass "fips DNS listener up on port 5354"
else
fail "fips DNS listener did not appear within ${DAEMON_TIMEOUT}s"
echo " --- fips log ---"
docker exec "$name" tail -30 /var/log/fips.log 2>&1 || true
echo " --- ss -uln ---"
docker exec "$name" ss -ulnp 2>&1 || true
cleanup_container "$name"
return
fi
# Confirm the daemon picked up the new ::1 default bind. Strip
# ANSI color codes from the log line before matching since the
# tracing-subscriber default formatter wraps fields in escape codes.
local bind_line
bind_line=$(docker exec "$name" grep -m1 "DNS responder started" /var/log/fips.log 2>/dev/null \
| sed -r 's/\x1b\[[0-9;]*m//g' || echo "")
echo " daemon log: $bind_line"
if echo "$bind_line" | grep -qE "bind=\[?::1\]?:5354"; then
pass "daemon bound on [::1]:5354 (new default)"
else
fail "daemon bind line missing or wrong: $bind_line"
fi
# Run setup.
local output
output=$(run_setup "$name" 2>&1)
echo " setup output: $output"
# Pick expected backend based on systemd version: dns-delegate
# on >= 258, global-drop-in otherwise. Either way the backend
# must target [::1]:5354 for the daemon to receive queries.
local ver
ver=$(container_systemd_version "$name")
local expected_backend
if [ -n "$ver" ] && [ "$ver" -ge 258 ]; then
expected_backend="dns-delegate"
else
expected_backend="global-drop-in"
fi
local backend
backend=$(get_backend "$name")
if [ "$backend" = "$expected_backend" ]; then
pass "setup picked $expected_backend backend (systemd $ver)"
else
fail "expected $expected_backend (systemd $ver), got: $backend"
fi
# Wait briefly for systemd-resolved to apply the new config.
sleep 2
# Pull the daemon's npub from the persistent identity file.
local npub
npub=$(docker exec "$name" cat /etc/fips/fips.pub 2>/dev/null | tr -d '[:space:]')
if [ -z "$npub" ]; then
fail "no /etc/fips/fips.pub after daemon start"
echo " --- /etc/fips ---"
docker exec "$name" ls -la /etc/fips/ 2>&1 || true
cleanup_container "$name"
return
fi
echo " daemon npub: $npub"
# Direct dig to the daemon's loopback bind — must succeed.
local direct_output
direct_output=$(docker exec "$name" dig +tries=1 +time=3 @::1 -p 5354 AAAA "${npub}.fips" 2>&1)
if echo "$direct_output" | grep -qE '^[a-zA-Z0-9].*\sAAAA\s+[0-9a-f:]+'; then
pass "direct dig @::1#5354 returns AAAA"
else
fail "direct dig @::1#5354 did not return AAAA"
echo " --- dig output ---"
echo "$direct_output" | tail -15
fi
# End-to-end via systemd-resolved stub.
local stub_output
stub_output=$(docker exec "$name" dig +tries=1 +time=3 @127.0.0.53 AAAA "${npub}.fips" 2>&1)
if echo "$stub_output" | grep -qE '^[a-zA-Z0-9].*\sAAAA\s+[0-9a-f:]+'; then
pass "end-to-end dig @127.0.0.53 returns AAAA (the bug fix)"
else
fail "end-to-end dig @127.0.0.53 did not return AAAA"
echo " --- dig output ---"
echo "$stub_output" | tail -15
echo " --- resolved status ---"
docker exec "$name" resolvectl status 2>&1 | tail -20 || true
echo " --- daemon log tail ---"
docker exec "$name" tail -30 /var/log/fips.log 2>&1 || true
fi
# Verify fips-gateway's DNS upstream reachability check passes
# against the daemon's new ::1 default. This locks the regression
# class where the gateway default (was 127.0.0.1:5354) and the
# daemon default (now [::1]:5354) drift apart on Linux IPv6
# sockets that don't accept v4-mapped traffic.
docker exec "$name" bash -c 'cat > /tmp/gateway-test.yaml <<EOF
node:
identity:
persistent: true
gateway:
enabled: true
pool: "fd01::/112"
lan_interface: "eth0"
EOF'
# fips-gateway checks IPv6 forwarding before the DNS upstream
# reachability check; enable forwarding so we get to the check we
# actually want to test.
docker exec "$name" sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true
docker exec -d "$name" bash -c '/usr/bin/fips-gateway --config /tmp/gateway-test.yaml >/var/log/fips-gateway.log 2>&1 || true'
# Wait briefly for the upstream-reachability log line to appear
# one way or the other.
local gw_ok=0
for _i in $(seq 1 5); do
if docker exec "$name" grep -q "DNS upstream is reachable" /var/log/fips-gateway.log 2>/dev/null; then
gw_ok=1
break
fi
if docker exec "$name" grep -qE "DNS upstream did not respond|Failed to send DNS probe|DNS upstream recv failed" /var/log/fips-gateway.log 2>/dev/null; then
break
fi
sleep 1
done
if [ "$gw_ok" = "1" ]; then
pass "fips-gateway reaches DNS upstream at [::1]:5354 (gateway/daemon default parity)"
else
fail "fips-gateway DNS upstream check failed — defaults drifted?"
echo " --- fips-gateway log ---"
docker exec "$name" tail -20 /var/log/fips-gateway.log 2>&1 || true
fi
# Stop the gateway (it will likely have failed past the upstream
# check on something unrelated in this minimal container — we only
# care that the upstream reachability step succeeded).
docker exec "$name" pkill -f fips-gateway 2>/dev/null || true
# Teardown via the script: backend config file must be removed
# (path varies by backend selected above).
local teardown_path
if [ "$expected_backend" = "dns-delegate" ]; then
teardown_path="/etc/systemd/dns-delegate.d/fips.dns-delegate"
else
teardown_path="/etc/systemd/resolved.conf.d/fips.conf"
fi
run_teardown "$name" >/dev/null 2>&1
if ! file_exists "$name" "$teardown_path"; then
pass "teardown removed $expected_backend config at $teardown_path"
else
fail "$expected_backend config still present after teardown at $teardown_path"
fi
cleanup_container "$name"
}
# Per-distro wrappers
_pkgs_with_resolved="systemd systemd-resolved iproute2 dbus dnsutils libdbus-1-3 procps"
_pkgs_ubuntu22="systemd iproute2 dbus dnsutils libdbus-1-3 procps"
test_e2e_debian12() { _run_e2e_scenario debian12 debian:12 "$_pkgs_with_resolved"; }
test_e2e_debian13() { _run_e2e_scenario debian13 debian:trixie "$_pkgs_with_resolved"; }
test_e2e_ubuntu22() { _run_e2e_scenario ubuntu22 ubuntu:22.04 "$_pkgs_ubuntu22"; }
test_e2e_ubuntu24() { _run_e2e_scenario ubuntu24 ubuntu:24.04 "$_pkgs_with_resolved"; }
test_e2e_ubuntu26() { _run_e2e_scenario ubuntu26 ubuntu:26.04 "$_pkgs_with_resolved"; }
# ───────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────
# Main # Main
# ───────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────
ALL_SCENARIOS="debian12-resolved debian13-resolved dnsmasq nm-dnsmasq no-resolver" ALL_SCENARIOS="debian12-resolved debian13-resolved ubuntu22-resolved ubuntu24-resolved ubuntu26-resolved dnsmasq nm-dnsmasq no-resolver e2e-debian12 e2e-debian13 e2e-ubuntu22 e2e-ubuntu24 e2e-ubuntu26"
if [ $# -eq 0 ]; then if [ $# -eq 0 ]; then
scenarios="$ALL_SCENARIOS" scenarios="$ALL_SCENARIOS"
@@ -371,9 +864,17 @@ for scenario in $scenarios; do
case "$scenario" in case "$scenario" in
debian12-resolved) test_debian12_resolved ;; debian12-resolved) test_debian12_resolved ;;
debian13-resolved) test_debian13_resolved ;; debian13-resolved) test_debian13_resolved ;;
ubuntu22-resolved) test_ubuntu22_resolved ;;
ubuntu24-resolved) test_ubuntu24_resolved ;;
ubuntu26-resolved) test_ubuntu26_resolved ;;
dnsmasq) test_dnsmasq ;; dnsmasq) test_dnsmasq ;;
nm-dnsmasq) test_nm_dnsmasq ;; nm-dnsmasq) test_nm_dnsmasq ;;
no-resolver) test_no_resolver ;; no-resolver) test_no_resolver ;;
e2e-debian12) test_e2e_debian12 ;;
e2e-debian13) test_e2e_debian13 ;;
e2e-ubuntu22) test_e2e_ubuntu22 ;;
e2e-ubuntu24) test_e2e_ubuntu24 ;;
e2e-ubuntu26) test_e2e_ubuntu26 ;;
*) *)
echo "Unknown scenario: $scenario" echo "Unknown scenario: $scenario"
echo "Available: $ALL_SCENARIOS" echo "Available: $ALL_SCENARIOS"