Files
fips/testing/check-pfsense-pkg.sh
fr34akyandJohnathan Corgan 69a512f7e8 fix(pfsense): restart unbound after editing the DNS Resolver options
Symptom: on pfSense Plus 26.07 amd64, fips-dns-setup wrote the .fips
forward-zone into the DNS Resolver custom options, config.xml and the
regenerated /var/unbound/unbound.conf both carried it, and the helper
reported "DNS Resolver updated and restarted" — but `drill <npub>.fips`
through unbound answered NXDOMAIN, and `unbound-control lookup` showed
the query still going to the root servers. The daemon answered the same
query directly. fips-dns-teardown had the mirror problem: the block was
gone from the file while the running resolver kept forwarding.

Root cause: the helper called sync_unbound_service(), which regenerates
unbound.conf and then only *starts* unbound, a no-op while an instance
is already running, so the running resolver never saw the new file.

Fix: call services_unbound_configure(), which is what the GUI's Apply
runs (services_unbound.php): it TERMs the running unbound, waits, and
starts it on the regenerated configuration. Verified on the same VM:
teardown and setup each produce a new unbound pid, `unbound-control
lookup` reports "forwarding request", and the full chain answers
NOERROR; after a reboot the block is regenerated from config.xml and the
chain still answers.

Regression check: check-pfsense-pkg.sh now asserts, statically, that the
shipped helper calls services_unbound_configure() and not
sync_unbound_service(); the call itself needs pfSense's includes and
cannot run in CI. The README's test record gains the Plus 26.07 amd64 VM
run that found this.
2026-09-18 13:13:52 +00:00

586 lines
25 KiB
Bash
Executable File

