Files
fips/testing/openwrt/scenarios.sh
Johnathan Corgan 43b6512503 fix(gateway): harden the virtual-IP pool and ship it disabled on OpenWrt
Any host able to query the LAN resolver could drain the gateway's
65,535-address virtual-IP pool one `.fips` name at a time, and each allocation
rebuilt the whole nftables table in a way that could leave the host with no
NAT at all. Four changes, each independently useful, close that off.

Do not allocate for query types the gateway never answers with an address.
handle_query minted a virtual IP for every query type and only then looked at
what the client asked, answering an A or HTTPS query with NODATA after
creating a mapping for it. The query type is now decided before the pool is
touched, and only AAAA and ANY allocate. The refresh an existing mapping used
to get from any query type is kept: it came from the reuse path in allocate,
so a new pool method does that refresh alone and never creates anything, and
the reuse path calls it.

Rebuild the NAT table in one netlink transaction. rebuild() deleted the
fips_gateway table in a batch of its own and discarded the result, then sent a
second batch recreating the table, the chains, the fips0 masquerade and two
rules per mapping. Between those sends the host had no NAT table, and a
recreate the kernel refused left the table deleted, turning one failed mapping
change into a total loss of forwarding until some later rebuild happened to
succeed. The delete and the recreate now share one batch. A leading table add
makes the delete legal on the first run, since rustables sends it with
NLM_F_CREATE and no NLM_F_EXCL and the crate offers no flush. Deciding what to
send is now separate from sending it, which is the seam the new unit tests
use: they assert one batch, the add-delete-add prefix, and that every chain
and rule follows the recreate, without a netlink socket or privileges.

Read conntrack once per tick, off the runtime thread, and match by address.
The session count searched each /proc/net/nf_conntrack line for `dst=`
followed by the virtual IP's compressed Display form, while the kernel prints
every tuple with `%pI6`, the full uncompressed form. That string cannot occur
in that field, so the count was zero for every mapping on every kernel that
has the file: nothing pinned an in-use mapping and one whose client did not
re-query DNS was reclaimed about two minutes after its last DNS reference with
traffic still flowing. Each `dst=` is now parsed and compared as an address.
The read was also per mapping, under the pool lock, on the runtime thread that
serves DNS; the tick now takes one snapshot in a blocking task before taking
the lock. An unreadable source was silent, because read_to_string's error
became zero through unwrap_or(0). Zero stays, since treating it as in-use
would pin every mapping forever on a kernel without
CONFIG_NF_CONNTRACK_PROCFS, but it is now reported at warn on the first
failure and on each change of outcome, and at debug on a repeat.

Ship the OpenWrt gateway disabled, and keep its state across upgrades. The
generated postinst enabled and started fips-gateway on every install, against
the init script's own header, the package README and the deployment tutorial,
which all say the service ships disabled. A fresh install now leaves it alone.
Upgrades are the awkward case: opkg runs the outgoing package's prerm first,
and every released prerm disabled the gateway on its way out without recording
whether it had been enabled. The new prerm stops the services on an upgrade
but no longer disables them, and leaves a marker the incoming postinst reads.
With the marker, enablement survived and the gateway starts only if it was
enabled; without it, the outgoing package was a released one whose prerm
destroyed that state, so the gateway is re-enabled rather than letting an
upgrade turn off a working deployment. That re-enables a hand-disabled gateway
once, which the CHANGELOG says. start_service now reads gateway.enabled from
fips.yaml before touching anything, since starting a gateway the config
disables used to take dnsmasq's `.fips` forwarding away from the daemon and
hand it to a port whose daemon exits immediately. The four maintainer-script
bodies move out of heredocs in the two build scripts into
packaging/openwrt-ipk/scripts/, so the .ipk and the .apk install the same
bodies and a test can run what ships.

Coverage recorded rather than closed. The conntrack parser's first test builds
its line from the kernel's own format string rather than a capture, because
this host is built without CONFIG_NF_CONNTRACK_PROCFS and has no
/proc/net/nf_conntrack, so the lab exercises only the unreadable path. Kernel
acceptance of delete-then-recreate inside one transaction is not asserted by a
unit test; the gateway suite is what proves it, since the manager rebuilds at
startup and the daemon exits if that fails. The OpenWrt scenarios run the
shipped script bodies under ash in a busybox container against stubbed init
scripts, and assert their behaviour given opkg's call order, arguments and
PKG_UPGRADE as read from opkg-lede's sources, not under a real opkg upgrade on
a router image.

