testsAndMisc/linux_configuration/features/lib/tests/test_dot_resolver_install.sh
2026-10-02 18:02:44 +02:00

195 lines
8.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# Tests for dot_resolver_install.sh — the DoT resolver's install half.
#
# Every function here sudo-writes a systemd unit, an stunnel config or an
# nftables rule against LIVE infrastructure (dns.kuhy.duckdns.org:853). They
# are executed for real, not mocked, because the generated CONTENT is the
# thing worth asserting -- a unit file with the wrong ExecStart is exactly the
# bug a presence check would miss.
#
# This is only safe under meta/scripts/shell_coverage_jail.sh, which runs the
# suite inside a user+mount namespace with /etc, /usr/local/bin and /var/lib
# bind-mounted to a throwaway dir. Run directly on a real host it would
# rewrite the resolver, so it refuses to start unless the jail is in place.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./features_harness.sh
. "$SCRIPT_DIR/features_harness.sh"
# --- Refuse to run outside the jail -----------------------------------------
# The jail bind-mounts /etc, so the real resolver's config is absent inside it.
# Seeing the host's own stunnel config means we are NOT contained.
if [[ -f /etc/stunnel/dot-resolver.conf ]] && [[ ! -w /etc ]]; then
printf 'REFUSING: this suite must run under shell_coverage_jail.sh\n' >&2
exit 1
fi
_t_setup_env
trap _t_teardown EXIT
# The values the entry script defines above its source line.
SCRIPT_NAME="setup_dot_resolver.sh"
CERT_DIR="/etc/stunnel/dot"
STUNNEL_CONF="/etc/stunnel/dot-resolver.conf"
SYNC_SCRIPT="/usr/local/bin/sync-dot-cert.sh"
DOT_PORT=853
DOMAIN="dns.kuhy.duckdns.org"
BIND_ADDR="10.8.0.1"
CADDY_CONTAINER="caddy"
# install_cert_sync interpolates this at GENERATION time (the heredoc is
# unquoted), baking the resolved path into the script it writes. That is
# deliberate, not a bug -- the live /usr/local/bin/sync-dot-cert.sh carries a
# fully populated src=. The fixture must therefore define it exactly as the
# entry script does, or the generated script gets src="" and the assertion
# below silently passes over an empty path.
cert_source_dir() {
printf '/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/%s' \
"$DOMAIN"
}
# shellcheck source=../dot_resolver_install.sh
. "$FEATURES_LIB_DIR/dot_resolver_install.sh"
# Fixture self-check: a typo in one of these names would turn every assertion
# below into a silent no-op rather than a failure.
[[ -n $CERT_DIR && -n $STUNNEL_CONF && -n $SYNC_SCRIPT ]] ||
{
printf 'fixture globals are not populated\n' >&2
exit 1
}
mkdir -p /etc/stunnel /etc/systemd/system /usr/local/bin
# --- install_cert_sync ------------------------------------------------------
install_cert_sync
_t_file_has "$SYNC_SCRIPT" 'combined.pem' "cert sync script assembles combined.pem"
_t_file_has "$SYNC_SCRIPT" "src=\"/data/caddy/certificates" "cert source path is baked in, not left empty"
_t_file_has "$SYNC_SCRIPT" 'reload-or-restart' "cert sync restarts stunnel after renewal"
if [[ -d $CERT_DIR ]]; then
_t_pass "cert dir created"
else
_t_fail "cert dir created"
fi
_t_eq "700" "$(stat -c '%a' "$CERT_DIR")" "cert dir is mode 700 (it holds a private key)"
_t_eq "755" "$(stat -c '%a' "$SYNC_SCRIPT")" "sync script is executable"
# --- install_stunnel_conf ---------------------------------------------------
install_stunnel_conf
_t_file_has "$STUNNEL_CONF" "accept = $BIND_ADDR:$DOT_PORT" "stunnel accepts on the bind address"
_t_file_has "$STUNNEL_CONF" 'connect = 127.0.0.1:53' "stunnel forwards to local dnsmasq"
_t_file_has "$STUNNEL_CONF" 'sslVersionMin = TLSv1.2' "stunnel refuses pre-TLS1.2 (Android requires it)"
_t_file_has "$STUNNEL_CONF" "cert = $CERT_DIR/combined.pem" "stunnel reads the synced cert"
# --- install_units ----------------------------------------------------------
_t_stub systemctl
install_units
_t_file_has /etc/systemd/system/stunnel-dot.service 'stunnel' "stunnel unit installed"
# Requires= turned one failed dnsmasq start at boot into a DoT outage that
# outlived dnsmasq's own 5 s recovery; Wants= lets stunnel start regardless.
_t_file_has /etc/systemd/system/stunnel-dot.service '^Wants=dnsmasq\.service$' "stunnel only Wants dnsmasq"
if grep -q '^Requires=dnsmasq' /etc/systemd/system/stunnel-dot.service; then
_t_fail "Requires=dnsmasq.service would fail stunnel whenever dnsmasq's first start fails"
else
_t_pass "no Requires=dnsmasq.service"
fi
_t_file_has /etc/systemd/system/dot-cert-sync.service "$SYNC_SCRIPT" "cert-sync service runs the sync script"
_t_file_has /etc/systemd/system/dot-cert-sync.timer 'OnCalendar' "cert-sync timer is scheduled"
_t_called 'daemon-reload' "units are followed by a daemon-reload"
# --- open_firewall: the WireGuard-only branch -------------------------------
# nft absent entirely: the function must still return cleanly rather than
# aborting the install on a host without nftables.
out="$(open_firewall 2>&1)"
_t_has "$out" 'WireGuard-only' "wg-bound install needs no router forward"
# nft present and the rule already there: must not add a duplicate.
_t_stub nft
printf '#!/usr/bin/env bash\nprintf "nft %%s\\n" "$*" >>"%s/calls.log"\nif [[ $* == *list* ]]; then printf "iifname \\"wg0\\" tcp dport %s accept\\n" ; fi\nexit 0\n' \
"$TEST_TMPDIR" "$DOT_PORT" >"$TEST_TMPDIR/bin/nft"
chmod +x "$TEST_TMPDIR/bin/nft"
out="$(open_firewall 2>&1)"
_t_has "$out" 'already permits' "an existing wg0 rule is not duplicated"
# nft present and the rule missing: must add it, scoped to wg0.
printf '#!/usr/bin/env bash\nprintf "nft %%s\\n" "$*" >>"%s/calls.log"\nexit 0\n' \
"$TEST_TMPDIR" >"$TEST_TMPDIR/bin/nft"
chmod +x "$TEST_TMPDIR/bin/nft"
out="$(open_firewall 2>&1)"
_t_has "$out" 'wg0 only' "a missing wg0 rule is added"
_t_called 'add rule inet filter input iifname wg0' "the added rule is scoped to the tunnel"
# nft present but the ADD fails (a locked-down or read-only ruleset): the
# install must warn and carry on, never abort. `nft list` still has to succeed
# or the outer guard short-circuits before the add is attempted.
cat >"$TEST_TMPDIR/bin/nft" <<'NFT'
#!/usr/bin/env bash
printf 'nft %s\n' "$*" >>"$TEST_TMPDIR/calls.log"
[[ $* == *add* ]] && exit 1
exit 0
NFT
chmod +x "$TEST_TMPDIR/bin/nft"
out="$(open_firewall 2>&1)"
_t_has "$out" 'WARNING: could not add nftables rule' "a failed wg0 rule add warns instead of aborting"
# --- open_firewall: the publicly-bound branch -------------------------------
# A public bind is an open resolver, so the rule MUST carry a rate limit.
BIND_ADDR="0.0.0.0"
out="$(open_firewall 2>&1)"
_t_has "$out" 'rate limited' "a public bind is rate limited"
_t_called 'limit rate 25/second burst 50' "the public rule carries the documented limit"
_t_has "$out" 'router forwards' "a public bind asks for a router forward"
# The same add-failure path on the public branch.
out="$(open_firewall 2>&1)"
_t_has "$out" 'WARNING: could not add rate-limited nftables rule' \
"a failed public rule add warns instead of aborting"
BIND_ADDR="10.8.0.1"
# --- run_check --------------------------------------------------------------
# Everything healthy: stunnel active, port listening, cert present.
printf '#!/usr/bin/env bash\nexit 0\n' >"$TEST_TMPDIR/bin/systemctl"
chmod +x "$TEST_TMPDIR/bin/systemctl"
printf '#!/usr/bin/env bash\nprintf "LISTEN 0 0 10.8.0.1:%s\\n"\n' "$DOT_PORT" >"$TEST_TMPDIR/bin/ss"
chmod +x "$TEST_TMPDIR/bin/ss"
install -m 600 /dev/null "$CERT_DIR/combined.pem"
printf 'cert\n' >"$CERT_DIR/combined.pem"
if run_check >/dev/null 2>&1; then
_t_pass "run_check passes on a healthy install"
else
_t_fail "run_check passes on a healthy install"
fi
# stunnel down: must fail and say which check failed.
printf '#!/usr/bin/env bash\n[[ $* == *is-active* ]] && exit 1\nexit 0\n' >"$TEST_TMPDIR/bin/systemctl"
chmod +x "$TEST_TMPDIR/bin/systemctl"
out="$(run_check 2>&1 || true)"
_t_has "$out" 'FAIL stunnel-dot.service not active' "a stopped stunnel is reported"
# Certificate missing: the check that must be run as root to be correct.
rm -f "$CERT_DIR/combined.pem"
printf '#!/usr/bin/env bash\nexit 0\n' >"$TEST_TMPDIR/bin/systemctl"
chmod +x "$TEST_TMPDIR/bin/systemctl"
out="$(run_check 2>&1 || true)"
_t_has "$out" 'FAIL certificate missing' "a missing certificate is reported"
# Nothing listening on the port.
printf '#!/usr/bin/env bash\nexit 0\n' >"$TEST_TMPDIR/bin/ss"
chmod +x "$TEST_TMPDIR/bin/ss"
out="$(run_check 2>&1 || true)"
_t_has "$out" 'FAIL nothing listening' "a silent port is reported"
# kdig present and the DoT query failing. The check is skipped entirely when
# kdig is absent, so this arm only exists on a host that has knot-utils --
# which makes it exactly the arm most likely to rot unnoticed.
printf '#!/usr/bin/env bash\nexit 1\n' >"$TEST_TMPDIR/bin/kdig"
chmod +x "$TEST_TMPDIR/bin/kdig"
printf 'cert\n' >"$CERT_DIR/combined.pem"
printf '#!/usr/bin/env bash\nprintf "LISTEN 0 0 10.8.0.1:%s\\n"\n' "$DOT_PORT" >"$TEST_TMPDIR/bin/ss"
chmod +x "$TEST_TMPDIR/bin/ss"
out="$(run_check 2>&1 || true)"
_t_has "$out" 'FAIL DoT query did not answer' "an unanswered DoT query is reported"
_t_report "test_dot_resolver_install.sh"