mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-05 19:18:25 +00:00
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.
343 lines
11 KiB
Bash
Executable File
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
|