Admission limits on the pool are deliberately not included here: they need a
measurement run before their constants can be chosen.
2026-09-17 20:46:12 +00:00

343 lines
11 KiB
Bash
Executable File

#!/bin/sh
# OpenWrt maintainer-script and init-guard scenarios, run under ash.
#
# Driven by testing/openwrt/maintainer-scripts-test.sh, which starts a busybox
# container so /bin/sh here is ash, the shell OpenWrt runs these scripts under.
# Nothing in this file needs opkg: the call order, the arguments and the
# PKG_UPGRADE environment are taken from opkg-lede's own sources, so what is
# exercised is the scripts' behaviour given that contract, not opkg itself.
# A real `opkg upgrade` on a router image stays uncovered.
#
# POSTINST and PRERM may be pointed at other files. That is the seam used to
# see a scenario red against the previously released scripts, and to re-break
# the fixed ones during a break-check.
set -u
REPO="${REPO:-/src}"
POSTINST="${POSTINST:-$REPO/packaging/openwrt-ipk/scripts/postinst}"
PRERM="${PRERM:-$REPO/packaging/openwrt-ipk/scripts/prerm}"
RELEASED_PRERM="$REPO/testing/openwrt/fixtures/released-prerm"
INIT_GATEWAY="$REPO/packaging/openwrt-ipk/files/etc/init.d/fips-gateway"
SHIPPED_YAML="$REPO/packaging/openwrt-ipk/files/etc/fips/fips.yaml"
WORK=/tmp/fips-openwrt-scenarios
UPGRADE_MARKER=/tmp/fips-prerm-upgrade
FAILURES=0
CASES=0
note() { echo " $*"; }
ok() {
CASES=$((CASES + 1))
echo " ok $*"
return 0
}
bad() {
CASES=$((CASES + 1))
FAILURES=$((FAILURES + 1))
echo " FAIL $*"
return 0
}
# Stub init scripts that record every call and keep an enable state file, so a
# scenario can assert both what was invoked and what the package left behind.
install_stubs() {
mkdir -p /etc/init.d /etc/uci-defaults
cat > /etc/init.d/fips-gateway <<'STUB'
#!/bin/sh
echo "fips-gateway $1" >> "$CALLS"
case "$1" in
enable) echo 1 > "$GW_STATE" ;;
disable) echo 0 > "$GW_STATE" ;;
enabled) [ "$(cat "$GW_STATE")" = 1 ] ;;
esac
STUB
cat > /etc/init.d/fips <<'STUB'
#!/bin/sh
echo "fips $1" >> "$CALLS"
case "$1" in
enable) echo 1 > "$FIPS_STATE" ;;
disable) echo 0 > "$FIPS_STATE" ;;
enabled) [ "$(cat "$FIPS_STATE")" = 1 ] ;;
esac
STUB
cat > /etc/uci-defaults/90-fips-setup <<'STUB'
#!/bin/sh
echo "uci-defaults" >> "$CALLS"
STUB
chmod 0755 /etc/init.d/fips-gateway /etc/init.d/fips /etc/uci-defaults/90-fips-setup
return 0
}
reset_state() {
rm -rf "$WORK"
mkdir -p "$WORK"
CALLS="$WORK/calls"
GW_STATE="$WORK/gateway-enabled"
FIPS_STATE="$WORK/fips-enabled"
export CALLS GW_STATE FIPS_STATE
: > "$CALLS"
echo 0 > "$GW_STATE"
echo 0 > "$FIPS_STATE"
rm -f "$UPGRADE_MARKER"
unset PKG_UPGRADE
install_stubs
return 0
}
calls_oneline() {
tr '\n' ';' < "$CALLS"
return 0
}
assert_called() {
# assert_called <expected call line> <what it means>
if grep -qxF "$1" "$CALLS"; then
ok "$2"
else
bad "$2 — '$1' is not among: $(calls_oneline)"
fi
return 0
}
assert_not_called() {
if grep -qxF "$1" "$CALLS"; then
bad "$2 — '$1' was called: $(calls_oneline)"
else
ok "$2"
fi
return 0
}
assert_file_is() {
# assert_file_is <file> <expected contents> <what it means>
got="$(cat "$1" 2>/dev/null)"
if [ "$got" = "$2" ]; then
ok "$3"
else
bad "$3 — expected '$2', got '$got'"
fi
return 0
}
assert_equals() {
# assert_equals <got> <want> <what it means>
if [ "$1" = "$2" ]; then
ok "$3"
else
bad "$3 — expected '$2', got '$1'"
fi
return 0
}
assert_absent() {
if [ -e "$1" ]; then
bad "$2 — $1 still exists"
else
ok "$2"
fi
return 0
}
# ── 1. Fresh install ────────────────────────────────────────────────────────
# opkg runs the postinst with "configure"; PKG_UPGRADE is set only on upgrades,
# so both its absence and an explicit 0 must leave the gateway alone.
scenario_fresh_install() {
for pkg_upgrade in unset 0; do
note "scenario 1: fresh install (PKG_UPGRADE $pkg_upgrade)"
reset_state
if [ "$pkg_upgrade" = "0" ]; then
PKG_UPGRADE=0 sh "$POSTINST" configure >/dev/null 2>&1
else
sh "$POSTINST" configure >/dev/null 2>&1
fi
assert_called "fips enable" "the daemon is enabled on a fresh install"
assert_called "fips start" "the daemon is started on a fresh install"
assert_not_called "fips-gateway enable" "the gateway is not enabled on a fresh install"
assert_not_called "fips-gateway start" "the gateway is not started on a fresh install"
assert_file_is "$GW_STATE" "0" "the gateway is left disabled on a fresh install"
done
return 0
}
# ── 2. Upgrade from a released package ──────────────────────────────────────
# Its prerm disabled the gateway on its way out and left no marker, so the
# incoming postinst cannot tell an enabled gateway from a disabled one and
# re-enables it.
scenario_upgrade_from_released() {
note "scenario 2: upgrade from a released package"
reset_state
echo 1 > "$GW_STATE"
echo 1 > "$FIPS_STATE"
sh "$RELEASED_PRERM" upgrade 0.5.1 >/dev/null 2>&1
PKG_UPGRADE=1 sh "$POSTINST" configure >/dev/null 2>&1
assert_called "fips-gateway enable" "the gateway is re-enabled after a released prerm disabled it"
assert_called "fips-gateway start" "the gateway is started again"
assert_file_is "$GW_STATE" "1" "the gateway ends up enabled"
return 0
}
# ── 3. Upgrade from a package carrying these scripts, gateway enabled ───────
scenario_upgrade_enabled() {
note "scenario 3: upgrade from these scripts, gateway enabled"
reset_state
echo 1 > "$GW_STATE"
echo 1 > "$FIPS_STATE"
sh "$PRERM" upgrade 0.5.2 >/dev/null 2>&1
assert_file_is "$GW_STATE" "1" "the outgoing prerm does not disable the gateway on an upgrade"
assert_not_called "fips-gateway disable" "the outgoing prerm does not call disable on an upgrade"
assert_called "fips-gateway stop" "the outgoing prerm still stops the gateway"
PKG_UPGRADE=1 sh "$POSTINST" configure >/dev/null 2>&1
assert_file_is "$GW_STATE" "1" "the gateway stays enabled across the upgrade"
assert_called "fips-gateway start" "an enabled gateway is started again"
assert_not_called "fips-gateway enable" "an enabled gateway does not need re-enabling"
assert_absent "$UPGRADE_MARKER" "the postinst removes the upgrade marker"
return 0
}
# ── 4. Upgrade from a package carrying these scripts, gateway disabled ──────
scenario_upgrade_disabled() {
note "scenario 4: upgrade from these scripts, gateway disabled"
reset_state
echo 1 > "$FIPS_STATE"
sh "$PRERM" upgrade 0.5.2 >/dev/null 2>&1
PKG_UPGRADE=1 sh "$POSTINST" configure >/dev/null 2>&1
assert_file_is "$GW_STATE" "0" "a disabled gateway stays disabled across the upgrade"
assert_not_called "fips-gateway enable" "a disabled gateway is not enabled by the upgrade"
assert_not_called "fips-gateway start" "a disabled gateway is not started by the upgrade"
assert_absent "$UPGRADE_MARKER" "the postinst removes the upgrade marker"
return 0
}
# ── 5. Removal ──────────────────────────────────────────────────────────────
scenario_removal() {
note "scenario 5: removal"
reset_state
echo 1 > "$GW_STATE"
echo 1 > "$FIPS_STATE"
sh "$PRERM" remove >/dev/null 2>&1
assert_called "fips-gateway stop" "removal stops the gateway"
assert_called "fips-gateway disable" "removal disables the gateway"
assert_called "fips stop" "removal stops the daemon"
assert_called "fips disable" "removal disables the daemon"
assert_file_is "$GW_STATE" "0" "the gateway ends up disabled"
assert_absent "$UPGRADE_MARKER" "removal leaves no upgrade marker"
return 0
}
# ── 6. gateway_config_enabled reads the config ──────────────────────────────
scenario_config_reader() {
note "scenario 6: gateway_config_enabled"
reset_state
# shellcheck source=/dev/null
. "$INIT_GATEWAY"
CONFIG="$SHIPPED_YAML"
assert_equals "$(gateway_config_enabled)" "true" "the shipped fips.yaml reads as true"
CONFIG="$WORK/disabled.yaml"
cat > "$CONFIG" <<'YAML'
identity:
key_file: "/etc/fips/node.key"
gateway:
enabled: false
pool: "fd01::/112"
peers: []
YAML
assert_equals "$(gateway_config_enabled)" "false" "an explicitly disabled gateway reads as false"
CONFIG="$WORK/no-gateway.yaml"
cat > "$CONFIG" <<'YAML'
identity:
key_file: "/etc/fips/node.key"
dns:
enabled: true
peers: []
YAML
assert_equals "$(gateway_config_enabled)" "" "a config with no gateway block reads as empty"
return 0
}
# ── 7. start_service refuses to touch dnsmasq for a disabled gateway ────────
# The init script's helpers are redefined after sourcing it, so start_service
# runs its own decision against recorded stubs instead of uci, procd and the
# network.
scenario_start_service_guard() {
note "scenario 7: start_service guard"
# shellcheck source=/dev/null
. "$INIT_GATEWAY"
sysctl() { return 0; }
modprobe() { return 0; }
logger() { return 0; }
sleep() { return 0; }
procd_set_param() { return 0; }
procd_close_instance() { return 0; }
dnsmasq_swap_fips_upstream() { echo "dnsmasq_swap $1" >> "$CALLS"; return 0; }
gateway_add_global_prefix() { echo "add_global_prefix" >> "$CALLS"; return 0; }
gateway_add_ra_route() { echo "add_ra_route" >> "$CALLS"; return 0; }
procd_open_instance() { echo "procd_open_instance" >> "$CALLS"; return 0; }
reset_state
CONFIG="$SHIPPED_YAML"
start_service >/dev/null 2>&1
assert_called "dnsmasq_swap 5353" "an enabled gateway still redirects dnsmasq"
assert_called "procd_open_instance" "an enabled gateway still starts the daemon"
reset_state
CONFIG="$WORK/disabled.yaml"
cat > "$CONFIG" <<'YAML'
gateway:
enabled: false
pool: "fd01::/112"
YAML
start_service >/dev/null 2>&1
assert_not_called "dnsmasq_swap 5353" "a disabled gateway does not redirect dnsmasq"
assert_not_called "add_global_prefix" "a disabled gateway does not add the LAN prefix"
assert_not_called "add_ra_route" "a disabled gateway does not advertise the pool route"
assert_not_called "procd_open_instance" "a disabled gateway does not start the daemon"
return 0
}
echo "OpenWrt maintainer-script scenarios (shell: $(readlink -f /proc/$$/exe 2>/dev/null || echo sh))"
echo " postinst: $POSTINST"
echo " prerm: $PRERM"
scenario_fresh_install
scenario_upgrade_from_released
scenario_upgrade_enabled
scenario_upgrade_disabled
scenario_removal
scenario_config_reader
scenario_start_service_guard
echo ""
if [ "$FAILURES" -eq 0 ]; then
echo "openwrt-scripts: all $CASES checks passed"
exit 0
fi
echo "openwrt-scripts: $FAILURES of $CASES checks failed"
exit 1