go-firewall/test/integration/guest-linux-run.sh
2026-07-08 15:54:48 -05:00

438 lines
17 KiB
Bash
Executable file

#!/usr/bin/env bash
# IN-VM script — runs inside the disposable VM, launched by cloud-init from
# host-linux-vm.sh. Do NOT run it on a workstation: it installs packages and
# enables/disables real backends, rewriting /etc/csf, /etc/apf, /etc/ufw, and
# iptables config, which would reconfigure a real host. A GOFW_ALLOW_RUN /
# disposable-VM-marker guard below refuses to run otherwise.
#
# Runs the go-firewall integration suite against real backends natively inside the
# disposable VM. This script loops sequentially over the requested backends (never
# in parallel — they all share the same kernel netfilter/ipset state) and for each
# one lazily provisions it on first use, then runs its test:
#
# ensure_provisioned — if the backend has no clean-config snapshot yet, install
# its packages, seed a minimal/clean config, and snapshot
# it under /root/gofw-base/<backend>/ (mirroring its real
# absolute path). Skipped once a snapshot exists, so a
# reused VM overlay provisions nothing.
# flush_kernel_state — clear leftover kernel netfilter/ipset state.
# restore_config — rsync the clean snapshot back over the live config.
# enable_backend — start the backend.
# run the test binary — one backend at a time.
# disable_backend — stop it again, pass or fail, before the next backend.
#
# Provisioning per backend (rather than all backends up front) means a run limited
# to a subset only pays for the packages it needs — csf and apf in particular pull
# from third-party network sources (see fetch_cached), which is slow and flaky, so
# a run that never touches them never downloads them.
#
# To run, use the Makefile:
#
# make test-integration-linux
set -u
export DEBIAN_FRONTEND=noninteractive
BASE=/root/gofw-base
CACHE_DIR=/mnt/gofw-cache
# Refuse to run unless the disposable-VM marker is present. Installing packages and
# enabling/disabling firewalld/ufw/csf/apf/iptables-persistent operates directly on
# the real host's firewall and systemd state, which is dangerous to do by accident
# on a workstation. host-linux-vm.sh writes the marker before invoking this script
# and exports GOFW_ALLOW_RUN=1 for the guest payload; either is accepted here.
MARKER=/etc/gofw-disposable-vm
if [[ ! -f "$MARKER" ]] && [[ "${GOFW_ALLOW_RUN:-0}" != "1" ]]; then
echo "!! guest-linux-run.sh must be run from inside the disposable test VM, not on your machine." >&2
echo " It installs backend packages and enables/disables real backends (firewalld, ufw," >&2
echo " csf, apf, iptables-persistent), which would reconfigure a real host's firewall." >&2
echo " Use: make test-integration-linux" >&2
exit 1
fi
backends=("$@")
if [[ ${#backends[@]} -eq 0 ]]; then
backends=(nft iptables firewalld ufw csf apf)
fi
# The host compiles the test binary into its .cache, shared here as the writable
# gofwcache mount (CACHE_DIR) — the repo share is read-only and holds no artifacts.
BIN="$CACHE_DIR/firewall.test"
# flush_kernel_state clears the shared netfilter/ipset state every backend reads
# and writes, so nothing one backend's test leaves behind can leak into the next
# backend's run. Run before AND after every backend: before, because a prior run
# in this same VM boot may have left rules behind if its test crashed; after,
# because none of these tools' own disable is guaranteed to fully clear it.
flush_kernel_state() {
nft flush ruleset 2>/dev/null || true
iptables -F 2>/dev/null || true
iptables -t nat -F 2>/dev/null || true
iptables -X 2>/dev/null || true
ip6tables -F 2>/dev/null || true
ip6tables -t nat -F 2>/dev/null || true
ip6tables -X 2>/dev/null || true
ipset destroy 2>/dev/null || true
}
# backend_config_dir prints the single config directory a backend snapshots, or
# nothing for nft (which has no persisted config).
backend_config_dir() {
case "$1" in
iptables) echo /etc/iptables ;;
firewalld) echo /etc/firewalld ;;
ufw) echo /etc/ufw ;;
csf) echo /etc/csf ;;
apf) echo /etc/apf ;;
esac
}
# restore_config rsync-restores a backend's clean config snapshot onto its own
# live config directory — never onto / itself, which would make --delete treat
# every other file on the filesystem as "extraneous" and try to remove it.
# --delete removes anything a previous test iteration left behind that
# provisioning did not seed (e.g. a rule file rewritten by AddRule).
restore_config() {
local b="$1"
local dir
dir="$(backend_config_dir "$b")"
[[ -n "$dir" ]] || return 0
if [[ -d "$BASE/$b$dir" ]]; then
rsync -a --delete "$BASE/$b$dir/" "$dir/"
fi
}
# snapshot copies each given absolute path into $BASE/<backend>/, preserving the
# absolute path underneath so restore_config can restore it with a single `rsync -a`
# scoped to that backend's own config directory (never onto / itself). Creating
# $BASE/<backend>/ also serves as the backend's "provisioned" marker.
snapshot() {
local backend="$1"
shift
mkdir -p "$BASE/$backend"
local p
for p in "$@"; do
mkdir -p "$BASE/$backend/$(dirname "$p")"
cp -a "$p" "$BASE/$backend/$p"
done
}
# pkg_installed reports whether a package is actually installed (dpkg status
# "install ok installed"), not merely known to dpkg — `dpkg -s` exits 0 even for
# a removed package whose config files remain (status "deinstall ok
# config-files"), which is exactly the state ufw and iptables-persistent leave
# each other in when their mutual apt Conflict removes one of them.
pkg_installed() {
dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q "^install ok installed"
}
# wait_active polls `systemctl is-active` for up to 15s so the test does not race
# a daemon that is still settling after `systemctl start` returns.
wait_active() {
local unit="$1"
for _ in $(seq 1 15); do
systemctl is-active --quiet "$unit" && return 0
sleep 1
done
return 1
}
# ---------------------------------------------------------------------------
# Provisioning (lazy, per backend)
#
# Two independent, idempotent concerns, each checked per backend before its test:
# - packages: gated on backend_installed (dpkg/binary presence), so the
# ufw/iptables-persistent package a conflicting backend's install removed is
# reinstalled on demand — the package state, not the config snapshot, answers
# "is it installed?".
# - config: gated on the $BASE/<backend>/ snapshot dir; seeded and snapshotted
# once, then replayed by restore_config before every test.
# A reused VM overlay keeps both, so it provisions nothing; a fresh overlay does
# only the backends this run actually touches.
# ---------------------------------------------------------------------------
apt_updated=0
# apt_update_once refreshes the package index a single time per boot, before the
# first install. Guarded so provisioning several backends in one run does not
# re-run `apt-get update` for each.
apt_update_once() {
[[ "$apt_updated" = 1 ]] && return 0
apt-get update
apt_updated=1
}
# fetch_cached downloads url to dest, retrying a few times with a backoff since
# csf's download host in particular is known to fail intermittently. A
# successful download is copied into CACHE_DIR (a writable 9p share backed by
# the host's .cache, vs. the read-only repo share) so a later re-provision — or
# a fresh VM overlay — reuses it instead of hitting the network again.
fetch_cached() {
local url="$1" dest="$2" name="$3"
local cached="$CACHE_DIR/$name"
if [[ -f "$cached" ]]; then
echo ">> using cached $name"
cp "$cached" "$dest"
return 0
fi
local attempt
for attempt in $(seq 1 5); do
if wget --tries=3 --timeout=30 -qO "$dest" "$url"; then
mkdir -p "$CACHE_DIR"
cp "$dest" "$cached"
return 0
fi
echo "!! download of $name failed (attempt $attempt/5); retrying" >&2
sleep $((attempt * 5))
done
echo "!! failed to download $name after 5 attempts" >&2
return 1
}
# backend_installed reports whether a backend's packages are already present. It
# gates provision_backend on package state rather than on the config snapshot, so
# the ufw/iptables-persistent pair — which apt's mutual Conflict makes
# install-one-remove-the-other — is reinstalled by whichever test needs it, even
# though its config snapshot already exists. csf and apf are third-party and not
# dpkg packages, so they are probed by their installed binary instead.
backend_installed() {
case "$1" in
nft) pkg_installed nftables ;;
iptables) pkg_installed netfilter-persistent ;;
firewalld) pkg_installed firewalld ;;
ufw) pkg_installed ufw ;;
csf) command -v csf >/dev/null 2>&1 ;;
apf) command -v apf >/dev/null 2>&1 ;;
*) return 1 ;;
esac
}
# provision_backend installs one backend's packages. Called only when
# backend_installed reports them missing — on first use, or to reinstall the
# ufw/iptables-persistent package a conflicting backend's install removed. It
# touches neither daemon state (enable_backend/disable_backend own that) nor config
# (ensure_config_snapshot owns that). Runs under `set -e` in a subshell (see
# ensure_provisioned) so any failing step fails the backend cleanly.
provision_backend() {
case "$1" in
nft)
# nft: kernel-native, no persisted config.
apt_update_once
apt-get install -y --no-install-recommends nftables
;;
iptables)
# iptables: installed via iptables-persistent. Preseed its debconf autosave
# prompts so the noninteractive install does not block waiting for a "save
# current rules?" answer. Install netfilter-persistent by name for clarity —
# it ships the .service unit the constructor requires, and iptables-persistent
# depends on it.
#
# ufw and iptables-persistent/netfilter-persistent mutually Conflict at the
# apt level on this distro (Ubuntu ships ufw preinstalled by default, and
# installing either package here auto-removes the other), so only one of the
# pair can be installed at a time — hence backend_installed gates this per
# test so the one a given backend needs is reinstalled on demand.
apt_update_once
echo "iptables-persistent iptables-persistent/autosave_v4 boolean false" | debconf-set-selections
echo "iptables-persistent iptables-persistent/autosave_v6 boolean false" | debconf-set-selections
apt-get install -y --no-install-recommends iptables iptables-persistent netfilter-persistent ipset
;;
firewalld)
# firewalld: available in Ubuntu's universe repo.
apt_update_once
apt-get install -y --no-install-recommends firewalld
;;
ufw)
# ufw: Ubuntu's default firewall tool.
apt_update_once
apt-get install -y --no-install-recommends ufw ipset
;;
csf)
# csf (ConfigServer Security & Firewall): third-party Perl package installed
# from upstream.
apt_update_once
apt-get install -y --no-install-recommends perl libwww-perl libio-socket-ssl-perl iptables ipset ca-certificates wget
fetch_cached https://download.configserver.dev/csf.tgz /tmp/csf.tgz csf.tgz
tar -xzf /tmp/csf.tgz -C /tmp
(cd /tmp/csf && sh install.sh)
rm -rf /tmp/csf /tmp/csf.tgz
;;
apf)
# apf (Advanced Policy Firewall): third-party package from rfxn upstream.
apt_update_once
apt-get install -y --no-install-recommends iproute2 kmod ca-certificates wget
fetch_cached https://github.com/rfxn/advanced-policy-firewall/archive/refs/heads/master.tar.gz /tmp/apf.tar.gz apf.tar.gz
tar -xzf /tmp/apf.tar.gz -C /tmp
(cd /tmp/advanced-policy-firewall-master && bash install.sh)
rm -rf /tmp/apf.tar.gz /tmp/advanced-policy-firewall-master
;;
*)
echo "!! unknown backend '$1'" >&2
return 1
;;
esac
}
# ensure_config_snapshot seeds a backend's minimal/clean config and snapshots it
# under $BASE/<backend>/ the first time it is needed, then is a no-op (the snapshot
# dir is the marker). Must run after provision_backend, since it edits config files
# the package install creates. restore_config replays this snapshot before every
# test, so a package reinstalled after a conflict always has its default config
# overwritten by the clean seed. Leaves every backend disabled — the loop's
# enable/disable owns daemon state, so nothing here starts or stops a service.
ensure_config_snapshot() {
local b="$1"
[[ -d "$BASE/$b" ]] && return 0
echo ">> [$b] seeding and snapshotting clean config"
case "$b" in
nft)
# nft has no persisted config; the snapshot dir is just a uniformity marker.
mkdir -p "$BASE/nft"
;;
iptables)
mkdir -p /etc/iptables
printf '*filter\n:INPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n*nat\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\nCOMMIT\n' \
| tee /etc/iptables/rules.v4 >/etc/iptables/rules.v6
snapshot iptables /etc/iptables/rules.v4 /etc/iptables/rules.v6
;;
firewalld)
snapshot firewalld /etc/firewalld
;;
ufw)
sed -i 's/^ENABLED=.*/ENABLED=no/' /etc/ufw/ufw.conf
snapshot ufw /etc/ufw
;;
csf)
sed -i 's/^TESTING = .*/TESTING = "0"/' /etc/csf/csf.conf
rm -f /etc/csf/csf.disable
snapshot csf /etc/csf
;;
apf)
sed -i 's/^DEVEL_MODE=.*/DEVEL_MODE="0"/' /etc/apf/conf.apf
snapshot apf /etc/apf
;;
esac
}
# ensure_provisioned makes a backend ready to test: installs its packages if
# backend_installed reports them missing, then seeds+snapshots its clean config if
# that has not been done yet. Each step runs in a `set -e` subshell so a mid-step
# failure aborts that step as a unit under this script's loop-friendly `set -u`,
# while staying contained to the one backend rather than aborting the whole run.
ensure_provisioned() {
local b="$1"
if ! backend_installed "$b"; then
echo ">> [$b] installing packages"
( set -eo pipefail; provision_backend "$b" ) || return 1
fi
( set -eo pipefail; ensure_config_snapshot "$b" ) || return 1
}
# enable_backend starts a backend's daemon against the clean config just restored.
# It restarts (rather than starts) the daemon backends to ensure the restored
# config is re-read if the service was already in a running state.
enable_backend() {
case "$1" in
nft) : ;; # kernel-native; nothing to enable
iptables)
# The unit's constructor requires UnitFileState=="enabled", so enable before
# restarting; daemon-reload picks up the generated unit for this SysV-only service.
systemctl daemon-reload
systemctl enable netfilter-persistent.service
systemctl restart netfilter-persistent.service
;;
firewalld)
# Debian/Ubuntu ships firewalld.service masked by default (it conflicts
# with the distro's default iptables/ufw setup), so it must be unmasked
# before it can start at all.
systemctl unmask firewalld.service 2>/dev/null || true
systemctl enable firewalld.service
systemctl restart firewalld.service && wait_active firewalld.service
;;
ufw)
# restore_config seeded ENABLED=no; flip it on for the test. No restart
# needed: `ufw --force enable` runs a full flush-and-re-apply even when ufw
# is already up, so it is already a restart.
sed -i 's/^ENABLED=.*/ENABLED=yes/' /etc/ufw/ufw.conf
systemctl enable ufw.service
ufw --force enable
;;
csf)
systemctl enable csf.service
systemctl restart csf.service && wait_active csf.service
;;
apf)
systemctl enable apf.service
systemctl restart apf.service && wait_active apf.service
;;
esac
}
# disable_backend stops and disables a backend's daemon so it cannot interfere with
# the next backend's test. Called after every run, pass or fail, and errors are
# ignored since the goal is only to leave the daemon down.
disable_backend() {
case "$1" in
nft) : ;;
iptables) systemctl disable --now netfilter-persistent.service 2>/dev/null || true ;;
firewalld) systemctl disable --now firewalld.service 2>/dev/null || true ;;
ufw)
ufw disable 2>/dev/null || true
systemctl disable ufw.service 2>/dev/null || true
;;
csf) systemctl disable --now csf.service lfd.service 2>/dev/null || true ;;
apf) systemctl disable --now apf.service 2>/dev/null || true ;;
esac
}
declare -A result
overall=0
# Loop through requested backends and test.
for b in "${backends[@]}"; do
# Provision on first use; a provisioning failure is contained to this backend.
if ! ensure_provisioned "$b"; then
echo "!! [$b] provisioning failed"
result[$b]="PROVISION-FAIL"
overall=1
continue
fi
echo ">> [$b] resetting kernel state and restoring clean config"
flush_kernel_state
restore_config "$b"
echo ">> [$b] enabling backend"
if ! enable_backend "$b"; then
echo "!! [$b] enable failed"
result[$b]="ENABLE-FAIL"
overall=1
disable_backend "$b"
flush_kernel_state
continue
fi
echo ">> [$b] running integration test"
FIREWALL_BACKEND="$b" "$BIN" -test.v -test.run TestIntegration
rc=$?
# Always disable, even on test failure, so one backend's failure never leaves
# it running to interfere with the next backend's test.
echo ">> [$b] disabling backend"
disable_backend "$b"
flush_kernel_state
if [[ $rc -eq 0 ]]; then
result[$b]="PASS"
else
result[$b]="FAIL($rc)"
overall=1
fi
done
echo
echo "==== integration results ===="
for b in "${backends[@]}"; do
printf " %-10s %s\n" "$b" "${result[$b]:-SKIPPED}"
done
exit $overall