mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-29 23:27:37 +00:00
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.
369 lines
13 KiB
Bash
Executable File
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 ]
|