#!/usr/bin/env bash
# ── pfSense package guard ───────────────────────────────────────────────────
# Validates a built pfSense .pkg and the scripts inside it.
#
# The pfSense package cannot be exercised the way the Docker suites exercise
# mesh behaviour: pfSense is not a container image, and Netgate publishes no
# base image to build one from. What IS checkable without a firewall — and is
# where this package's mistakes would actually live — is the handful of
# pfSense-specific decisions that separate it from the FreeBSD package, each of
# which fails silently on a real box if it regresses:
#
# - the boot script must be named *.sh, or pfSense's rc.start_packages glob
# never runs it and the daemon simply never starts, with no error anywhere;
# - `start` must be idempotent, because that same glob is re-run on every
# interface link change, IP change and gateway event;
# - a recycled pid must not be mistaken for a running daemon, which would
# make `start` a permanent no-op;
# - the shipped config must carry the IPv4 loopback bind, because a config
# that kept the ::1 default resolves nothing on a firewall with "Allow
# IPv6" off and says nothing about why;
# - fips.yaml must be 0600, because it may hold a node private key (nsec:).
#
# What this does NOT cover, stated so nobody reads a pass as more than it is:
# installing on pfSense, the config.xml edit in fips-unbound-custom.php (which
# needs pfSense's PHP and its config.inc), unbound actually answering .fips,
# and pf passing mesh traffic. Those are the manual steps in
# packaging/pfsense/README.md.
#
# Usage: testing/check-pfsense-pkg.sh [<package.pkg>]
# With no argument, builds one from target/release via build-pkg.sh --no-build.
# FreeBSD only: it needs pkg(8) to have produced the package.
#
# Exit 0 = every check passed. Exit 1 = at least one failed. Exit 2 = the check
# could not run; never treated as a pass.
# ─────────────────────────────────────────────────────────────────────────────
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PFSENSE_DIR="$PROJECT_ROOT/packaging/pfsense"
failures=0
checks=0
pass() { checks=$((checks + 1)); printf ' PASS %s\n' "$1"; }
fail() {
checks=$((checks + 1))
failures=$((failures + 1))
printf ' FAIL %s\n' "$1"
[[ $# -gt 1 ]] && printf ' %s\n' "$2"
return 0
}
bail() { printf 'check-pfsense-pkg: %s\n' "$1" >&2; exit 2; }
# A check that could not run here. Printed, not counted: a skipped check
# that reads as a pass would let a host without the tooling report a
# cleaner result than a host with it.
skip() { skipped=$((skipped + 1)); printf ' SKIP %s\n' "$1"; }
skipped=0
# Probed by capability rather than by `uname -s`: what this needs is a
# working pkg(8) to have produced and to read the package, and that is
# also true on a host whose uname comes from a Linux compat layer.
command -v pkg >/dev/null 2>&1 && pkg config abi >/dev/null 2>&1 \
|| bail "needs pkg(8) — run this on the FreeBSD host that built the package"
PKG="${1:-}"
if [[ -z "$PKG" ]]; then
"$PFSENSE_DIR/build-pkg.sh" --no-build >/dev/null \
|| bail "build-pkg.sh --no-build failed"
PKG=$(ls -t "$PROJECT_ROOT"/deploy/fips-*-pfsense-*.pkg 2>/dev/null | head -1)
fi
[[ -n "$PKG" && -f "$PKG" ]] || bail "no package to check (pass one as \$1)"
WORK=$(mktemp -d "${TMPDIR:-/tmp}/fips-pfsense-check.XXXXXX") || bail "mktemp failed"
trap 'rm -rf "$WORK"' EXIT
echo "==> checking $(basename "$PKG")"
MANIFEST="$WORK/manifest.json"
tar -xOf "$PKG" +MANIFEST > "$MANIFEST" 2>/dev/null \
|| bail "cannot read +MANIFEST from $PKG"
# Members are stored as absolute paths; bsdtar strips the leading "/" on
# extract, so the payload lands under $WORK/usr/local.
tar -xf "$PKG" -C "$WORK" 2>/dev/null || bail "cannot extract payload from $PKG"
PAYLOAD="$WORK/usr/local"
# ── 1. Contents ─────────────────────────────────────────────────────────────
echo "-- contents"
EXPECTED_PATHS="
bin/fips
bin/fipsctl
bin/fipstop
etc/fips/fips.conf.sample
etc/fips/fips.newsyslog
etc/fips/fips.yaml.sample
etc/fips/hosts.sample
etc/rc.d/fips.sh
libexec/fips/fips-dns-setup
libexec/fips/fips-dns-teardown
libexec/fips/fips-unbound-custom.php
"
for rel in $EXPECTED_PATHS; do
if [[ -e "$PAYLOAD/$rel" ]]; then
pass "ships $rel"
else
fail "missing $rel"
fi
done
# The whole reason this package exists separately from the FreeBSD one.
if [[ -e "$PAYLOAD/etc/rc.d/fips" && ! -e "$PAYLOAD/etc/rc.d/fips.sh" ]]; then
fail "boot script is rc.d/fips, not rc.d/fips.sh" \
"pfSense's rc.start_packages globs *.sh; a suffixless script never runs."
else
pass "boot script carries the .sh suffix pfSense globs for"
fi
for rel in etc/rc.d/fips.sh libexec/fips/fips-dns-setup libexec/fips/fips-dns-teardown; do
if [[ -x "$PAYLOAD/$rel" ]]; then
pass "$rel is executable"
else
fail "$rel is not executable"
fi
done
# ── 2. Manifest ─────────────────────────────────────────────────────────────
echo "-- manifest"
perm_of() {
grep -o "\"/usr/local/$1\":{[^}]*}" "$MANIFEST" \
| grep -o '"perm":"[0-7]*"' | head -1 | cut -d'"' -f4
return 0
}
# fips.yaml may hold a node private key (nsec:); every other platform's
# package installs it 0600 and so must this one.
yaml_perm=$(perm_of "etc/fips/fips.yaml.sample")
if [[ "$yaml_perm" == "0600" ]]; then
pass "fips.yaml.sample is 0600"
else
fail "fips.yaml.sample is ${yaml_perm:-unknown}, expected 0600" \
"It may carry an nsec: private key."
fi
abi=$(grep -o '"abi":"[^"]*"' "$MANIFEST" | head -1 | cut -d'"' -f4)
if [[ "$abi" =~ ^FreeBSD:[0-9]+:[a-z0-9_]+$ ]]; then
pass "manifest ABI is well-formed ($abi)"
else
fail "manifest ABI is malformed: ${abi:-unset}"
fi
# The ABI string is the only thing pkg checks on the appliance, and it is
# just text in the manifest: a package labelled for one architecture and
# filled with binaries for another installs cleanly and then cannot exec.
# So compare the label against the binaries actually in the payload.
abi_arch="${abi##*:}"
file_out=$(file -b "$PAYLOAD/bin/fips" 2>/dev/null)
case "$abi_arch" in
amd64) want="x86-64" ;;
aarch64) want="aarch64" ;;
i386) want="Intel 80386" ;;
*) want="" ;;
esac
if [[ -z "$want" ]]; then
fail "unrecognised ABI architecture '${abi_arch}'" "$file_out"
elif [[ "$file_out" == *"$want"* ]]; then
pass "binaries are ${abi_arch}, matching the manifest ABI"
else
fail "manifest says ${abi_arch} but bin/fips is not ${want}" \
"pkg trusts the label; the appliance would install this and fail to exec. file: ${file_out}"
fi
# Provenance annotations. These are the artifact's own record of which
# compiler built it and how it was linked; a package that lost them
# cannot answer "was this the pinned toolchain?" after the fact, which
# is the whole point of recording them.
ann_of() {
grep -o "\"$1\":\"[^\"]*\"" "$MANIFEST" | head -1 | cut -d'"' -f4
return 0
}
built_with=$(ann_of built_with)
linkage=$(ann_of linkage)
pin_honoured=$(ann_of pin_honoured)
toolchain_pin=$(ann_of toolchain_pin)
if [[ "$built_with" =~ ^rustc\ [0-9]+\.[0-9]+\.[0-9]+ ]]; then
pass "records the compiler that built it (${built_with})"
else
fail "no usable 'built_with' annotation: '${built_with:-missing}'" \
"pkg info -A could not say which rustc produced this package."
fi
case "$linkage" in
static | dynamic) pass "records its linkage (${linkage})" ;;
*) fail "no usable 'linkage' annotation: '${linkage:-missing}'" ;;
esac
# The annotation must describe the artifact, not the intent. A package
# claiming static while carrying a dynamic binary would put back exactly
# the base-drift exposure that static linking exists to remove.
if [[ "$linkage" == "static" ]]; then
if [[ "$file_out" == *"statically linked"* ]]; then
pass "linkage annotation matches the binary (statically linked)"
else
fail "annotated static, but bin/fips is not statically linked" \
"file: ${file_out}"
fi
if grep -q '"shlibs_required"' "$MANIFEST"; then
fail "annotated static, but the manifest still declares shared libraries" \
"A static package should depend on no shlibs at all."
else
pass "static package declares no shared-library dependencies"
fi
elif [[ "$linkage" == "dynamic" ]]; then
if [[ "$file_out" == *"dynamically linked"* ]]; then
pass "linkage annotation matches the binary (dynamically linked)"
else
fail "annotated dynamic, but bin/fips is not dynamically linked" "file: ${file_out}"
fi
fi
# Not a failure: an aarch64 package cannot honour the pin, because
# rustup ships no toolchain for that platform. It is reported loudly
# because such a package is build-it-yourself and must not be published
# as a release artifact.
case "$pin_honoured" in
yes) pass "built with the pinned toolchain (${toolchain_pin})" ;;
no) pass "NOT built with the pinned toolchain (${toolchain_pin}; used ${built_with}) — do not publish this as a release artifact" ;;
*) fail "no usable 'pin_honoured' annotation: '${pin_honoured:-missing}'" ;;
esac
# The FreeBSD major is not asserted against a fixed number: CE 2.8 is 15,
# CE 2.9 and Plus 26.x are 16, and all are legitimate outputs. It only has
# to be present and numeric — the operator matches it to the target.
abi_major=$(printf '%s' "$abi" | awk -F: '{print $2}')
if [[ "$abi_major" =~ ^[0-9]+$ ]]; then
pass "ABI FreeBSD major is ${abi_major} — match this to the target's base"
else
fail "ABI has no numeric FreeBSD major: ${abi}"
fi
if grep -q 'pw groupadd fips' "$MANIFEST"; then
pass "post-install creates the fips control-socket group"
else
fail "post-install does not create the fips group"
fi
# The manifest's file list always names the teardown script, so grep the
# pre-deinstall script body itself, not the whole manifest.
pre_deinstall=$(grep -o '"pre-deinstall":"[^"]*\(\\"[^"]*\)*"' "$MANIFEST" | head -1)
post_install=$(grep -o '"post-install":"[^"]*\(\\"[^"]*\)*"' "$MANIFEST" | head -1)
if [[ "$pre_deinstall" == *"libexec/fips/fips-dns-teardown"* ]]; then
pass "pre-deinstall invokes fips-dns-teardown"
else
fail "pre-deinstall never calls fips-dns-teardown" \
"config.xml would keep forwarding fips. to a dead port after removal."
fi
if [[ "$post_install" == *"install -m 0600"*"fips.yaml.sample"*"fips.yaml"* ]]; then
pass "post-install seeds fips.yaml at 0600"
else
fail "post-install does not seed fips.yaml with install -m 0600" \
"It may carry an nsec: private key."
fi
# ── 3. Shipped configuration ────────────────────────────────────────────────
echo "-- configuration"
CFG="$PAYLOAD/etc/fips/fips.yaml.sample"
if grep -Fqx ' bind_addr: "127.0.0.1"' "$CFG"; then
pass "dns.bind_addr is IPv4 loopback"
else
fail "dns.bind_addr is not 127.0.0.1" \
"unbound gets do-ip6: no unless 'Allow IPv6' is set, and cannot reach [::1]."
fi
if grep -Eq '^[[:space:]]+bind_addr:[[:space:]]*"::1"' "$CFG"; then
fail "config still carries an active ::1 bind"
else
pass "config carries no active ::1 bind"
fi
# The splice must keep the rest of the common config, not just the dns block.
for key in "^node:" "^tun:" "^transports:" "^peers:"; do
if grep -q "$key" "$CFG"; then
pass "config retains ${key#^}"
else
fail "config lost ${key#^} — the dns: splice ate too much"
fi
done
# ── 4. Boot script behaviour ────────────────────────────────────────────────
# Two kinds of case. The negative ones prove a stale or foreign pid is not
# mistaken for the daemon. The positive one proves the script can see a
# daemon it started — which no negative case can: a pid check that never
# succeeds passes every negative case. That defect shipped once, so the
# positive case comes first and the rewrites it depends on are asserted.
# Exercised against the copy inside the package, with its pidfile and knobs
# file redirected into the work dir so nothing here touches a running daemon.
echo "-- boot script"
RC="$WORK/fips-under-test.sh"
# The stub daemon is a copy of sleep named fips: the script identifies the
# daemon by comm, and comm is the basename it was exec'd as.
mkdir -p "$WORK/run"; : > "$WORK/cfg.yaml"; cp /bin/sleep "$WORK/fips"
sed -e "s|^pidfile=.*|pidfile=\"$WORK/test.pid\"|" \
-e "s|^supervisor_pidfile=.*|supervisor_pidfile=\"$WORK/test-daemon.pid\"|" \
-e "s|^daemon_err=.*|daemon_err=\"$WORK/run/daemon.err\"|" \
-e "s|^fips_conf=.*|fips_conf=\"$WORK/fips.conf\"|" \
-e "s|^procname=.*|procname=\"$WORK/fips\"|" \
-e "s|^runtime_dir=.*|runtime_dir=\"$WORK/run\"|" \
-e "s|fips_config:=/usr/local/etc/fips/fips.yaml|fips_config:=$WORK/cfg.yaml|" \
-e "s|fips_logfile:=/var/log/fips.log|fips_logfile:=$WORK/fips.log|" \
-e "s|^ if pw groupshow fips| if false \&\& pw groupshow fips|" \
-e "s|--config \"\$fips_config\" \${fips_flags}|300|" \
"$PAYLOAD/etc/rc.d/fips.sh" > "$RC"
chmod +x "$RC"
# Every one of those rewrites must have landed, or the cases below run
# against the wrong paths and the wrong binary and prove nothing.
for want in "pidfile=\"$WORK/test.pid\"" "procname=\"$WORK/fips\"" "runtime_dir=\"$WORK/run\"" \
"fips_config:=$WORK/cfg.yaml" "fips_logfile:=$WORK/fips.log"; do
if grep -Fq "$want" "$RC"; then
pass "harness rewrite in place: ${want%%=*}"
else
fail "harness rewrite missing: $want" "the lifecycle cases below would test the wrong thing."
fi
done
# Redirect the script's output to a file, not through $(...): start and
# restart background daemon(8), whose child inherits the caller's stdout,
# so a command-substitution capture would block until that child exits —
# the same pipe-inheritance trap the boot script itself avoids. The
# daemon writes to the file; the capture only reads cat's output.
rc() {
: > "$WORK/rc.out"
# --foreground matters. Without it, FreeBSD's timeout(1) makes itself a
# reaper for everything the script spawns (procctl PROC_REAP_ACQUIRE):
# after the script exits it keeps waiting until every descendant has
# exited too, and at the time limit it kills them all (PROC_REAP_KILL).
# daemon(8) never exits, so `start` would sit for the full 30 seconds
# and then the daemon it started would be killed — "started", no
# daemon. With --foreground timeout only signals its direct child, and
# the detached daemon is left alone. No process group is involved.
# (Found by running this checker as root in a FreeBSD VM.)
timeout --foreground 30 sh "$RC" "$@" > "$WORK/rc.out" 2>&1 </dev/null
rc_status=$?
cat "$WORK/rc.out"
return $rc_status
}
out=$(rc start); sleep 1
if [[ "$out" == *"started"* ]] && [[ -s "$WORK/test.pid" ]]; then
pass "start launches the daemon via daemon(8)"
else
fail "start did not launch the daemon" "$out"
fi
# daemon(8) writes the pid with no trailing newline; read must cope with
# that, which is exactly what the shipped-once defect did not. Require the
# pidfile to exist first: tail on a missing file prints nothing, which
# would otherwise pass this vacuously.
if [[ ! -s "$WORK/test.pid" ]]; then
fail "no pidfile to check for a trailing newline (start did not write one)"
elif [[ "$(tail -c1 "$WORK/test.pid" | od -An -c | tr -d ' ')" != '\n' ]]; then
pass "pidfile has no trailing newline (the case read must handle)"
else
fail "pidfile ends in a newline — this harness no longer exercises the read-at-EOF case"
fi
live=$(cat "$WORK/test.pid")
out=$(rc status)
if [[ "$out" == *"running as pid $live"* ]]; then
pass "status sees the daemon it started (pid $live)"
else
fail "status cannot see a running daemon" "$out"
fi
out=$(rc start)
if [[ "$out" == *"already running as pid $live"* ]] && kill -0 "$live" 2>/dev/null; then
pass "re-entrant start is a no-op (pfSense re-runs start on WAN IP changes)"
else
fail "re-entrant start disturbed a running daemon" "$out"
fi
out=$(rc restart); sleep 1
newpid=$(cat "$WORK/test.pid" 2>/dev/null)
if [[ "$out" == *"stopped"* ]] && [[ "$out" == *"started"* ]] && [[ -n "$newpid" ]] && [[ "$newpid" != "$live" ]] \
&& ! kill -0 "$live" 2>/dev/null && kill -0 "$newpid" 2>/dev/null; then
pass "restart stops the old daemon and starts a new one ($live -> $newpid)"
else
fail "restart did not replace the daemon" "$out"
fi
out=$(rc stop); sleep 1
if [[ "$out" == *"stopped"* ]] && ! kill -0 "$newpid" 2>/dev/null; then
pass "stop terminates the daemon"
else
fail "stop left the daemon running" "$out"
fi
if rc status >/dev/null 2>&1; then fail "status exits 0 after stop"; else pass "status exits non-zero after stop"; fi
pkill -f "$WORK/fips" 2>/dev/null || true
# A live process whose comm is not "fips": stands in for a recycled pid.
sleep 120 &
decoy_pid=$!
status_says() {
sh "$RC" status 2>&1 | tail -1
return 0
}
printf '%s\n' "$$" > "$WORK/test.pid"
# $$ is this shell, comm "bash" — a pid that exists but is not the daemon.
if [[ "$(status_says)" == *"not running"* ]]; then
pass "a live pid whose comm is not fips reads as not running"
else
fail "a recycled pid was mistaken for a running daemon" \
"start would then be a permanent no-op."
fi
printf '%s\n' "$decoy_pid" > "$WORK/test.pid"
if [[ "$(status_says)" == *"not running"* ]]; then
pass "a live non-fips child reads as not running"
else
fail "a live non-fips process was mistaken for the daemon"
fi
printf 'junk\n' > "$WORK/test.pid"
if [[ "$(status_says)" == *"not running"* ]]; then
pass "a non-numeric pidfile reads as not running"
else
fail "a non-numeric pidfile was accepted"
fi
printf '999999\n' > "$WORK/test.pid"
if [[ "$(status_says)" == *"not running"* ]]; then
pass "a pid that does not exist reads as not running"
else
fail "a dead pid was reported as running"
fi
rm -f "$WORK/test.pid"
if [[ "$(status_says)" == *"not running"* ]]; then
pass "a missing pidfile reads as not running"
else
fail "a missing pidfile was not handled"
fi
kill "$decoy_pid" 2>/dev/null
wait "$decoy_pid" 2>/dev/null
# status must exit non-zero when not running: the package's pre-deinstall
# tests it to decide whether to restart the daemon after an upgrade.
sh "$RC" status >/dev/null 2>&1
if [[ $? -ne 0 ]]; then
pass "status exits non-zero when not running"
else
fail "status exits 0 when not running" \
"pre-deinstall would mark a stopped daemon for restart on upgrade."
fi
# The opt-out has to be silent as well as inert: pfSense re-runs this on every
# interface event, and a chatty no-op floods /tmp/bootup_messages.
printf 'fips_enable="NO"\n' > "$WORK/fips.conf"
out=$(sh "$RC" start 2>&1)
rc=$?
if [[ $rc -eq 0 && -z "$out" ]]; then
pass "fips_enable=NO start is silent and exits 0"
else
fail "fips_enable=NO start printed '${out}' and exited ${rc}"
fi
rm -f "$WORK/fips.conf"
sh "$RC" not-a-verb >/dev/null 2>&1
if [[ $? -eq 64 ]]; then
pass "an unknown verb exits 64 (EX_USAGE)"
else
fail "an unknown verb did not exit 64"
fi
# ── 5. DNS helper ───────────────────────────────────────────────────────────
echo "-- dns helper"
# On anything that is not pfSense it must refuse rather than half-configure a
# resolver it does not understand. This host is FreeBSD, so /etc/inc/config.inc
# is absent and the refusal path is the one that runs.
if [[ -f /etc/inc/config.inc ]]; then
skip "non-pfSense refusal check (this host is pfSense)"
else
out=$("$PAYLOAD/libexec/fips/fips-dns-setup" 2>&1)
rc=$?
if [[ $rc -ne 0 && "$out" == *"not a pfSense system"* ]]; then
pass "fips-dns-setup refuses on a non-pfSense host"
else
fail "fips-dns-setup did not refuse on a non-pfSense host (rc=${rc})" "$out"
fi
fi
# The reader must take dns.bind_addr, not a transport's bind_addr — a plain
# grep would hand back the UDP transport's 0.0.0.0:2121 and point unbound there.
READER="$WORK/reader.sh"
sed -n '/^yaml_dns_field()/,/^}/p' "$PAYLOAD/libexec/fips/fips-dns-setup" > "$READER"
{
echo 'echo "$(yaml_dns_field bind_addr) $(yaml_dns_field port)"'
} >> "$READER"
got=$(FIPS_CONFIG="$CFG" sh -c ". '$READER'")
if [[ "$got" == "127.0.0.1 5354" ]]; then
pass "dns field reader is scoped to the dns: block ($got)"
else
fail "dns field reader returned '${got}', expected '127.0.0.1 5354'"
fi
# In the shipped config dns: precedes transports:, so a first-match grep
# would also pass. Put a transport's bind_addr FIRST and make sure the
# reader still finds the dns: one.
cat > "$WORK/transports-first.yaml" <<'EOF'
transports:
udp:
bind_addr: "0.0.0.0:2121"
port: 1
dns:
enabled: true
bind_addr: "127.0.0.1"
port: 5354
EOF
got=$(FIPS_CONFIG="$WORK/transports-first.yaml" sh -c ". '$READER'")
if [[ "$got" == "127.0.0.1 5354" ]]; then
pass "dns field reader ignores a transports: block that comes first"
else
fail "dns field reader picked up the transport's bind_addr: '${got}'" \
"unbound would be pointed at the UDP transport."
fi
# ── 6. PHP helper ───────────────────────────────────────────────────────────
echo "-- php helper"
HELPER="$PAYLOAD/libexec/fips/fips-unbound-custom.php"
# After editing config.xml the helper must restart the running resolver the
# way the GUI's Apply does, services_unbound_configure(): it TERMs unbound
# and starts it on the regenerated unbound.conf. sync_unbound_service()
# regenerates the file too but then only *starts* unbound, a no-op while one
# is running, so the resolver kept its old forward set and answered NXDOMAIN
# for .fips while setup reported success (Plus 26.07 amd64). A static check,
# since the call needs pfSense's includes to run.
if grep -qE '^[^*/#]*services_unbound_configure\(' "$HELPER" \
&& ! grep -qE '^[^*/#]*sync_unbound_service\(' "$HELPER"; then
pass "helper restarts unbound with services_unbound_configure()"
else
fail "helper does not restart unbound with services_unbound_configure()" \
"sync_unbound_service() leaves a running unbound on its old config."
fi
if ! command -v php >/dev/null 2>&1; then
skip "php -l and the fips_strip_block unit test (no php on this host)"
else
if php -l "$HELPER" >/dev/null 2>&1; then
pass "fips-unbound-custom.php parses (php -l)"
else
fail "fips-unbound-custom.php does not parse" "$(php -l "$HELPER" 2>&1 | head -3)"
fi
# fips_strip_block has no side effects; under FIPS_UNBOUND_HELPER_TEST the
# file defines its functions and returns before touching config.xml.
if php -r '
putenv("FIPS_UNBOUND_HELPER_TEST=1"); require $argv[1];
$bad = 0;
foreach (["", "opt: 1\n", "opt: 1", "a\n\n\nb\n", "\n"] as $t) {
$hb = $mf = false;
$r = fips_strip_block(fips_add_block($t, "server:\n x: y"), $hb, $mf);
if ($r !== $t || !$hb || $mf) { $bad++; }
}
$hb = $mf = false; fips_strip_block("x\n" . FIPS_BEGIN . "\nzzz\n", $hb, $mf); if (!$mf) { $bad++; }
$hb = $mf = false; $r = fips_strip_block("keep\n", $hb, $mf); if ($r !== "keep\n" || $hb) { $bad++; }
// Operator text on its own line below the block must survive on its
// own line, not be spliced onto the line before BEGIN.
$above = "# my local tweaks";
$below = fips_add_block($above, "server:\n x: y") . "private-domain: \"lan\"";
$hb = $mf = false; $r = fips_strip_block($below, $hb, $mf);
if ($r !== $above . "\nprivate-domain: \"lan\"" || !$hb || $mf) { $bad++; }
exit($bad ? 1 : 0);' "$HELPER" >/dev/null 2>&1; then
pass "fips_strip_block: add then remove is byte-identical; partial block flagged"
else
fail "fips_strip_block does not round-trip the operator text" \
"the README promises the surrounding text is left byte for byte."
fi
fi
# ── Result ──────────────────────────────────────────────────────────────────
echo
if [[ $failures -eq 0 ]]; then
echo "==> pfSense package checks PASSED (${checks} checks, ${skipped} skipped)"
exit 0
fi
echo "==> pfSense package checks FAILED (${failures} of ${checks})" >&2
exit 1