Files
Johnathan Corgan 88c7974d25 Follow the daemon's TUN name and the OpenWrt LAN settings in the gateway, and warn when proxy NDP is off
The daemon names its TUN from tun.name, but fips-gateway looked for fips0:
with any other name it exited at start saying fips0 was missing, and had
it started, its NAT, forward and raw rules would have matched an
interface that is not the mesh, and its DNS listener would have answered
queries arriving from the mesh.

The gateway now reads tun.name from the configuration it already loads
and uses it for its start-up check, for every nftables match on the TUN,
and for the DNS listener's mesh-interface filter.

The packaged systemd units and the NixOS module waited for fips0 with a
shell loop before starting the gateway, and failed with any other name.
The gateway now does that wait itself, for up to 30 seconds, for the
interface tun.name names, and exits at once on a stop during it, so the
loops are gone. On OpenWrt the init script's wait reads tun.name.

The gateway answers LAN hosts' neighbor solicitations for pool addresses
with proxy NDP entries, which the kernel ignores unless proxy_ndp is set
for all interfaces or for the LAN interface. The gateway checked IPv6
forwarding at start but not proxy_ndp, so on a host without it, LAN
hosts that resolve pool addresses on-link got no answer and nothing said
why. Only the OpenWrt package sets it.

The gateway now warns at start when neither net.ipv6.conf.all.proxy_ndp
nor the LAN interface's own proxy_ndp is set. It does not stop: a LAN
that reaches the pool through a route to the gateway does not need it.

Starting the OpenWrt gateway replaced any IPv6 address the operator had
set on the LAN with its own prefix, and stopping it deleted the LAN's
ra_default whatever it had been before, so both were lost after one
start and stop. The pool route and its router advertisement were always
put on br-lan and the lan interface, whatever gateway.lan_interface said.

A start now leaves an address the operator set in place and adds its
prefix only to a LAN with none. It keeps the ra_default it replaces in
/etc/fips/gateway-ra-default.saved and a stop puts it back, also after a
power loss between the two. A stop with nothing saved, as every package
upgrade and removal makes whether or not the gateway ran, leaves
ra_default alone unless it is 2, the value an older package's start
left. The pool route, its advertisement, the prefix and ra_default
follow gateway.lan_interface and the network interface whose device it
is, with br-lan and lan still the defaults.

The README also said the gateway prefix could take over as a LAN
client's source address only for 2001::/16 destinations; it can for any
destination that shares more leading bits with it than with the
client's upstream address, which with a 2a00::/7 upstream is all of
2000::/5.
2026-10-27 21:51:02 +00:00

369 lines
13 KiB
Bash
Executable File

