#!/usr/bin/env bash
# tests/opnsense-render-config/run-tests.sh -- offline harness for
# scripts/opnsense-render-config.sh. Unlike the other opnsense-* scripts,
# this one needs no external tool (pure bash + template substitution), so
# this harness tests the REAL behavior end-to-end, not just guard clauses.
# Exit: 0 all pass | 1 any case failed. ASCII + LF.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="$HERE/../../scripts/opnsense-render-config.sh"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0
base_env() {
export OPNSENSE_HOSTNAME="opnsense" DOMAIN="omega.dc1.vr1.cloud.neumatrix.local"
export ROOT_PASSWORD_HASH='$2y$10$fakehashfakehashfakehashfakehashfakehas'
export WAN_IF="vtnet0" WAN_IPADDR="203.0.113.2" WAN_SUBNET_BITS="30" WAN_GATEWAY="203.0.113.1"
export LAN_IF="vtnet1" LAN_IPADDR="10.12.8.1" LAN_SUBNET_BITS="22"
# PUBLIC key only (a throwaway fixture value; never a real key).
export ROOT_AUTHORIZED_KEYS="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE harness@fixture"
export DHCP_POOL_START="10.12.8.100" DHCP_POOL_END="10.12.11.199"
# MIRROR_* removed (DOCFIX-185): edge is a normal ISP router, no egress-airgap tokens.
unset MIRROR_SYNC_PROTOCOL MIRROR_UPSTREAM_NET MIRROR_SYNC_PORT
unset ROUTE1_NETWORK ROUTE1_GATEWAY ROUTE1_DESCR ROUTE2_NETWORK ROUTE2_GATEWAY ROUTE2_DESCR
unset NTP_UPSTREAM_SERVERS NTP_PREFER_SERVER
}
run_ok() { # run_ok <label> <out-file>
local label="$1" out="$2" rc
bash "$SCRIPT" "$out" >/dev/null 2>&1; rc=$?
if [[ "$rc" == 0 ]] && [ -s "$out" ]; then
echo " PASS $label"; PASS=$((PASS+1))
else
echo " FAIL $label (rc=$rc)"; FAIL=$((FAIL+1))
fi
}
run_fail() { # run_fail <want_rc> <label> <out-file>
local want="$1" label="$2" out="$3" rc
bash "$SCRIPT" "$out" >/dev/null 2>&1; rc=$?
if [[ "$rc" == "$want" ]]; then
echo " PASS $label"; PASS=$((PASS+1))
else
echo " FAIL $label (rc=$rc want=$want)"; FAIL=$((FAIL+1))
fi
}
# T1: happy path, no optional routes -- renders, no leftover {{TOKEN}}, well-formed XML
base_env
run_ok "T1 happy path renders" "$TMP/t1.xml"
if ! grep -qE '\{\{[A-Z0-9_]+\}\}' "$TMP/t1.xml" 2>/dev/null; then
echo " PASS T1b no leftover {{TOKEN}} markers"; PASS=$((PASS+1))
else
echo " FAIL T1b leftover tokens found: $(grep -oE '\{\{[A-Z0-9_]+\}\}' "$TMP/t1.xml" | tr '\n' ' ')"; FAIL=$((FAIL+1))
fi
if python3 -c "import xml.dom.minidom,sys; xml.dom.minidom.parse(sys.argv[1])" "$TMP/t1.xml" >/dev/null 2>&1; then
echo " PASS T1c well-formed XML"; PASS=$((PASS+1))
else
echo " FAIL T1c not well-formed XML"; FAIL=$((FAIL+1))
fi
# T2: missing required var FAILS
base_env
unset OPNSENSE_HOSTNAME
run_fail 1 "T2 missing required var FAILS (rc 1)" "$TMP/t2.xml"
# T3: NTP defaults apply when unset (real OPNsense default, not invented)
base_env
bash "$SCRIPT" "$TMP/t3.xml" >/dev/null 2>&1
if grep -q "0.opnsense.pool.ntp.org" "$TMP/t3.xml" 2>/dev/null; then
echo " PASS T3 NTP default pool applied when unset"; PASS=$((PASS+1))
else
echo " FAIL T3 NTP default pool missing"; FAIL=$((FAIL+1))
fi
# T4: one static route renders correctly, second slot stays empty
base_env
export ROUTE1_NETWORK="fd00:dc2::/64" ROUTE1_GATEWAY="fd00:mesh::2" ROUTE1_DESCR="to DC2"
bash "$SCRIPT" "$TMP/t4.xml" >/dev/null 2>&1
if grep -q "fd00:dc2::/64" "$TMP/t4.xml" 2>/dev/null && grep -q "<staticroutes>" "$TMP/t4.xml" 2>/dev/null; then
echo " PASS T4 static route 1 renders"; PASS=$((PASS+1))
else
echo " FAIL T4 static route 1 missing from output"; FAIL=$((FAIL+1))
fi
# T5: route network set but gateway missing FAILS
base_env
export ROUTE1_NETWORK="10.0.0.0/24"
unset ROUTE1_GATEWAY
run_fail 1 "T5 route network without gateway FAILS (rc 1)" "$TMP/t5.xml"
# T6: missing output arg FAILS
base_env
out="$(bash "$SCRIPT" 2>&1)"; rc=$?
if [[ "$rc" == 1 ]] && grep -q "usage: opnsense-render-config.sh" <<<"$out"; then
echo " PASS T6 missing output arg FAILS (rc 1)"; PASS=$((PASS+1))
else
echo " FAIL T6 missing output arg (rc=$rc)"; FAIL=$((FAIL+1))
fi
# --- D-112(c): the edge's management path is SSH, so a rendered config MUST be reachable ---
# The pre-D-112 template had <ssh><group>admins</group></ssh> and NO key: applying it would
# have DISABLED sshd and installed no authorized key, locking management out to the console.
# These three cases make that unrepeatable.
base_env
run_ok "T7 renders with an SSH key" "$TMP/t7.xml"
if grep -q "<enabled>enabled</enabled>" "$TMP/t7.xml" && grep -q "<permitrootlogin>" "$TMP/t7.xml"; then
echo " PASS T7b sshd is ENABLED + permitrootlogin set (not the lockout config)"; PASS=$((PASS+1))
else
echo " FAIL T7b rendered config does not enable sshd -- edge would be unmanageable"; FAIL=$((FAIL+1))
fi
# The authorizedkeys value must be BASE64 of the public key -- OPNsense base64_decode()s it
# (upstream src/etc/inc/auth.inc). Assert the ROUND TRIP, not merely that a field is present:
# a wrongly-encoded key yields a well-formed config that silently grants no access.
akeys="$(sed -n 's:.*<authorizedkeys>\(.*\)</authorizedkeys>.*:\1:p' "$TMP/t7.xml")"
if [ -n "$akeys" ] && printf '%s' "$akeys" | base64 -d 2>/dev/null | grep -qF "$ROOT_AUTHORIZED_KEYS"; then
echo " PASS T7c <authorizedkeys> base64-DECODES back to the supplied public key"; PASS=$((PASS+1))
else
echo " FAIL T7c <authorizedkeys> missing or does not decode to the key"; FAIL=$((FAIL+1))
fi
# T8: no key supplied -> FAIL LOUD. An edge rendered without a key is a lockout waiting to
# happen, so this must not be silently allowed.
base_env
unset ROOT_AUTHORIZED_KEYS
run_fail 1 "T8 missing ROOT_AUTHORIZED_KEYS FAILS (rc 1) -- no silent lockout" "$TMP/t8.xml"
# T9: the edge is a VM with NO VIDEO DEVICE, so the SERIAL console is its only break-glass
# path. A config that does not select the serial console silences it permanently after the
# first reboot (measured 2026-07-12: console went dead, box reachable only because sshd was
# on). Assert the rendered config keeps serial alive.
base_env
run_ok "T9 renders" "$TMP/t9.xml"
if grep -q "<primaryconsole>serial</primaryconsole>" "$TMP/t9.xml" && grep -q "<serialspeed>115200</serialspeed>" "$TMP/t9.xml"; then
echo " PASS T9b serial console retained (break-glass survives; no video device exists)"; PASS=$((PASS+1))
else
echo " FAIL T9b rendered config does not select the serial console -- console would go dead"; FAIL=$((FAIL+1))
fi
# There is no `enableserial` in OPNsense (that is a pfSense key). Guard against re-adding it.
if ! grep -q "<enableserial" "$TMP/t9.xml"; then
echo " PASS T9c no bogus <enableserial> (pfSense-ism, not an OPNsense key)"; PASS=$((PASS+1))
else
echo " FAIL T9c <enableserial> present -- not an OPNsense key"; FAIL=$((FAIL+1))
fi
# --- LAN DHCP via Kea. OPNsense 26.1 has NO ISC dhcpd, so an old <dhcpd> block is inert.
# Until 2026-07-12 the template had NO DHCP section at all: the edge routed but served no
# addresses, while the ledger claimed it did. These cases stop that recurring.
base_env
run_ok "T10 renders with Kea DHCP" "$TMP/t10.xml"
if grep -q "<Kea>" "$TMP/t10.xml" && grep -q "<pools>10.12.8.100-10.12.11.199</pools>" "$TMP/t10.xml"; then
echo " PASS T10b Kea block present with the requested pool"; PASS=$((PASS+1))
else
echo " FAIL T10b no Kea block / wrong pool -- the edge would serve no DHCP"; FAIL=$((FAIL+1))
fi
# dhcp4 must actually be ENABLED and bound to the LAN interface identifier (not a device name).
if sed -n '/<dhcp4/,/<\/general>/p' "$TMP/t10.xml" | grep -q "<enabled>1</enabled>" \
&& sed -n '/<dhcp4/,/<\/general>/p' "$TMP/t10.xml" | grep -q "<interfaces>lan</interfaces>"; then
echo " PASS T10c dhcp4 ENABLED and bound to the 'lan' interface identifier"; PASS=$((PASS+1))
else
echo " FAIL T10c dhcp4 not enabled / not bound to lan"; FAIL=$((FAIL+1))
fi
# The subnet must be the LAN NETWORK, not the LAN host address (10.12.8.1/22 -> 10.12.8.0/22).
if grep -q "<subnet>10.12.8.0/22</subnet>" "$TMP/t10.xml"; then
echo " PASS T10d subnet is the LAN NETWORK (10.12.8.0/22), not the host address"; PASS=$((PASS+1))
else
echo " FAIL T10d subnet is not the derived LAN network"; FAIL=$((FAIL+1))
fi
# The subnet4 uuid must be DETERMINISTIC, so re-rendering does not churn the config.
base_env
run_ok "T10e re-render" "$TMP/t10e.xml"
u1="$(grep -o 'subnet4 uuid="[^"]*"' "$TMP/t10.xml")"
u2="$(grep -o 'subnet4 uuid="[^"]*"' "$TMP/t10e.xml")"
if [ -n "$u1" ] && [ "$u1" = "$u2" ]; then
echo " PASS T10f subnet4 uuid is deterministic across renders (idempotent)"; PASS=$((PASS+1))
else
echo " FAIL T10f subnet4 uuid changed between renders -- config would churn"; FAIL=$((FAIL+1))
fi
# T11: a pool OUTSIDE the LAN subnet is XML-valid but serves nobody -> must FAIL LOUD.
base_env
export DHCP_POOL_START="192.168.99.100" DHCP_POOL_END="192.168.99.199"
run_fail 1 "T11 pool outside the LAN subnet FAILS (rc 1)" "$TMP/t11.xml"
# T12: a pool that swallows the router's own LAN address -> must FAIL LOUD.
base_env
export DHCP_POOL_START="10.12.8.1" DHCP_POOL_END="10.12.8.199"
run_fail 1 "T12 pool containing the LAN address itself FAILS (rc 1)" "$TMP/t12.xml"
# T13: reversed pool bounds -> must FAIL LOUD.
base_env
export DHCP_POOL_START="10.12.8.199" DHCP_POOL_END="10.12.8.100"
run_fail 1 "T13 reversed pool bounds FAIL (rc 1)" "$TMP/t13.xml"
echo; echo "RESULT: PASS=$PASS FAIL=$FAIL"
[[ "$FAIL" -eq 0 ]] && { echo "ALL PASS"; exit 0; } || exit 1