diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f5f1fdc..b60ae603 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1007,11 +1007,12 @@ jobs: - type: deb-install scenario: ubuntu26 arch: amd64 - # The arm64 package on the oldest supported distribution: a fresh - # install and a daemon start. Deliberately GitHub-only (the local host - # is x86_64), and deliberately one leg: the upgrade, purge and - # conffile paths run under debian12 on amd64 only and stay - # unexercised on arm64. + # The arm64 package on the oldest supported distribution: the install + # scenario, which covers a fresh install, a daemon start and a purge + # of the DNS routing. Deliberately GitHub-only (the local host is + # x86_64), and deliberately one leg: the upgrade and conffile paths, + # including the upgrade scenario's own purge, run under debian12 on + # amd64 only and stay unexercised on arm64. - type: deb-install scenario: ubuntu22 arch: arm64 diff --git a/packaging/debian/postrm b/packaging/debian/postrm index 70efcfd6..8f1afd5d 100755 --- a/packaging/debian/postrm +++ b/packaging/debian/postrm @@ -13,10 +13,45 @@ case "$1" in # Remove runtime directory rm -rf /run/fips/ - # Remove DNS config files that fips-dns-setup may have created + # Remove the DNS routing fips-dns-setup may have written, in case + # fips-dns-teardown did not run (prerm's stop runs it only when + # fips-dns.service was active), and make the resolver drop it. The + # paths match packaging/common/fips-dns-teardown, which dpkg has + # already removed, so it cannot be called from here. + restart_resolved=0 + if [ -f /etc/systemd/dns-delegate.d/fips.dns-delegate ]; then + rm -f /etc/systemd/dns-delegate.d/fips.dns-delegate + restart_resolved=1 + fi + if [ -f /etc/systemd/resolved.conf.d/fips.conf ]; then + rm -f /etc/systemd/resolved.conf.d/fips.conf + restart_resolved=1 + fi + # Only pre-v0.3.0 development builds wrote this path, and systemd + # never read it. rm -f /etc/systemd/dns-delegate/fips.dns-delegate - rm -f /etc/dnsmasq.d/fips.conf - rm -f /etc/NetworkManager/dnsmasq.d/fips.conf + if [ "$restart_resolved" = 1 ] && [ -d /run/systemd/system ] \ + && systemctl is-active --quiet systemd-resolved.service; then + systemctl restart systemd-resolved \ + || echo "fips: warning: could not restart systemd-resolved; restart it to drop the .fips route" + fi + if [ -f /etc/dnsmasq.d/fips.conf ]; then + rm -f /etc/dnsmasq.d/fips.conf + if [ -d /run/systemd/system ] \ + && systemctl is-active --quiet dnsmasq.service; then + systemctl reload dnsmasq \ + || echo "fips: warning: could not reload dnsmasq; reload it to drop the .fips route" + fi + fi + if [ -f /etc/NetworkManager/dnsmasq.d/fips.conf ]; then + rm -f /etc/NetworkManager/dnsmasq.d/fips.conf + if [ -d /run/systemd/system ] \ + && systemctl is-active --quiet NetworkManager.service \ + && command -v nmcli >/dev/null 2>&1; then + nmcli general reload \ + || echo "fips: warning: could not reload NetworkManager; reload it to drop the .fips route" + fi + fi # Remove fips system group if getent group fips >/dev/null 2>&1; then diff --git a/packaging/systemd/uninstall.sh b/packaging/systemd/uninstall.sh index 251bb897..be77d8c2 100755 --- a/packaging/systemd/uninstall.sh +++ b/packaging/systemd/uninstall.sh @@ -42,10 +42,54 @@ rm -rf /usr/lib/fips/ systemctl daemon-reload echo "systemd units and DNS scripts removed." -# Clean up DNS config files that fips-dns-setup may have created +# --- Remove DNS routing --- +# fips-dns-setup may have written one of these, and stopping fips-dns.service +# above runs fips-dns-teardown only when the unit was active. The paths match +# packaging/common/fips-dns-teardown. + +restart_resolved=false +if [ -f /etc/systemd/dns-delegate.d/fips.dns-delegate ]; then + rm -f /etc/systemd/dns-delegate.d/fips.dns-delegate + echo "Removed /etc/systemd/dns-delegate.d/fips.dns-delegate." + restart_resolved=true +fi +if [ -f /etc/systemd/resolved.conf.d/fips.conf ]; then + rm -f /etc/systemd/resolved.conf.d/fips.conf + echo "Removed /etc/systemd/resolved.conf.d/fips.conf." + restart_resolved=true +fi +# Only pre-v0.3.0 development builds wrote this path, and systemd never read it. rm -f /etc/systemd/dns-delegate/fips.dns-delegate -rm -f /etc/dnsmasq.d/fips.conf -rm -f /etc/NetworkManager/dnsmasq.d/fips.conf +if $restart_resolved && systemctl is-active --quiet systemd-resolved.service 2>/dev/null; then + if systemctl restart systemd-resolved; then + echo "systemd-resolved restarted." + else + echo "Warning: could not restart systemd-resolved; restart it to drop the .fips route." >&2 + fi +fi +if [ -f /etc/dnsmasq.d/fips.conf ]; then + rm -f /etc/dnsmasq.d/fips.conf + echo "Removed /etc/dnsmasq.d/fips.conf." + if systemctl is-active --quiet dnsmasq.service 2>/dev/null; then + if systemctl reload dnsmasq; then + echo "dnsmasq reloaded." + else + echo "Warning: could not reload dnsmasq; reload it to drop the .fips route." >&2 + fi + fi +fi +if [ -f /etc/NetworkManager/dnsmasq.d/fips.conf ]; then + rm -f /etc/NetworkManager/dnsmasq.d/fips.conf + echo "Removed /etc/NetworkManager/dnsmasq.d/fips.conf." + if systemctl is-active --quiet NetworkManager.service 2>/dev/null \ + && command -v nmcli >/dev/null 2>&1; then + if nmcli general reload; then + echo "NetworkManager reloaded." + else + echo "Warning: could not reload NetworkManager; reload it to drop the .fips route." >&2 + fi + fi +fi # --- Remove tmpfiles.d entry --- diff --git a/src/packaging_tests.rs b/src/packaging_tests.rs index b026bb52..d7005ac7 100644 --- a/src/packaging_tests.rs +++ b/src/packaging_tests.rs @@ -161,6 +161,33 @@ fn logical_lines(sh: &str) -> Vec { out } +/// Returns the logical lines of a shell script, trimmed at both ends, without +/// the lines that are comments. +fn code_lines(sh: &str) -> Vec { + logical_lines(sh) + .into_iter() + .map(|l| l.trim().to_string()) + .filter(|l| !l.starts_with('#')) + .collect() +} + +/// Returns the code lines of the `case` branch `label)` in a shell script: +/// those after the line that trims to `label)`, up to the next line that trims +/// to `;;`. +fn case_branch(sh: &str, label: &str) -> Vec { + let open = format!("{label})"); + let lines = code_lines(sh); + let start = 1 + lines + .iter() + .position(|l| *l == open) + .unwrap_or_else(|| panic!("no `{open}` branch found")); + let len = lines[start..] + .iter() + .position(|l| l == ";;") + .unwrap_or_else(|| panic!("`{open}` branch is never closed with `;;`")); + lines[start..start + len].to_vec() +} + #[test] fn deb_and_aur_packages_declare_nftables_for_the_firewall_units_nft() { let unit = repo_file("packaging/debian/fips-firewall.service"); @@ -316,3 +343,77 @@ fn freebsd_newsyslog_entry_signals_the_daemon8_supervisor_started_with_sighup_re "build-pkg.sh pkg-plist does not list etc/newsyslog.conf.d/fips.conf: {plist:?}" ); } + +/// Pins the DNS cleanup in `postrm purge` and `uninstall.sh` to the files +/// `fips-dns-setup` writes, so a purge after a `fips-dns` that never ran its +/// teardown does not leave the resolver sending `.fips` to a dead responder. +/// +/// This is a text test. Each path must appear on an `rm -f` line, but a +/// resolver command passes wherever it appears on a code line, including in a +/// message. What `postrm` actually does is covered by the deb-install purge +/// check. No suite runs `uninstall.sh`: its two resolved paths were run once, +/// by hand in a container, and its dnsmasq and NetworkManager paths by nothing. +#[test] +fn dns_cleanup_in_postrm_purge_and_uninstall_removes_every_file_fips_dns_setup_writes_and_restarts_its_resolver() + { + let setup = rc_vars(&repo_file("packaging/common/fips-dns-setup")); + let teardown = rc_vars(&repo_file("packaging/common/fips-dns-teardown")); + let paths: Vec<&str> = [ + "DNS_DELEGATE_FILE", + "RESOLVED_DROPIN_FILE", + "DNSMASQ_CONF", + "NM_DNSMASQ_CONF", + ] + .into_iter() + .map(|name| { + let path = setup + .get(name) + .filter(|p| p.starts_with('/')) + .unwrap_or_else(|| panic!("fips-dns-setup sets no absolute {name}")); + assert_eq!( + teardown.get(name), + Some(path), + "fips-dns-teardown's {name} is not the file fips-dns-setup writes" + ); + path.as_str() + }) + .collect(); + let commands = [ + "restart systemd-resolved", + "reload dnsmasq", + "nmcli general reload", + ]; + + let scripts = [ + ( + "packaging/debian/postrm purge)", + case_branch(&repo_file("packaging/debian/postrm"), "purge"), + ), + ( + "packaging/systemd/uninstall.sh", + code_lines(&repo_file("packaging/systemd/uninstall.sh")), + ), + ]; + let mut missing = Vec::new(); + for (script, lines) in &scripts { + for path in &paths { + if !lines + .iter() + .any(|l| l.contains("rm -f") && l.contains(path)) + { + missing.push(format!("{script}: no `rm -f` of {path}")); + } + } + for command in commands { + if !lines.iter().any(|l| l.contains(command)) { + missing.push(format!("{script}: never runs `{command}`")); + } + } + } + assert!( + missing.is_empty(), + "DNS cleanup does not match the files fips-dns-setup writes and the resolvers \ + fips-dns-teardown restarts:\n {}", + missing.join("\n ") + ); +} diff --git a/testing/deb-install/test.sh b/testing/deb-install/test.sh index 31f421a2..9b46c0b4 100755 --- a/testing/deb-install/test.sh +++ b/testing/deb-install/test.sh @@ -8,13 +8,16 @@ # to come up, and verifies that `dig @127.0.0.53 AAAA .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. +# same daemon to verify the gateway/daemon default-pairing. Finally it +# purges the package with the DNS routing file planted and fips-dns +# stopped, and checks the file is removed and systemd-resolved restarted. # # 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) +# - postrm purge (removing the DNS routing fips-dns-setup wrote) # - The fips, fips-dns, and (optionally) fips-gateway systemd units # - End-to-end .fips resolution as a real user would experience it # @@ -301,6 +304,111 @@ EOF return } +# Purge the package with the DNS routing file planted and fips-dns stopped, and +# check that postrm removes the file and restarts systemd-resolved. +# +# Stopping fips-dns runs fips-dns-teardown, which removes the file; putting it +# back gives the state in which removal leaves it behind: a live delegation and +# an inactive fips-dns, so prerm's stop runs no teardown. Only postrm purge is +# left to clean up, and a file it misses keeps the resolver sending .fips to +# [::1]:5354 after nothing listens there. +# +# Args: , the backend the scenario expects +# fips-dns-setup to pick (dns-delegate or global-drop-in). +check_purge_clears_dns() { + local name="$1" backend="$2" file + case "$backend" in + dns-delegate) file=/etc/systemd/dns-delegate.d/fips.dns-delegate ;; + global-drop-in) file=/etc/systemd/resolved.conf.d/fips.conf ;; + *) + fail "purge: no DNS routing file known for backend '$backend'" + return + ;; + esac + + # The gateway-enable restart of fips.service is passed on to fips-dns + # (Requires=fips.service), whose setup waits for fips0 before it writes the + # file, and nothing since has waited for it. After=fips.service stops the + # old instance before the daemon, so active here means the new setup ran. + if ! wait_for_service_active "$name" fips-dns.service; then + fail "purge: fips-dns.service not active again after the gateway-enable restart" + echo " --- fips-dns.service journal ---" + docker exec "$name" journalctl -u fips-dns.service --no-pager 2>&1 | tail -20 + return + fi + if ! cexec "$name" test -f "$file"; then + fail "purge: $file not written by fips-dns-setup before the purge" + return + fi + # Saved inside the container: cexec runs docker exec without -i, so a + # copy piped back from the host would arrive empty. + if ! cexec "$name" cp "$file" /root/fips-dns.saved; then + fail "purge: could not save $file" + return + fi + + cexec "$name" systemctl stop fips-dns.service >/dev/null 2>&1 + cexec "$name" cp /root/fips-dns.saved "$file" + cexec "$name" systemctl restart systemd-resolved >/dev/null 2>&1 + + local ok=1 status + if ! cexec "$name" sh -c "test -s '$file' && cmp -s '$file' /root/fips-dns.saved"; then + fail "purge: $file not restored before the purge" + ok=0 + fi + if cexec "$name" systemctl is-active --quiet fips-dns.service; then + fail "purge: fips-dns.service still active before the purge" + ok=0 + fi + # Captured rather than piped into grep -q: under pipefail, grep closing the + # pipe early can fail the pipeline on a match. + if ! status=$(cexec "$name" resolvectl status 2>&1); then + fail "purge: resolvectl status failed before the purge" + echo "$status" | tail -10 + ok=0 + elif ! grep -q ':5354' <<<"$status"; then + fail "purge: resolvectl status does not show $file in effect before the purge" + echo "$status" | tail -25 + ok=0 + fi + [ "$ok" = 1 ] || return + local before after + before=$(cexec "$name" systemctl show -p InvocationID --value systemd-resolved) + + run_apt "$name" "$UPGRADE_APT_TIMEOUT" purge -y fips + echo " purge took ${APT_SECS}s" + if [ "$APT_RC" -ne 0 ]; then + fail "purge: apt-get purge exited $APT_RC" + echo "$APT_OUT" | tail -20 + return + fi + + # test exits 1 for a missing file; any other failure is docker exec's. + local rc=0 + cexec "$name" test -e "$file" || rc=$? + case "$rc" in + 1) pass "purge removed $file" ;; + 0) fail "purge left $file behind" ;; + *) fail "purge: could not check for $file (exit $rc)" ;; + esac + after=$(cexec "$name" systemctl show -p InvocationID --value systemd-resolved) + if [ -n "$before" ] && [ -n "$after" ] && [ "$before" != "$after" ]; then + pass "purge restarted systemd-resolved" + else + fail "purge did not restart systemd-resolved (InvocationID '$before' -> '$after')" + fi + if ! status=$(cexec "$name" resolvectl status 2>&1); then + fail "purge: resolvectl status failed after the purge" + echo "$status" | tail -10 + elif grep -q ':5354' <<<"$status"; then + fail "purge: resolvectl status still routes to port 5354" + echo "$status" | tail -25 + else + pass "purge: resolvectl status no longer routes to port 5354" + fi + return +} + # ───────────────────────────────────────────────────────────────────── # Scenario runner # @@ -601,6 +709,8 @@ DOCKERFILE docker exec "$name" journalctl -u fips-gateway.service --no-pager 2>&1 | tail -15 fi + check_purge_clears_dns "$name" "$expected_backend" + cleanup_container "$name" }