#!/bin/bash
# Test the systemd tarball's install.sh as an upgrade, under real systemd.
#
# fips-dns.service and fips-gateway.service carry Requires=fips.service, so
# stopping fips stops both. An upgrade that checks which units are running
# only after stopping fips sees the other two as already stopped, and starts
# fips alone again. This suite installs the tarball once, puts the units in a
# known state, runs install.sh a second time (the upgrade), and checks that
# exactly the units that were running before are running after.
#
# The tarball is staged from the real packaging/systemd/install.sh and unit
# files, and the real packaging/common configuration, the same file set
# build-tarball.sh ships. The binaries and DNS helpers are stubs: the fips and
# fips-gateway stubs sleep, so their units stay active, and the DNS helpers
# exit 0. What is under test is install.sh against real units and a real
# systemd, which carries the Requires= stop propagation; nothing here depends
# on what the binaries do.
#
# Scenarios, each on a fresh container:
# all-active fips, fips-dns and fips-gateway running before the upgrade.
# All three must be running after it, and fips must have been
# restarted (its InvocationID changes).
# fips-only only fips running. fips must be running after, and the other
# two must still be stopped.
# none-active nothing running. Nothing may be running after.
#
# Usage: ./test.sh [--install-sh PATH] [scenario ...]
# No scenarios = run all of them.
# --install-sh PATH runs another install.sh in place of the tree's, so the
# suite can be pointed at an older script without editing the tree.
#
# Requirements: Docker able to grant SYS_ADMIN and NET_ADMIN and an unconfined
# AppArmor profile (the container is not privileged; see
# testing/lib/systemd-container.sh), and /dev/net/tun on the host.
#
# Exit 0 only when every check passed. A container that cannot boot or be
# inspected is a FAIL, never a skip.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=SCRIPTDIR/../lib/systemd-container.sh
source "$SCRIPT_DIR/../lib/systemd-container.sh"
# shellcheck source=SCRIPTDIR/../lib/image-build.sh
source "$SCRIPT_DIR/../lib/image-build.sh"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
IMAGE="fips-tarball-test:debian12"
BOOT_TIMEOUT=60
# Bounds one install.sh run. It starts at most three units, whose programs here
# are stubs, so none of them waits for anything before it is started.
INSTALL_TIMEOUT=120
UNIT_START_TIMEOUT=60
TARBALL_DIR=/opt/fips-tarball
PASS=0
FAIL=0
INSTALL_SH="$REPO_ROOT/packaging/systemd/install.sh"
STAGE=""
log() { echo "=== $*"; }
pass() { echo " PASS: $*"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $*"; FAIL=$((FAIL + 1)); }
# Remove a container, ignoring one that does not exist.
cleanup_container() {
local name="$1"
docker rm -f "$name" >/dev/null 2>&1 || true
}
# Remove every scenario's container and the staged tarball.
cleanup_all() {
local s
for s in $ALL_SCENARIOS; do
cleanup_container "$(container_name "$s")"
done
[ -n "$STAGE" ] && rm -rf "$STAGE"
}
# The container name for a scenario, scoped to this CI run.
container_name() {
echo "fips-tarball-test-$1${FIPS_CI_NAME_SUFFIX:-}"
return 0
}
# Build the systemd image. The build context is an empty directory: the
# tarball is copied into the container after it starts, not baked in.
build_image() {
local ctx rc=0
ctx=$(mktemp -d)
retry_build "docker build -t $IMAGE" build_inline "$IMAGE" "$(cat <<'DOCKERFILE'
FROM debian:12
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
systemd iproute2 procps && \
apt-get clean && rm -rf /var/lib/apt/lists/*
CMD ["/lib/systemd/systemd"]
DOCKERFILE
)" "$ctx" || rc=$?
rm -rf "$ctx"
return "$rc"
}
# Stage a tarball directory: the real install script, units and configuration,
# with stub binaries and DNS helpers.
stage_tarball() {
local f
STAGE=$(mktemp -d)
cp "$INSTALL_SH" "$STAGE/install.sh" || return 1
for f in fips.service fips-dns.service fips-gateway.service fips-firewall.service; do
cp "$REPO_ROOT/packaging/systemd/$f" "$STAGE/" || return 1
done
for f in fips.yaml hosts fips.nft; do
cp "$REPO_ROOT/packaging/common/$f" "$STAGE/" || return 1
done
printf '#!/bin/sh\nexec sleep infinity\n' > "$STAGE/fips"
printf '#!/bin/sh\nexec sleep infinity\n' > "$STAGE/fips-gateway"
printf '#!/bin/sh\nexit 0\n' > "$STAGE/fipsctl"
printf '#!/bin/sh\nexit 0\n' > "$STAGE/fips-dns-setup"
printf '#!/bin/sh\nexit 0\n' > "$STAGE/fips-dns-teardown"
chmod 0755 "$STAGE"/install.sh "$STAGE"/fips "$STAGE"/fips-gateway \
"$STAGE"/fipsctl "$STAGE"/fips-dns-setup "$STAGE"/fips-dns-teardown
return 0
}
# Start the scenario's systemd container. Not privileged: see
# testing/lib/systemd-container.sh for the flags and why. The TUN device is
# passed so the scenario can create a fips0. fips-gateway.service no longer
# needs it, since the gateway here is a stub that does not look for the TUN; it
# is kept because it is harmless and matches a host the daemon has started on.
start_container() {
local name="$1"
cleanup_container "$name"
run_quiet "docker run $name" \
docker run -d --name "$name" \
"${CI_LABELS[@]}" \
"${SYSTEMD_CAPS[@]}" \
--cgroupns=host \
--device /dev/net/tun \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
--tmpfs /run --tmpfs /run/lock \
"$IMAGE" || return
check_isolation "$name"
return
}
# Wait for systemd to finish booting. `degraded` counts as booted: units such
# as systemd-modules-load cannot succeed in a container, and the system still
# finished starting. The state string is tested directly rather than piped, so
# pipefail cannot turn a degraded boot into a failure.
wait_for_systemd() {
local name="$1" state
for _i in $(seq 1 "$BOOT_TIMEOUT"); do
state=$(systemd_state "$name")
case "$state" in
running | degraded) return 0 ;;
esac
sleep 1
done
echo " ERROR: systemd did not reach running state in ${BOOT_TIMEOUT}s" >&2
return 1
}
# Run the staged install.sh in the container, under a bound.
run_install() {
local name="$1" label="$2" out rc=0
out=$(timeout "$INSTALL_TIMEOUT" docker exec "$name" "$TARBALL_DIR/install.sh" 2>&1) || rc=$?
if [ "$rc" -ne 0 ]; then
fail "$label: install.sh exited $rc"
echo "$out" | sed 's/^/ /'
return 1
fi
pass "$label: install.sh exits 0"
return 0
}
# Print a unit's active state ("unknown" when the container cannot be asked).
unit_state() {
local name="$1" unit="$2" state
state=$(docker exec "$name" systemctl is-active "$unit" 2>/dev/null)
echo "${state:-unknown}"
return 0
}
# Print a unit's InvocationID, which changes on every start.
invocation_id() {
docker exec "$1" systemctl show -p InvocationID --value "$2" 2>/dev/null
return
}
# Record a PASS when a unit is in the wanted state, a FAIL otherwise.
expect_state() {
local name="$1" label="$2" unit="$3" want="$4" got
got=$(unit_state "$name" "$unit")
if [ "$got" = "$want" ]; then
pass "$label: $unit is $got"
else
fail "$label: $unit is $got (want $want)"
fi
return 0
}
# Start a unit under a bound, recording a FAIL when it does not start.
start_unit() {
local name="$1" unit="$2" out
if ! out=$(timeout "$UNIT_START_TIMEOUT" docker exec "$name" systemctl start "$unit" 2>&1); then
fail "setup: could not start $unit: ${out:-no output}"
return 1
fi
return 0
}
# Boot a fresh container, copy the tarball in and run the first install.
# Returns 1, having recorded a FAIL, when any of that does not happen.
prepare() {
local name="$1" label="$2"
start_container "$name" || { fail "$label: container did not start"; return 1; }
wait_for_systemd "$name" || {
fail "$label: systemd did not boot; the checks would read an unstarted system"
return 1
}
if ! docker cp "$STAGE/." "$name:$TARBALL_DIR" >/dev/null; then
fail "$label: could not copy the tarball into $name"
return 1
fi
if ! docker exec "$name" ip tuntap add fips0 mode tun >/dev/null 2>&1; then
fail "$label: could not create fips0 in $name"
return 1
fi
run_install "$name" "$label: first install" || return 1
return 0
}
scenario_all_active() {
local label="all-active" name before after
name=$(container_name "$label")
log "$label: fips, fips-dns and fips-gateway running before the upgrade"
if prepare "$name" "$label" \
&& start_unit "$name" fips.service \
&& start_unit "$name" fips-dns.service \
&& start_unit "$name" fips-gateway.service; then
expect_state "$name" "$label: before" fips.service active
expect_state "$name" "$label: before" fips-dns.service active
expect_state "$name" "$label: before" fips-gateway.service active
before=$(invocation_id "$name" fips.service)
if run_install "$name" "$label: upgrade"; then
expect_state "$name" "$label: after" fips.service active
expect_state "$name" "$label: after" fips-dns.service active
expect_state "$name" "$label: after" fips-gateway.service active
after=$(invocation_id "$name" fips.service)
if [ -z "$before" ] || [ -z "$after" ]; then
fail "$label: could not read fips.service's InvocationID (before '${before}', after '${after}')"
elif [ "$before" != "$after" ]; then
pass "$label: the upgrade restarted fips.service"
else
fail "$label: fips.service kept InvocationID $before, so the upgrade did not stop and restart it"
fi
fi
fi
cleanup_container "$name"
return 0
}
scenario_fips_only() {
local label="fips-only" name
name=$(container_name "$label")
log "$label: only fips running before the upgrade"
if prepare "$name" "$label" && start_unit "$name" fips.service; then
expect_state "$name" "$label: before" fips.service active
expect_state "$name" "$label: before" fips-dns.service inactive
expect_state "$name" "$label: before" fips-gateway.service inactive
if run_install "$name" "$label: upgrade"; then
expect_state "$name" "$label: after" fips.service active
expect_state "$name" "$label: after" fips-dns.service inactive
expect_state "$name" "$label: after" fips-gateway.service inactive
fi
fi
cleanup_container "$name"
return 0
}
scenario_none_active() {
local label="none-active" name
name=$(container_name "$label")
log "$label: nothing running before the upgrade"
if prepare "$name" "$label"; then
expect_state "$name" "$label: before" fips.service inactive
if run_install "$name" "$label: upgrade"; then
expect_state "$name" "$label: after" fips.service inactive
expect_state "$name" "$label: after" fips-dns.service inactive
expect_state "$name" "$label: after" fips-gateway.service inactive
fi
fi
cleanup_container "$name"
return 0
}
ALL_SCENARIOS="all-active fips-only none-active"
_args=()
while [ $# -gt 0 ]; do
case "$1" in
--install-sh)
INSTALL_SH="${2:?--install-sh requires a path}"
shift 2
;;
-h|--help)
echo "usage: test.sh [--install-sh PATH] [scenario ...]"
echo "scenarios: $ALL_SCENARIOS"
exit 0
;;
-*)
echo "Unknown option: $1" >&2
exit 1
;;
*)
_args+=("$1")
shift
;;
esac
done
set -- ${_args[@]+"${_args[@]}"}
if [ $# -eq 0 ]; then
scenarios="$ALL_SCENARIOS"
else
scenarios="$*"
fi
for scenario in $scenarios; do
case " $ALL_SCENARIOS " in
*" $scenario "*) ;;
*)
echo "Unknown scenario: $scenario (available: $ALL_SCENARIOS)" >&2
exit 1
;;
esac
done
if [ ! -f "$INSTALL_SH" ]; then
echo "install script not found: $INSTALL_SH" >&2
exit 1
fi
trap cleanup_all EXIT
log "Building $IMAGE"
if ! build_image; then
fail "image build failed"
elif ! stage_tarball; then
fail "could not stage the tarball"
else
for scenario in $scenarios; do
case "$scenario" in
all-active) scenario_all_active ;;
fips-only) scenario_fips_only ;;
none-active) scenario_none_active ;;
esac
echo
done
fi
echo "═══════════════════════════════════════"
echo "Results: $PASS passed, $FAIL failed"
echo "═══════════════════════════════════════"
[ "$FAIL" -eq 0 ] && [ "$PASS" -gt 0 ]