Files
vanlink/tests/check-templates.sh
T
andreasandClaude Sonnet 5 e0792fade2 van-router-mqtt: broker credentials in a separate 0600 file; log refused connects
config.json is re-rendered every deploy, so credentials go in
/etc/van-router-mqtt/credentials.json (seeded empty if absent, never
overwritten). The service logs MQTT connect/refuse/disconnect state changes,
and --post-check warns when the broker refuses the service.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqhjP7nud5gdBg25zCiRk9
2026-09-23 21:45:16 -04:00

789 lines
63 KiB
Bash
Executable File

#!/usr/bin/env bash
# Static checks for the host-config templating. No root needed.
# ./tests/check-templates.sh
set -euo pipefail
cd "$(dirname "$(readlink -f "$0")")/.."
fail=0
ok() { echo "ok - $*"; }
bad() { echo "FAIL - $*"; fail=1; }
# 1. shell syntax
for f in deploy.sh failover/sync-usb-backup.sh failover/sync-sd-backup.sh tests/check-templates.sh; do
if bash -n "$f"; then ok "bash -n $f"; else bad "bash -n $f"; fi
done
# 3. every host file passes deploy.sh --check (validates vars, renders all templates)
for c in hosts/*.conf; do
if out=$(./deploy.sh --check "$c" 2>&1); then
ok "--check $c"
else
bad "--check $c"; echo "$out" | sed 's/^/ /'
fi
done
# 4. the example defines exactly the variables deploy.sh knows about
known=$(sed -n '/^REQUIRED_VARS=(/,/^)/p;/^OPTIONAL_VARS=(/,/^)/p' deploy.sh \
| grep -vE '^[A-Z_]+=\(|^\)' | tr -s ' \t' '\n' | grep -v '^$' | sort || true)
defined=$(grep -oE '^[A-Z][A-Z0-9_]*=' hosts/vanlink.conf.example 2>/dev/null | tr -d = | sort || true)
if [ -n "$known" ] && [ "$known" = "$defined" ]; then
ok "hosts/vanlink.conf.example defines exactly the variables deploy.sh knows"
else
bad "hosts/vanlink.conf.example differs from deploy.sh's variable lists"
diff <(echo "$known") <(echo "$defined") | sed 's/^/ /' || true
fi
# 2. every file carrying an @TOKEN@ is installed through install_rendered
# (plain `install` would ship the placeholder unrendered — the 2026-09-23 failure)
# vanrouter.js: installed by the loop in the cockpit block; 50-van-wan.yaml: installed only by
# the detached helper of `./deploy.sh --apply-network` (a routine deploy only warns when it differs)
# 50-van-wan-wifi.yaml, cellular.nmconnection: installed by dedicated code, not `install_rendered <src> /abs/dst` literal lines
exempt=" cockpit/vanrouter/vanrouter.js ap/50-van-wan.yaml ap/50-van-wan-wifi.yaml ap/cellular.nmconnection "
for f in $(./deploy.sh --templates); do
case "$exempt" in *" $f "*) continue ;; esac
if grep -qE "^[[:space:]]*install_rendered +$f[[:space:]]" deploy.sh; then
ok "install_rendered $f"
else
bad "$f contains an @TOKEN@ but deploy.sh does not install it with install_rendered"
fi
done
# 5. host-specific values land in the rendered output
r() { VANLINK_CONF="hosts/$1.conf" ./deploy.sh render "$2"; }
expect() { # expect <host> <file> <fixed string that must appear>
if r "$1" "$2" | grep -qF -- "$3"; then ok "$1: $2 has '$3'"; else bad "$1: $2 lacks '$3'"; fi
}
expect_line() { # expect_line <host> <file> <whole line that must appear>
if r "$1" "$2" | grep -qxF -- "$3"; then ok "$1: $2 has line '$3'"; else bad "$1: $2 lacks line '$3'"; fi
}
expect_absent() { # expect_absent <host> <file> <fixed string that must NOT appear>
if r "$1" "$2" | grep -qF -- "$3"; then bad "$1: $2 must not contain '$3'"; else ok "$1: $2 has no '$3'"; fi
}
expect van ap/nftables.conf 'ip saddr 10.42.1.0/24'
expect wan ap/nftables.conf 'ip saddr 10.42.0.0/24'
expect van ap/van-br0-setup.service 'brctl addif br0 enP1p17s0'
expect wan ap/van-br0-setup.service 'brctl addif br0 enx00e04c331140'
expect van failover/config.json '"device": ""'
expect wan failover/config.json '"device": "wlan0"'
expect van router-status/config.json '"wifi_wan_iface": ""'
expect van failover/van-wlan-watchdog.service 'ExecStart=/usr/local/sbin/van-wlan-watchdog'
expect wan failover/van-wlan-watchdog.service 'van-wlan-watchdog wlan0'
expect van ap/van-ap-unmanaged.conf 'interface-name:enP1p17s0'
expect wan ap/van-ap-unmanaged.conf 'interface-name:enx00e04c331140;interface-name:eth0'
expect wan ap/23-van-lan-eth0.network 'Name=eth0'
expect van power/nvme-watch-config.json '"nvme_device": ""'
expect wan ap/hostapd.conf 'interface=wlxc83a35a4ee55'
expect van ap/hostapd.conf 'interface=wlp1s0'
expect wan ap/hostapd-2g.conf 'interface=wlxf4f26d1760a7'
expect van ap/hostapd-2g.conf 'interface=wlx3c3332002066'
expect_line wan ap/hostapd.conf 'ssid=VanLink'
expect_line van ap/hostapd.conf 'ssid=VanLink2'
expect_line wan ap/hostapd-2g.conf 'ssid=VanLink'
expect_line van ap/hostapd-2g.conf 'ssid=VanLink2-2G'
expect_line wan ap/hostapd.conf 'vht_oper_chwidth=1'
expect_line wan ap/hostapd.conf 'he_oper_centr_freq_seg0_idx=155'
expect_line wan ap/hostapd.conf 'ieee80211h=0'
expect_line van ap/hostapd.conf 'he_su_beamformee=1'
expect_absent van ap/hostapd.conf 'vht_oper_chwidth'
if r van ap/van-ap-unmanaged.conf | grep -q 'eth0'; then
bad "van: unmanaged-devices must not mention eth0 (ONBOARD_LAN_IFACE is empty)"
else
ok "van: unmanaged-devices has no eth0"
fi
# 6. flagged units/paths only appear inside their ENABLE_* guard in deploy.sh.
# Convention: guards are column-0 `if [ "$ENABLE_X" = 1 ]; then` ... `fi`,
# contents indented (so a nested `fi` never sits in column 0).
if awk '
BEGIN {
need["van-li3-battery"]="ENABLE_LI3"
need["van-nvme-watch"]="ENABLE_NVME_WATCH"
need["nvme-watch-config"]="ENABLE_NVME_WATCH"
need["homeassistant"]="ENABLE_HA"
need["esphome"]="ENABLE_HA"
need["frigate"]="ENABLE_FRIGATE"
need["cockpit"]="ENABLE_COCKPIT"
}
/^if \[ "\$ENABLE_[A-Z0-9_]+" = 1 \]/ { match($0, /ENABLE_[A-Z0-9_]+/); guard=substr($0, RSTART, RLENGTH); next }
/^fi([[:space:]]|$)/ { guard=""; next }
/^[[:space:]]*(#|echo)/ { next }
{ for (u in need) if (index($0, u) && guard != need[u]) {
printf " deploy.sh:%d mentions %s outside an %s guard: %s\n", NR, u, need[u], $0; bad=1 } }
END { exit bad }
' deploy.sh; then ok "flagged units are only touched inside their ENABLE_* guard"; else bad "flag gating"; fi
# 7. backup scripts take their settings from the host config and say what is missing
empty=$(mktemp); trap 'rm -f "$empty"' EXIT
for pair in "failover/sync-usb-backup.sh:USB_BACKUP_ROOT_PART" "failover/sync-sd-backup.sh:SD_BACKUP_UUID"; do
s=${pair%%:*}; v=${pair##*:}
if out=$(VANLINK_CONF="$empty" "./$s" 2>&1); then
bad "$s should fail when $v is not set"
elif echo "$out" | grep -q "$v"; then
ok "$s names missing $v"
else
bad "$s failed but did not mention $v"; echo "$out" | sed 's/^/ /'
fi
done
if grep -nE '/dev/sda2|10\.42\.[01]\.44|39118305-4927' failover/sync-*-backup.sh; then
bad "hardcoded host values remain in the backup scripts"
else
ok "no hardcoded devices/UUIDs/addresses in the backup scripts"
fi
# 8. ./deploy.sh --diff runs (no root) and lists rendered files
if out=$(./deploy.sh --diff hosts/van.conf 2>&1) && echo "$out" | grep -qE '^(NEW|CHANGED) '; then
ok "deploy.sh --diff hosts/van.conf"
else
bad "deploy.sh --diff hosts/van.conf"; echo "$out" | sed 's/^/ /'
fi
if echo "$out" | grep -qE '^(NEW|CHANGED) +/etc/systemd/system/van-ap-dnsmasq\.service$'; then
ok "--diff covers plain-install files"
else
bad "--diff does not list plain-install file van-ap-dnsmasq.service"
fi
# 9. no interface name of one host hardcoded in a shipped file (the hostapd-2g.conf class of bug)
for c in hosts/*.conf; do
for v in WIFI_5G_IFACE WIFI_2G_IFACE LAN_IFACE STARLINK_IFACE; do
val=$(unset "$v"; . "./$c"; echo "${!v}")
if hits=$(git grep -nF -- "$val" -- . ':!hosts' ':!docs' ':!tests' ':!*.md' ':!*.bak'); then
bad "$c: $v value '$val' is hardcoded outside hosts/"; echo "$hits" | sed 's/^/ /'
else
ok "$c: $v value '$val' appears only in hosts/"
fi
done
done
# 10. --diff never touches seed-if-absent secret files, and redacts secret values
out=$(./deploy.sh --diff hosts/wan.conf 2>&1) || true
leak=$(echo "$out" | grep -E '/etc/van-gps/config\.json|/etc/van-li3/config\.json|/etc/van-battery/pushover\.json') || true
if [ -z "$leak" ]; then
ok "--diff ignores seeded secret files"
else
bad "--diff mentions seeded secret files"; echo "$leak" | sed 's/^/ /'
fi
red=$(printf '%s\n' 'wpa_passphrase=hunter2' '+ "password": "hunter2",' '- "token": "abc123"' ' "user": "u123"' | ./deploy.sh --redact 2>&1) || true
if echo "$red" | grep -qE 'hunter2|abc123|u123'; then
bad "--redact leaks a secret value"; echo "$red" | sed 's/^/ /'
elif echo "$red" | grep -q '^wpa_passphrase=' && echo "$red" | grep -q '^+ "password"' \
&& echo "$red" | grep -q '^- "token"' && echo "$red" | grep -q '^ "user"'; then
ok "--redact masks values, keeps keys and diff markers"
else
bad "--redact lost keys or diff markers"; echo "$red" | sed 's/^/ /'
fi
# 11. --post-check: no root, warns on this (non-router) machine, exits 1; host-aware; no stale text
pc=$(POSTCHECK_WAIT=0 ./deploy.sh --post-check hosts/van.conf 2>&1) && rc=0 || rc=$?
if [ "$rc" = 1 ] && echo "$pc" | grep -q '^== post-deploy check ==$' && echo "$pc" | grep -q -- '-> post-deploy:'; then
ok "--post-check hosts/van.conf: heading, warnings, exit 1"
else
bad "--post-check hosts/van.conf (exit $rc)"; echo "$pc" | sed 's/^/ /'
fi
tmpconf=$(mktemp); trap 'rm -f "$empty" "$tmpconf"' EXIT
{ grep -v '^ENABLE_NVME_WATCH=' hosts/wan.conf; echo 'ENABLE_NVME_WATCH=0'; } > "$tmpconf"
pc=$(POSTCHECK_WAIT=0 ./deploy.sh --post-check "$tmpconf" 2>&1) || true
if ! echo "$pc" | grep -q '^== post-deploy check ==$'; then bad "--post-check on temp conf did not run"; echo "$pc" | sed 's/^/ /'
elif echo "$pc" | grep -q 'van-nvme-watch'; then bad "--post-check mentions van-nvme-watch with ENABLE_NVME_WATCH=0"
else ok "--post-check skips van-nvme-watch when ENABLE_NVME_WATCH=0"; fi
if grep -q 'USB hub' deploy.sh; then bad "stale 'USB hub' text remains in deploy.sh"; else ok "no stale 'USB hub' text in deploy.sh"; fi
# 12. unit-state classification used by post_check (run-to-completion units are not failures)
for row in "active success 0:ok" "inactive success 0:finished" "failed exit-code 1:warn" \
"activating success 0:wait" "inactive exit-code 1:warn" "inactive success 1:warn"; do
args=${row%%:*}; want=${row##*:}
# shellcheck disable=SC2086
got=$(./deploy.sh --classify-unit $args 2>&1) || true
if [ "$got" = "$want" ]; then ok "--classify-unit $args -> $want"; else bad "--classify-unit $args gave '$got', want '$want'"; fi
done
# 13. radio beaconing classification used by post_check (present but no ssid = not beaconing)
r_ok=$'Interface wlan1\n\tifindex 5\n\ttype AP\n\tssid VanLink2\n\tchannel 149 (5745 MHz), width: 80 MHz, center1: 5775 MHz'
r_bad=$'Interface wlxd8ec5e2faa8c\n\tifindex 6\n\twdev 0x100000001\n\taddr d8:ec:5e:2f:aa:8c\n\ttype AP\n\twiphy 39\n\ttxpower 30.00 dBm'
for row in "beaconing:$r_ok" "silent:$r_bad" "silent:"; do
want=${row%%:*}; inp=${row#*:}
got=$(printf '%s\n' "$inp" | ./deploy.sh --classify-radio 2>&1) || true
if [ "$got" = "$want" ]; then ok "--classify-radio -> $want"; else bad "--classify-radio gave '$got', want '$want'"; fi
done
# 14. the netplan split leaves the Pi 5's rendered WAN netplan byte-identical, and the R5C (no Wi-Fi WAN) omits wifis
old=$(mktemp)
# 3d117e7: the last commit with the original comments, byte-identical to what the Pi 5 has installed
git show 3d117e7:ap/50-van-wan.yaml > "$old"
want=$(VANLINK_CONF=hosts/wan.conf ./deploy.sh render "$old")
tmpw=$(mktemp)
# render_wan_netplan is internal; expose it through --render-wan-netplan (documented in the header)
VANLINK_CONF=hosts/wan.conf ./deploy.sh --render-wan-netplan > "$tmpw"
if [ "$want" = "$(cat "$tmpw")" ]; then ok "wan.conf: split netplan renders identical to the old single template"; else bad "wan.conf: netplan render changed by the split"; diff <(echo "$want") "$tmpw" | head -10 | sed 's/^/ /' || true; fi
if grep -qF '/etc/vanlink.conf' "$tmpw"; then bad "wan.conf: rendered netplan mentions /etc/vanlink.conf (a comment was re-edited; live hosts would report differs)"; else ok "wan.conf: rendered netplan keeps the original comments (no /etc/vanlink.conf)"; fi
if VANLINK_CONF=hosts/van.conf ./deploy.sh --render-wan-netplan | grep -q '^ wifis:'; then bad "van.conf: wifis stanza must be omitted (WIFI_WAN_IFACE empty)"; else ok "van.conf: no wifis stanza"; fi
rm -f "$tmpw" "$old"
# 15. cellular connection name comes from the host config
expect van failover/config.json '"connection": "Koodo"'
expect wan failover/config.json '"connection": "Koodo"'
# 16. cellular keyfile template renders a valid NM gsm profile with a stable uuid
for h in van wan; do
k=$(VANLINK_CONF=hosts/$h.conf ./deploy.sh render ap/cellular.nmconnection)
for pat in '^id=Koodo$' '^type=gsm$' '^apn=sp.koodo.com$' '^route-metric=300$' '^never-default=true$' '^uuid=[0-9a-f-]{36}$'; do
if echo "$k" | grep -qE "$pat"; then ok "$h: cellular keyfile has $pat"; else bad "$h: cellular keyfile lacks $pat"; fi
done
done
# 17. --wan-plan describes what would happen (config-derived lines only; no root, no changes)
for h in van wan; do
if ! p=$(VANLINK_CONF=hosts/$h.conf ./deploy.sh --wan-plan 2>&1); then bad "$h: --wan-plan failed"; echo "$p" | sed 's/^/ /'; continue; fi
echo "$p" | grep -q '^wan-plan: ENABLE_NM_WAN=1$' && ok "$h: wan-plan header" || bad "$h: wan-plan header"
echo "$p" | grep -q 'cellular profile: Koodo apn=sp.koodo.com' && ok "$h: wan-plan cellular" || bad "$h: wan-plan cellular"
done
p=$(VANLINK_CONF=hosts/van.conf ./deploy.sh --wan-plan); echo "$p" | grep -q 'wifi WAN: none' && ok "van: wan-plan says no Wi-Fi WAN" || bad "van: wan-plan Wi-Fi WAN"
p=$(VANLINK_CONF=hosts/wan.conf ./deploy.sh --wan-plan); echo "$p" | grep -q 'wifi WAN: wlan0' && ok "wan: wan-plan Wi-Fi WAN wlan0" || bad "wan: wan-plan Wi-Fi WAN"
# CELLULAR_UUID is derived in pure bash (no uuidgen dependency) and stable
u1=$(VANLINK_CONF=hosts/van.conf ./deploy.sh render ap/cellular.nmconnection | grep '^uuid=')
u2=$(VANLINK_CONF=hosts/wan.conf ./deploy.sh render ap/cellular.nmconnection | grep '^uuid=')
[ -n "$u1" ] && [ "$u1" != "uuid=" ] && [ "$u1" = "$u2" ] && ok "cellular uuid non-empty and stable across hosts" || bad "cellular uuid: '$u1' vs '$u2'"
[ "$(grep -v '^[[:space:]]*#' deploy.sh | grep -c uuidgen)" = 0 ] && ok "deploy.sh does not call uuidgen" || bad "deploy.sh calls uuidgen"
# 17b. an existing cellular profile is found by id, whatever its file name
nmd=$(mktemp -d); nme=$(mktemp -d)
printf '[connection]\nid=Koodo\ntype=gsm\n' > "$nmd/weird-file-name.nmconnection"
p=$(VANLINK_NM_DIR=$nmd VANLINK_CONF=hosts/van.conf ./deploy.sh --wan-plan 2>&1)
echo "$p" | grep -q 'cellular profile: Koodo apn=sp.koodo.com exists' && ok "wan-plan: profile found under another file name" || bad "wan-plan: profile under other file name not found"
if command -v nmcli >/dev/null 2>&1 && nmcli -g NAME connection show 2>/dev/null | grep -Fxq Koodo; then
ok "wan-plan would-create assertion skipped (this machine's NM lists Koodo)"
else
p=$(VANLINK_NM_DIR=$nme VANLINK_CONF=hosts/van.conf ./deploy.sh --wan-plan 2>&1)
echo "$p" | grep -q 'cellular profile: Koodo apn=sp.koodo.com would create' && ok "wan-plan: empty NM dir -> would create" || bad "wan-plan: empty NM dir should say would create"
fi
rm -rf "$nmd" "$nme"
# 17c. NM must not auto-create "Wired connection" profiles
[ "$(grep -v '^[[:space:]]*#' ap/nm-no-auto-default.conf 2>/dev/null | grep -v '^[[:space:]]*$')" = "$(printf '[main]\nno-auto-default=*')" ] && ok "nm-no-auto-default.conf is [main] + no-auto-default=*" || bad "ap/nm-no-auto-default.conf missing or wrong"
# 18. the detached apply helper: syntax, then behaviour against stubbed system commands in a sandbox
h=$(VANLINK_CONF=hosts/van.conf ./deploy.sh --print-apply-helper 2>&1) || { bad "--print-apply-helper failed"; echo "$h" | sed 's/^/ /'; h=; }
if [ -n "$h" ] && [ "$(id -u)" = 0 ]; then
bad "check 18 refuses to run the helper sandbox as root"
elif [ -n "$h" ]; then
echo "$h" | bash -n && ok "apply helper: bash -n" || bad "apply helper: bash -n"
echo "$h" | grep -q 'enP2p33s0' && ok "apply helper waits on the Starlink port" || bad "apply helper does not reference STARLINK_IFACE"
echo "$h" | grep -q 'systemctl restart van-failover' && ok "apply helper restarts van-failover" || bad "apply helper lacks van-failover restart"
stubs=$(mktemp -d)
for c in netplan nmcli ip ping systemctl reboot networkctl; do
printf '#!/bin/bash\necho "%s $*" >> "$STUB_CALLS"\n' "$c" > "$stubs/$c"
done
cat >> "$stubs/networkctl" <<'STUB'
case " $* " in
*" list "*) echo " 3 enP2p33s0 ether routable ${STUB_NL_SETUP:-configured}" ;;
*" reload "*) [ -z "${STUB_RELOAD_FAIL:-}" ] || exit 1 ;;
*" status "*) echo "STUB-NETWORKCTL-STATUS" ;;
esac
exit 0
STUB
cat >> "$stubs/netplan" <<'STUB'
if [ "$1" = apply ]; then
if [ -e "${STUB_HOLD:-/nonexistent}" ]; then
: > "$STUB_SB/applying"
while [ -e "$STUB_HOLD" ]; do /bin/sleep 0.05; done
fi
n=$(cat "$STUB_SB/napply" 2>/dev/null || echo 0); echo $((n + 1)) > "$STUB_SB/napply"
[ "${STUB_FAIL_APPLY:-}" = first ] && [ "$n" = 0 ] && exit 1
fi
exit 0
STUB
cat >> "$stubs/nmcli" <<'STUB'
case "$2" in
GENERAL.STATE) echo "${NM_STATE-100 (connected)}" ;;
GENERAL.CONNECTION) echo "${NM_CONN-netplan-enP2p33s0}" ;;
IP4.ADDRESS) echo "${NM_ADDR-192.168.100.10/24}" ;;
IP4.GATEWAY) echo "${NM_GW-}" ;;
# never-default: IP4.GATEWAY is empty, the router is only in the DHCP lease options (nmcli -g joins them with " | ")
DHCP4.OPTION) echo "${NM_DHCP-requested_routers = 1 | routers = 192.168.100.1 | subnet_mask = 255.255.255.0}" ;;
GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP4.GATEWAY) echo "STUB-DIAG-NM" ;;
esac
exit 0
STUB
# STUB_ROUTE: yes | after-rollback | none; STUB_ROUTE_PROTO: static (van-failover's own route, default) | dhcp (a leftover networkd route)
cat >> "$stubs/ip" <<'STUB'
case "$*" in
"route get "*) echo "1.1.1.1 via 192.168.100.1 dev x"; exit 0 ;;
"-4 addr show "*) echo "STUB-ADDR inet 192.168.100.10/24"; exit 0 ;;
esac
r=
case "${STUB_ROUTE:-yes}" in
yes) r=1 ;;
after-rollback) [ -e "$STUB_ND/50-van-wan.yaml" ] || r=1 ;;
esac
[ -n "$r" ] || exit 0
case "$*" in
*"proto static"*) [ "${STUB_ROUTE_PROTO:-static}" = static ] && echo "default via 192.168.100.1 dev enP2p33s0 proto static metric 200" ;;
*) echo "default via 192.168.100.1 dev enP2p33s0 proto ${STUB_ROUTE_PROTO:-static} metric 200" ;;
esac
exit 0
STUB
cat >> "$stubs/ping" <<'STUB'
case " $* " in *" -I "*) exit "${STUB_GW_PING:-0}" ;; esac
exit "${STUB_NET_PING:-0}"
STUB
printf '#!/bin/bash\nexec /bin/sleep 0.05\n' > "$stubs/sleep"
printf '#!/bin/bash\n[ "$1" = -k ] && shift 2\nshift\nexec "$@"\n' > "$stubs/timeout"
printf '#!/bin/bash\n[ "$1" = cat ] && [ -n "${STUB_NO_BR0:-}" ] && exit 1\nexit 0\n' >> "$stubs/systemctl"
chmod +x "$stubs"/*
stubs_ok=1
for c in netplan nmcli ip ping systemctl reboot networkctl sleep timeout; do [ -x "$stubs/$c" ] || stubs_ok=0; done
[ "$stubs_ok" = 1 ] && ok "apply helper sandbox: all system commands stubbed" || bad "apply helper sandbox: stub missing"
an_prep() { # fresh sandbox + generated helper; sets sb
sb=$(mktemp -d); mkdir -p "$sb/netplan" "$sb/sysnet" "$sb/nmconf"
printf 'armbian\n' > "$sb/netplan/10-dhcp-all-interfaces.yaml"; chmod 0600 "$sb/netplan/10-dhcp-all-interfaces.yaml"
printf 'old\n' > "$sb/netplan/60-other.yaml"
cp -a "$sb/netplan" "$sb/bak"
# the uplink-pending NM conf, and its copy in the backup (as apply_network makes it)
if [ -z "${AN_NOPEND:-}" ]; then
printf '[keyfile]\nunmanaged-devices+=interface-name:enP2p33s0\n' > "$sb/nmconf/zz-van-wan-pending.conf"
cp -a "$sb/nmconf/zz-van-wan-pending.conf" "$sb/bak/nm-pending.conf"
fi
[ -n "${AN_NOPORT:-}" ] || mkdir "$sb/sysnet/enP2p33s0"
[ -z "${AN_NOBAK:-}" ] || rm -rf "$sb/bak"
[ -z "${AN_BAKBAD:-}" ] || chmod 000 "$sb/bak/60-other.yaml"
printf 'network:\n version: 2\n' > "$sb/rendered.yaml"
VANLINK_CONF=hosts/van.conf ./deploy.sh --print-apply-helper --backup "$sb/bak" --netplan-dir "$sb/netplan" \
--log "$sb/log" --status "$sb/status" --sysnet "$sb/sysnet" --rendered "$sb/rendered.yaml" \
--nm-conf-dir "$sb/nmconf" --deadlines "1 1 1" --stamp T > "$sb/helper.sh"
# never run a helper that does not point at the sandbox
grep -qF "nd='$sb/netplan'" "$sb/helper.sh" && grep -qF "ncd='$sb/nmconf'" "$sb/helper.sh" && [ "$(id -u)" != 0 ] \
|| { bad "helper does not bake the sandbox netplan/NM conf dirs (or root) - refusing to run"; return 1; }
}
an_run() { # an_run [VAR=val ...] — fresh sandbox, run the helper; sets sb rc
an_prep || { rc=99; return 0; }
env PATH="$stubs:/usr/bin:/bin" STUB_CALLS="$sb/calls" STUB_SB="$sb" STUB_ND="$sb/netplan" "$@" bash "$sb/helper.sh" && rc=0 || rc=$?
}
a_is() { [ "$2" = "$3" ] && ok "apply helper $1" || bad "apply helper $1 (got '$2', want '$3')"; }
st_word() { cut -d' ' -f1 "$sb/status" 2>/dev/null; }
# (1) success
an_run
a_is "success: exit 0" "$rc" 0; a_is "success: status" "$(st_word)" ok
grep -q '^ping -c1 -W2 -I enP2p33s0 192.168.100.1$' "$sb/calls" && ok "apply helper success: gateway from the DHCP routers option" || bad "apply helper success: routers option not used"
[ ! -e "$sb/helper.sh" ] && ok "apply helper success: helper removed itself" || bad "apply helper success: helper file left behind"
grep -q 'route to 1.1.1.1:' "$sb/log" && ok "apply helper success: route logged" || bad "apply helper success: no route log"
grep -q 'apply-network OK' "$sb/log" && ok "apply helper success: log OK" || bad "apply helper success: log"
a_is "success: 50-van-wan.yaml mode" "$(stat -c %a "$sb/netplan/50-van-wan.yaml" 2>/dev/null)" 600
[ -e "$sb/netplan/10-dhcp-all-interfaces.yaml.vanlink-disabled" ] && [ ! -e "$sb/netplan/10-dhcp-all-interfaces.yaml" ] && ok "apply helper success: Armbian file moved aside" || bad "apply helper success: Armbian file not moved aside"
grep -q '^systemctl restart van-failover' "$sb/calls" && ok "apply helper success: van-failover restarted" || bad "apply helper success: no van-failover restart"
[ ! -e "$sb/nmconf/zz-van-wan-pending.conf" ] && ok "apply helper success: uplink-pending NM conf removed" || bad "apply helper success: uplink-pending NM conf left in place"
[ ! -e "$sb/netplan/nm-pending.conf" ] && ok "apply helper success: no nm-pending.conf in the netplan dir" || bad "apply helper success: nm-pending.conf leaked into the netplan dir"
# order in the call log: NM conf reload -> netplan generate -> netplan apply -> van-br0-setup restart -> van-failover restart
ln_of() { grep -n -m1 -- "$1" "$sb/calls" 2>/dev/null | cut -d: -f1 || true; }
l_rl=$(ln_of '^nmcli general reload conf$'); l_gen=$(ln_of '^netplan generate'); l_ap=$(ln_of '^netplan apply')
l_br=$(ln_of '^systemctl restart van-br0-setup$'); l_fo=$(ln_of '^systemctl restart van-failover')
[ -n "$l_rl" ] && [ -n "$l_gen" ] && [ "$l_rl" -lt "$l_gen" ] && ok "apply helper success: NM conf reloaded before netplan generate" || bad "apply helper success: NM conf reload missing or after netplan generate (reload '$l_rl', generate '$l_gen')"
[ -n "$l_br" ] && [ -n "$l_ap" ] && [ -n "$l_fo" ] && [ "$l_ap" -lt "$l_br" ] && [ "$l_br" -lt "$l_fo" ] && ok "apply helper success: van-br0-setup restarted after netplan apply, before stage 2" || bad "apply helper success: van-br0-setup restart missing or misplaced (apply '$l_ap', br0 '$l_br', failover '$l_fo')"
[ "$(grep -c '^systemctl restart van-br0-setup$' "$sb/calls")" = 1 ] && ok "apply helper success: van-br0-setup restarted once" || bad "apply helper success: van-br0-setup restart count"
grep -q '^networkd (info, not gating): *3 enP2p33s0 ether routable configured' "$sb/log" && ok "apply helper success: networkd state of the port logged" || bad "apply helper success: networkd info line missing"
grep -q -- '--- diagnostics' "$sb/log" && bad "apply helper success: diagnostics must only be logged on a failed gate" || ok "apply helper success: no failure diagnostics"
l_nr=$(ln_of '^networkctl reload$'); l_s1=$(ln_of '^nmcli -g GENERAL.STATE device')
[ -n "$l_nr" ] && [ -n "$l_ap" ] && [ -n "$l_s1" ] && [ "$l_ap" -lt "$l_nr" ] && [ "$l_nr" -lt "$l_s1" ] && ok "apply helper success: networkctl reload after netplan apply, before the stage-1 probes" || bad "apply helper success: networkctl reload missing or misplaced (apply '$l_ap', reload '$l_nr', stage1 '$l_s1')"
[ "$(grep -c '^networkctl reload$' "$sb/calls")" = 1 ] && ok "apply helper success: networkd reloaded once" || bad "apply helper success: networkctl reload count"
grep -q '^networkd after reload: .*enP2p33s0 ether routable configured' "$sb/log" && ok "apply helper success: port state logged after the reload" || bad "apply helper success: no post-reload port state line"
grep -q '^networkd still manages enP2p33s0' "$sb/log" && ok "apply helper success: 'networkd still manages' noted for a configured port" || bad "apply helper success: still-manages note missing"
rm -rf "$sb"
# (1b) van-br0-setup unit absent: not restarted, still ok; no pending conf: nothing to reload
AN_NOPEND=1 an_run STUB_NO_BR0=1
a_is "no van-br0-setup unit: status" "$(st_word)" ok
grep -q '^systemctl restart van-br0-setup' "$sb/calls" && bad "apply helper: restarted a van-br0-setup that does not exist" || ok "apply helper: van-br0-setup restart skipped when the unit is absent"
grep -q '^nmcli general reload conf$' "$sb/calls" && bad "apply helper: reloaded NM conf with no pending conf" || ok "apply helper: no NM conf reload without a pending conf"
rm -rf "$sb"
# (1c) after the reload the port is unmanaged: no note. A failing reload changes nothing (logged, not fatal, not gating)
an_run STUB_NL_SETUP=unmanaged
a_is "port unmanaged after reload: status" "$(st_word)" ok
grep -q '^networkd still manages' "$sb/log" && bad "apply helper: 'still manages' noted for an unmanaged port" || ok "apply helper: no 'still manages' note for an unmanaged port"
rm -rf "$sb"
an_run STUB_RELOAD_FAIL=1
a_is "reload failure: exit 0" "$rc" 0; a_is "reload failure: status" "$(st_word)" ok
grep -q 'networkctl reload failed' "$sb/log" && ok "apply helper: failed reload is logged" || bad "apply helper: failed reload not logged"
rm -rf "$sb"
an_run STUB_RELOAD_FAIL=1 NM_STATE=bad
a_is "reload failure + stage 1 failure: status" "$(st_word)" rolled-back
rm -rf "$sb"
# (2) stage 1 fails: connected (externally)
an_run NM_STATE="100 (connected (externally))"
a_is "stage1: exit 1" "$rc" 1; a_is "stage1: status" "$(st_word)" rolled-back
grep -q 'ROLLED BACK (restored, connectivity ok)' "$sb/log" && ok "apply helper stage1: log ROLLED BACK" || bad "apply helper stage1: log"
a_is "stage1: Armbian file restored mode" "$(stat -c %a "$sb/netplan/10-dhcp-all-interfaces.yaml" 2>/dev/null)" 600
[ ! -e "$sb/netplan/50-van-wan.yaml" ] && [ ! -e "$sb/netplan/10-dhcp-all-interfaces.yaml.vanlink-disabled" ] && [ -e "$sb/netplan/60-other.yaml" ] && ok "apply helper stage1: netplan dir restored wholesale" || bad "apply helper stage1: netplan dir not restored"
cmp -s "$sb/nmconf/zz-van-wan-pending.conf" "$sb/bak/nm-pending.conf" && ok "apply helper stage1: uplink-pending NM conf restored" || bad "apply helper stage1: uplink-pending NM conf not restored"
[ ! -e "$sb/netplan/nm-pending.conf" ] && ok "apply helper stage1: nm-pending.conf not copied into the netplan dir" || bad "apply helper stage1: nm-pending.conf leaked into the netplan dir"
l_ap2=$(grep -n '^netplan apply' "$sb/calls" | sed -n 2p | cut -d: -f1 || true); l_rl2=$(grep -n '^nmcli general reload conf$' "$sb/calls" | sed -n 2p | cut -d: -f1 || true)
[ -n "$l_ap2" ] && [ -n "$l_rl2" ] && [ "$l_ap2" -lt "$l_rl2" ] && ok "apply helper stage1: NM conf reloaded after the rollback apply" || bad "apply helper stage1: no NM conf reload after the rollback apply (apply '$l_ap2', reload '$l_rl2')"
l_nr2=$(grep -n '^networkctl reload$' "$sb/calls" | sed -n 2p | cut -d: -f1 || true)
[ -n "$l_rl2" ] && [ -n "$l_nr2" ] && [ "$l_rl2" -lt "$l_nr2" ] && ok "apply helper stage1: NM conf reload before the networkd reload on rollback" || bad "apply helper stage1: networkd reload not after the NM conf reload (nm '$l_rl2', networkd '$l_nr2')"
l_b2=$(grep -n '^systemctl restart van-br0-setup$' "$sb/calls" | sed -n 2p | cut -d: -f1 || true)
[ -n "$l_b2" ] && [ "$l_nr2" -lt "$l_b2" ] && ok "apply helper stage1: networkd reload before the van-br0-setup restart" || bad "apply helper stage1: networkd reload after van-br0-setup restart"
[ -n "$l_ap2" ] && [ -n "$l_nr2" ] && [ "$l_ap2" -lt "$l_nr2" ] && ok "apply helper stage1: networkctl reload after the rollback apply" || bad "apply helper stage1: no networkctl reload after the rollback apply (apply '$l_ap2', reload '$l_nr2')"
sed -n '/restoring/,$p' "$sb/log" | grep -q '^networkd still manages' && bad "apply helper rollback: 'still manages' note belongs to the forward path only" || ok "apply helper rollback: no 'still manages' note"
for m in '--- diagnostics: stage 1' 'STUB-DIAG-NM' 'routers = 192.168.100.1' 'STUB-NETWORKCTL-STATUS' 'STUB-ADDR inet' 'default via 192.168.100.1 dev enP2p33s0'; do
sed -n '/--- diagnostics/,/--- end diagnostics/p' "$sb/log" | grep -qF -- "$m" && ok "apply helper stage1: diagnostics contain '$m'" || bad "apply helper stage1: diagnostics lack '$m'"
done
d_ln=$(grep -n -m1 -- '--- diagnostics' "$sb/log" | cut -d: -f1 || true); r_ln=$(grep -n -m1 'restoring' "$sb/log" | cut -d: -f1 || true)
[ -n "$d_ln" ] && [ -n "$r_ln" ] && [ "$d_ln" -lt "$r_ln" ] && ok "apply helper stage1: diagnostics logged before the restore" || bad "apply helper stage1: diagnostics not before the restore"
grep -q '^networkctl .*status enP2p33s0' "$sb/calls" && ok "apply helper: networkctl status called" || bad "apply helper: networkctl status not called"
grep '^networkctl .*status' "$sb/calls" | grep -qv -- ' -n 0 ' && bad "apply helper: networkctl status must pass -n 0 (no journal)" || ok "apply helper: networkctl status without journal lines"
rm -rf "$sb"
# (2c) stage 1 fails on a host without a pending conf: nothing to restore there
AN_NOPEND=1 an_run NM_STATE=bad
a_is "stage1 without pending conf: status" "$(st_word)" rolled-back
[ ! -e "$sb/nmconf/zz-van-wan-pending.conf" ] && ok "apply helper stage1 without pending conf: none created" || bad "apply helper stage1 without pending conf: one appeared"
rm -rf "$sb"
# (2b) stage 1 fails: wrong connection name; gateway unpingable
an_run NM_CONN="Wired connection 1"; a_is "stage1 conn name: status" "$(st_word)" rolled-back; rm -rf "$sb"
an_run STUB_GW_PING=1; a_is "stage1 gateway ping: status" "$(st_word)" rolled-back; rm -rf "$sb"
an_run NM_DHCP="requested_routers = 1 | subnet_mask = 255.255.255.0"; a_is "stage1 no IP4.GATEWAY and no routers option: status" "$(st_word)" rolled-back; rm -rf "$sb"
an_run NM_DHCP="routers = 192.168.100.1 192.168.100.2"; a_is "routers option with two routers: status" "$(st_word)" ok
grep -q '^ping -c1 -W2 -I enP2p33s0 192.168.100.1$' "$sb/calls" && ok "apply helper: pinged the first router" || bad "apply helper: first router not pinged"; rm -rf "$sb"
an_run NM_GW=192.168.100.9 NM_DHCP=; a_is "IP4.GATEWAY present: status" "$(st_word)" ok
grep -q '^ping -c1 -W2 -I enP2p33s0 192.168.100.9$' "$sb/calls" && ok "apply helper: pinged IP4.GATEWAY" || bad "apply helper: IP4.GATEWAY not pinged"; rm -rf "$sb"
# (3) netplan apply fails, port absent
AN_NOPORT=1 an_run STUB_FAIL_APPLY=first
a_is "apply failure: exit 1" "$rc" 1; a_is "apply failure: not ok" "$(st_word)" rolled-back
[ -e "$sb/netplan/10-dhcp-all-interfaces.yaml" ] && [ ! -e "$sb/netplan/50-van-wan.yaml" ] && ok "apply helper apply failure: restored" || bad "apply helper apply failure: not restored"
rm -rf "$sb"
# (3b) port absent and apply ok: stage 1 needs only the apply
AN_NOPORT=1 an_run; a_is "port absent, apply ok: status" "$(st_word)" ok; rm -rf "$sb"
# (4) backup dir missing: undo own changes, report failure, never reboot into the new config
AN_NOBAK=1 an_run NM_STATE=bad STUB_ROUTE=none
a_is "backup missing: exit 1" "$rc" 1; a_is "backup missing: status" "$(st_word)" rollback-failed
grep -q 'ROLLBACK FAILED: BACKUP-MISSING' "$sb/log" && ok "apply helper backup missing: loud log" || bad "apply helper backup missing: log"
grep -q '^systemctl reboot' "$sb/calls" && bad "apply helper backup missing: must not reboot" || ok "apply helper backup missing: no reboot"
[ ! -e "$sb/netplan/50-van-wan.yaml" ] && [ -e "$sb/netplan/10-dhcp-all-interfaces.yaml" ] && [ ! -e "$sb/netplan/10-dhcp-all-interfaces.yaml.vanlink-disabled" ] && ok "apply helper backup missing: own changes undone" || bad "apply helper backup missing: own changes not undone"
rm -rf "$sb"
# (4b) rollback cp fails (unreadable backup file) but connectivity is fine: loud, no reboot
AN_BAKBAD=1 an_run NM_STATE=bad
a_is "cp failure: status" "$(st_word)" rollback-failed
grep -q 'ROLLBACK FAILED: CP-FAILED' "$sb/log" && ok "apply helper cp failure: loud log" || bad "apply helper cp failure: log"
grep -q '^systemctl reboot' "$sb/calls" && bad "apply helper cp failure with connectivity: must not reboot" || ok "apply helper cp failure with connectivity: no reboot"
chmod 600 "$sb/bak/60-other.yaml"; rm -rf "$sb"
# (5) stage 2 fails: no default route until rolled back
an_run STUB_ROUTE=after-rollback
a_is "stage2: exit 1" "$rc" 1; a_is "stage2: status" "$(st_word)" rolled-back
[ ! -e "$sb/netplan/50-van-wan.yaml" ] && [ -e "$sb/netplan/10-dhcp-all-interfaces.yaml" ] && ok "apply helper stage2: restored" || bad "apply helper stage2: not restored"
rm -rf "$sb"
# (5b) stage 2: a default route that is only a leftover networkd/DHCP one (not van-failover's proto static) must not pass
an_run STUB_ROUTE_PROTO=dhcp
a_is "stage2 dhcp-only default route: exit 1" "$rc" 1; a_is "stage2 dhcp-only default route: status" "$(st_word)" rolled-back
grep -q '^ip route show default dev enP2p33s0 proto static$' "$sb/calls" && ok "apply helper stage2: checks van-failover's proto static route on the port" || bad "apply helper stage2: no proto static route check"
grep -qF -- '--- diagnostics: stage 2' "$sb/log" && ok "apply helper stage2: diagnostics logged" || bad "apply helper stage2: diagnostics missing"
# rollback: van-br0-setup restarted after the re-apply (the 2nd netplan apply), before the connectivity re-check (last ping)
l_ap2=$(grep -n '^netplan apply' "$sb/calls" | sed -n 2p | cut -d: -f1 || true); l_br2=$(grep -n '^systemctl restart van-br0-setup$' "$sb/calls" | sed -n 2p | cut -d: -f1 || true)
l_pg=$(grep -n '^ping -c1 -W2 1.1.1.1$' "$sb/calls" | tail -1 | cut -d: -f1 || true)
[ -n "$l_ap2" ] && [ -n "$l_br2" ] && [ -n "$l_pg" ] && [ "$l_ap2" -lt "$l_br2" ] && [ "$l_br2" -lt "$l_pg" ] && ok "apply helper rollback: van-br0-setup restarted after the re-apply, before the connectivity re-check" || bad "apply helper rollback: van-br0-setup restart missing or misplaced (apply '$l_ap2', br0 '$l_br2', ping '$l_pg')"
[ -e "$sb/netplan/10-dhcp-all-interfaces.yaml" ] && cmp -s "$sb/nmconf/zz-van-wan-pending.conf" "$sb/bak/nm-pending.conf" && ok "apply helper stage2: Armbian file and pending conf restored" || bad "apply helper stage2: Armbian file / pending conf not restored"
rm -rf "$sb"
# (5c) port absent: stage 2 needs no proto static route on it
AN_NOPORT=1 an_run STUB_ROUTE_PROTO=dhcp; a_is "port absent, dhcp route: status" "$(st_word)" ok; rm -rf "$sb"
# (6) clean rollback but no connectivity (uplink unplugged): must NOT reboot
an_run STUB_ROUTE=none
a_is "no connectivity after clean rollback: exit" "$rc" 1
a_is "no connectivity after clean rollback: status" "$(cut -d' ' -f1,3 "$sb/status" 2>/dev/null)" "rolled-back restored-no-connectivity;"
grep -q 'restored-no-connectivity; stage 2' "$sb/status" && ok "apply helper: original reason kept in status" || bad "apply helper: reason lost from status"
grep -q 'restored, but no connectivity' "$sb/log" && ok "apply helper: no-connectivity log" || bad "apply helper: no-connectivity log"
grep -q '^systemctl reboot' "$sb/calls" && bad "apply helper: must not reboot after a clean rollback" || ok "apply helper: no reboot after a clean rollback"
[ -e "$sb/netplan/10-dhcp-all-interfaces.yaml" ] && [ ! -e "$sb/netplan/50-van-wan.yaml" ] && ok "apply helper: clean rollback restored files" || bad "apply helper: clean rollback files"
rm -rf "$sb"
# (7) rollback cp fails AND no connectivity: last-resort reboot
AN_BAKBAD=1 an_run STUB_ROUTE=none
a_is "failed rollback, no connectivity: status" "$(st_word)" rollback-failed
grep -q '^systemctl reboot' "$sb/calls" && ok "apply helper: reboots when the rollback failed and connectivity is absent" || bad "apply helper: no reboot after failed rollback"
rm -rf "$sb"
# (8) TERM while a slow netplan apply is running: the trap restores, once
an_prep && {
: > "$sb/hold"
env PATH="$stubs:/usr/bin:/bin" STUB_CALLS="$sb/calls" STUB_SB="$sb" STUB_ND="$sb/netplan" STUB_HOLD="$sb/hold" bash "$sb/helper.sh" &
hp=$!
for _ in $(seq 1 100); do [ -e "$sb/applying" ] && break; /bin/sleep 0.05; done
[ -e "$sb/applying" ] && ok "apply helper TERM: helper reached the slow apply" || bad "apply helper TERM: never reached the apply"
a_is "TERM: status while running" "$(cut -d' ' -f1,3 "$sb/status" 2>/dev/null)" "running $sb/bak"
kill -TERM "$hp"; rm -f "$sb/hold"
wait "$hp" && rc=0 || rc=$?
a_is "TERM: exit 1" "$rc" 1
a_is "TERM: status" "$(cut -d' ' -f1,3 "$sb/status" 2>/dev/null)" "rolled-back terminated"
[ -e "$sb/netplan/10-dhcp-all-interfaces.yaml" ] && [ ! -e "$sb/netplan/50-van-wan.yaml" ] && ok "apply helper TERM: restored" || bad "apply helper TERM: not restored"
[ "$(grep -c 'restoring' "$sb/log")" = 1 ] && ok "apply helper TERM: restore ran once" || bad "apply helper TERM: restore count"
rm -rf "$sb"
}
rm -rf "$stubs"
# 18a. ordering that a behavioural test cannot reach (a signal at an exact instant), checked on the text
r1=$(echo "$h" | grep -A2 '^restore() {' | sed -n 2p)
[ "$r1" = " trap '' TERM INT HUP" ] && ok "apply helper: restore() ignores signals before its guard" || bad "apply helper: restore() must start with trap '' (got '$r1')"
so=$(echo "$h" | grep -n '^status ok$' | cut -d: -f1); tr=$(echo "$h" | grep -n '^trap - TERM INT HUP$' | cut -d: -f1)
[ -n "$so" ] && [ -n "$tr" ] && [ "$so" -lt "$tr" ] && ok "apply helper: status ok is written before the trap is removed" || bad "apply helper: status ok must precede trap removal"
echo "$h" | grep -q "^trap 'rm -f \"\$0\"' EXIT$" && ok "apply helper removes itself on exit" || bad "apply helper lacks the self-remove EXIT trap"
echo "$h" | grep -q 'timeout -k 10 30 netplan generate' && echo "$h" | grep -q 'timeout -k 10 90 netplan apply' && ok "apply helper: netplan under timeout -k" || bad "apply helper: netplan lacks timeout -k"
dg=$(echo "$h" | sed -n '/^diag() {/,/^}/p')
[ -n "$dg" ] && ! echo "$dg" | grep -E '(^ *|[;|&(] *)(nmcli|networkctl|ip) ' | grep -q . && [ "$(echo "$dg" | grep -c 'timeout 5 ')" -ge 5 ] \
&& ok "apply helper: every diagnostic command runs under timeout 5" || bad "apply helper: diag() missing or a diagnostic command without timeout 5"
grep -q '^AN_ND=' deploy.sh && grep -n '^AN_FWD=.*AN_ND' deploy.sh >/dev/null && grep -n '^AN_RB=.*AN_ND' deploy.sh >/dev/null && ok "apply helper: networkd reload time is in both worst cases" || bad "apply helper: AN_ND missing from AN_FWD/AN_RB"
echo "$h" | grep -q 'timeout 10 networkctl reload' && ok "apply helper: networkctl reload under timeout 10" || bad "apply helper: networkctl reload without timeout 10"
echo "$h" | grep -q journalctl && bad "apply helper must not call journalctl" || ok "apply helper: no journal calls"
fi
# 18b. status file gate and STARLINK_IFACE validation
gs=$(mktemp -d)
for w in running rollback-failed ok rolled-back; do
echo "$w 2026-01-01T00:00:00+00:00 x" > "$gs/s"
case $w in running|rollback-failed) want=yes ;; *) want=no ;; esac
[ "$(./deploy.sh --apply-status-check "$gs/s")" = "$want" ] && ok "apply-status-check: $w -> $want" || bad "apply-status-check: $w should be $want"
done
[ "$(./deploy.sh --apply-status-check "$gs/none")" = no ] && ok "apply-status-check: no file -> no" || bad "apply-status-check: no file"
sed 's/^STARLINK_IFACE=.*/STARLINK_IFACE="a'"'"'b"/' hosts/van.conf > "$gs/bad.conf"
if ./deploy.sh --check "$gs/bad.conf" >/dev/null 2>&1; then bad "STARLINK_IFACE with a quote must be rejected"; else ok "STARLINK_IFACE with a quote is rejected"; fi
rm -rf "$gs"
# 19. post_check NetworkManager additions
tc=$(mktemp -d)
sed 's/^ENABLE_NM_WAN=.*/ENABLE_NM_WAN=0/' hosts/van.conf > "$tc/off.conf"
pc=$(POSTCHECK_WAIT=0 ./deploy.sh --post-check "$tc/off.conf" 2>&1) || true
if ! echo "$pc" | grep -q '^== post-deploy check ==$'; then bad "19: --post-check (ENABLE_NM_WAN=0) did not run"
elif echo "$pc" | grep -q 'NetworkManager'; then bad "19: --post-check mentions NetworkManager with ENABLE_NM_WAN=0"
else ok "19: --post-check omits NetworkManager when ENABLE_NM_WAN=0"; fi
pc=$(POSTCHECK_WAIT=0 ./deploy.sh --post-check hosts/van.conf 2>&1) || true
echo "$pc" | grep -q 'NetworkManager' && ok "19: --post-check mentions NetworkManager when ENABLE_NM_WAN=1" || bad "19: --post-check lacks NetworkManager with ENABLE_NM_WAN=1"
# 19b. apply status file (VANLINK_APPLY_STATUS)
pcs() { POSTCHECK_WAIT=0 VANLINK_APPLY_STATUS="$1" ./deploy.sh --post-check hosts/van.conf 2>&1 || true; }
echo "ok 2026-01-01T00:00:00+00:00" > "$tc/st"
pcs "$tc/st" | grep -qF ' ok last --apply-network: ok 2026-01-01T00:00:00+00:00' && ok "19b: status ok -> ok line" || bad "19b: status ok line missing"
for w in "running 2026-01-01T00:00:00+00:00 /bak" "rolled-back 2026-01-01T00:00:00+00:00 no-uplink" "rollback-failed 2026-01-01T00:00:00+00:00 boom"; do
echo "$w" > "$tc/st"
pcs "$tc/st" | grep -qF -- "-> post-deploy: last --apply-network status: $w — see /var/log/vanlink-apply-network.log" && ok "19b: status ${w%% *} -> warn" || bad "19b: status ${w%% *} warn missing"
done
pcs "$tc/none" | grep -q 'last --apply-network' && bad "19b: absent status file must print nothing" || ok "19b: absent status file prints nothing"
# 19c. van-failover state.json (VANLINK_FAILOVER_STATE, VANLINK_SYSNET); inline output only (before "== warnings ==")
mkdir -p "$tc/sys/up1" "$tc/sys/up2" "$tc/sys/down"
echo 1 > "$tc/sys/up1/carrier"; echo 1 > "$tc/sys/up2/carrier"; echo 0 > "$tc/sys/down/carrier"
fo() { POSTCHECK_WAIT=0 VANLINK_SYSNET="$tc/sys" VANLINK_FAILOVER_STATE="$1" VANLINK_APPLY_STATUS="$tc/none" ./deploy.sh --post-check hosts/van.conf 2>&1 | sed '/^== warnings ==$/,$d' || true; }
cat > "$tc/f.json" <<'J'
{"updated":"2026-01-01T00:00:00+0000","wans":[{"name":"starlink","device":"up1","present":false},{"name":"cell","device":"up2","present":true},
{"name":"nolink","device":"down","present":false},{"name":"wifi","device":"","present":false},{"name":"gone","device":"nodev","present":false}]}
J
o=$(fo "$tc/f.json")
echo "$o" | grep -q -- '-> post-deploy: starlink (up1) has link but no active NetworkManager connection' && ok "19c: link up + present:false -> warn" || bad "19c: starlink warn missing"
echo "$o" | grep -q ' ok .*WAN cell' && ok "19c: link up + present:true -> ok" || bad "19c: cell ok missing"
echo "$o" | grep -qE 'nolink|wifi|gone' && bad "19c: no carrier / empty / absent device must be ignored" || ok "19c: no carrier, empty and absent devices ignored"
echo '{ not json' > "$tc/bad.json"
o=$(fo "$tc/bad.json"); [ "$(echo "$o" | grep -c -- 'failover state')" = 1 ] && ok "19c: invalid JSON -> one warn" || bad "19c: invalid JSON warn count"
o=$(fo "$tc/nofile"); [ "$(echo "$o" | grep -c -- 'failover state')" = 1 ] && ok "19c: missing file -> one warn" || bad "19c: missing file warn count"
echo '{"wans":[]}' > "$tc/noupd.json"
o=$(fo "$tc/noupd.json"); [ "$(echo "$o" | grep -c -- 'failover state')" = 1 ] && ok "19c: no updated stamp -> one warn" || bad "19c: no-updated warn count"
# 19d. status file: unrecognised / blank
echo "bogus 1 2" > "$tc/st"; pcs "$tc/st" | grep -qF -- '-> post-deploy: unrecognised apply-network status: bogus 1 2' && ok "19d: unrecognised status -> warn" || bad "19d: unrecognised status warn missing"
printf '\n \n' > "$tc/st"; pcs "$tc/st" | grep -qE 'last --apply-network|unrecognised' && bad "19d: blank status must be silent" || ok "19d: blank status silent"
# 19e. probes h (unmanaged), i (cellular profile), k (netplan) in both branches, with a stub nmcli first on PATH
mkdir -p "$tc/stub" "$tc/nm" "$tc/sys2/wlan1" "$tc/sys2/wlan2" "$tc/sys2/lan"
for i in $(sed -n 's/^WIFI_5G_IFACE=\([^ ]*\).*/\1/p;s/^WIFI_2G_IFACE=\([^ ]*\).*/\1/p;s/^LAN_IFACE=\([^ ]*\).*/\1/p' hosts/van.conf | tr -d '"'); do mkdir -p "$tc/sys2/$i"; done
cat > "$tc/stub/nmcli" <<'S'
#!/bin/sh
case "$*" in
*DEVICE,STATE*) cat "$STUB_DEV" ;;
*"NAME connection show"*) cat "$STUB_CONN" ;;
esac
S
chmod +x "$tc/stub/nmcli"
ifs=$(sed -n 's/^WIFI_5G_IFACE=\([^ ]*\).*/\1/p;s/^WIFI_2G_IFACE=\([^ ]*\).*/\1/p;s/^LAN_IFACE=\([^ ]*\).*/\1/p' hosts/van.conf | tr -d '"')
conn=$(sed -n 's/^CELLULAR_CONN_NAME=//p' hosts/van.conf | tr -d '"')
nm() { PATH="$tc/stub:/usr/bin:/bin" STUB_DEV="$tc/dev" STUB_CONN="$tc/conn" VANLINK_NM_DIR="$tc/nm" VANLINK_SYSNET="$tc/sys2" \
VANLINK_WAN_NETPLAN="$tc/np.yaml" VANLINK_APPLY_STATUS="$tc/none" POSTCHECK_WAIT=0 ./deploy.sh --post-check hosts/van.conf 2>&1 | sed '/^== warnings ==$/,$d' || true; }
: > "$tc/dev"; : > "$tc/conn"
for i in $ifs; do echo "$i:unmanaged" >> "$tc/dev"; done
echo "$conn" > "$tc/conn"; VANLINK_CONF=hosts/van.conf ./deploy.sh --render-wan-netplan > "$tc/np.yaml"
o=$(nm)
first=$(echo "$ifs" | head -1)
echo "$o" | grep -q " ok NetworkManager leaves $first unmanaged" && ok "19e: unmanaged -> ok" || bad "19e: unmanaged ok missing"
echo "$o" | grep -q " ok NetworkManager connection $conn exists" && ok "19e: cellular profile present -> ok" || bad "19e: cellular ok missing"
echo "$o" | grep -q ' ok WAN netplan matches' && ok "19e: netplan matches -> ok" || bad "19e: netplan ok missing"
echo "$first:connected" > "$tc/dev"; : > "$tc/conn"; echo '# differs' >> "$tc/np.yaml"
o=$(nm)
echo "$o" | grep -q -- "-> post-deploy: NetworkManager manages $first" && ok "19e: managed -> warn" || bad "19e: managed warn missing"
echo "$o" | grep -q -- "-> post-deploy: NetworkManager connection $conn not found" && ok "19e: cellular missing -> warn" || bad "19e: cellular warn missing"
echo "$o" | grep -q -- '-> post-deploy: WAN netplan differs' && ok "19e: netplan differs -> warn" || bad "19e: netplan warn missing"
printf 'id=%s\n' "$conn" > "$tc/nm/x.nmconnection"
nm | grep -q " ok NetworkManager connection $conn exists" && ok "19e: cellular keyfile scan -> ok" || bad "19e: keyfile scan ok missing"
# 19h. networkd must not still manage the uplink port NM owns (stub networkctl first on PATH)
mkdir -p "$tc/nostub" "$tc/sys3"
STAR=$(sed -n 's/^STARLINK_IFACE=\([^ ]*\).*/\1/p' hosts/van.conf | tr -d '"')
mkdir -p "$tc/sys3/$STAR"
cat > "$tc/stub/networkctl" <<'S'
#!/bin/sh
case "$*" in
*list*) printf ' 1 lo loopback carrier unmanaged\n 2 %sx ether routable configured\n 3 %s ether routable %s\n' "$STUB_STAR" "$STUB_STAR" "$STUB_SETUP" ;;
esac
S
chmod +x "$tc/stub/networkctl"
for x in /usr/bin/* /bin/*; do [ "${x##*/}" = networkctl ] || ln -sf "$x" "$tc/nostub/${x##*/}" 2>/dev/null || true; done
nd() { # nd <path-dir> <setup> [netplan-state: matches|missing]
[ "${3:-matches}" = matches ] && cp "$tc/np-ok.yaml" "$tc/np.yaml" || rm -f "$tc/np.yaml"
PATH="${4:-$1:/usr/bin:/bin}" STUB_STAR="$STAR" STUB_SETUP="$2" STUB_DEV="$tc/dev" STUB_CONN="$tc/conn" VANLINK_NM_DIR="$tc/nm" VANLINK_SYSNET="$tc/sys3" \
VANLINK_WAN_NETPLAN="$tc/np.yaml" VANLINK_APPLY_STATUS="$tc/none" POSTCHECK_WAIT=0 ./deploy.sh --post-check hosts/van.conf 2>&1 | sed '/^== warnings ==$/,$d' || true
}
VANLINK_CONF=hosts/van.conf ./deploy.sh --render-wan-netplan > "$tc/np-ok.yaml"
W="-> post-deploy: networkd still manages $STAR although NetworkManager owns it — run: networkctl reload"
nd "$tc/stub" configured | grep -qF -- "$W" && ok "19h: matches + configured -> warn" || bad "19h: matches + configured warn missing"
nd "$tc/stub" configuring | grep -qF -- "$W" && ok "19h: matches + configuring -> warn" || bad "19h: matches + configuring warn missing"
o=$(nd "$tc/stub" unmanaged)
echo "$o" | grep -qF " ok networkd leaves $STAR to NetworkManager" && ! echo "$o" | grep -q 'networkd still manages' && ok "19h: matches + unmanaged -> ok" || bad "19h: matches + unmanaged ok line missing"
nd "$tc/stub" configured missing | grep -qE 'networkd (still manages|leaves)' && bad "19h: netplan not matching must print nothing about networkd" || ok "19h: netplan missing + configured -> nothing"
nd "$tc/nostub" configured matches "$tc/nostub" | grep -qE 'networkd (still manages|leaves)' && bad "19h: no networkctl must print nothing" || ok "19h: no networkctl -> nothing"
rmdir "$tc/sys3/$STAR"
nd "$tc/stub" configured | grep -qE 'networkd (still manages|leaves)' && bad "19h: absent port must print nothing" || ok "19h: uplink port absent -> nothing"
# 19g. an installed WAN netplan this user cannot read (the helper installs it 0600) is "unreadable", not "differs"
if [ "$(id -u)" = 0 ]; then
ok "19g: unreadable-netplan checks skipped (running as root)"
else
chmod 000 "$tc/np.yaml"
o=$(nm)
echo "$o" | grep -qxF ' ok WAN netplan: cannot read as non-root (run with sudo to verify)' && ok "19g: post-check unreadable netplan -> ok line" || bad "19g: post-check unreadable netplan ok line missing"
echo "$o" | grep -q -- '-> post-deploy: WAN netplan' && bad "19g: post-check must not warn about an unreadable netplan" || ok "19g: post-check does not warn about an unreadable netplan"
p=$(VANLINK_WAN_NETPLAN="$tc/np.yaml" ./deploy.sh --wan-plan hosts/van.conf 2>&1)
echo "$p" | grep -qxF ' netplan: /etc/netplan/50-van-wan.yaml unreadable (run with sudo)' && ok "19g: wan-plan unreadable netplan" || bad "19g: wan-plan unreadable netplan line"
chmod 600 "$tc/np.yaml"
fi
# 19f. VANLINK_WAN_NETPLAN is a read-only-path test knob: apply_network must neutralise it, and no apply code reads other VANLINK_* vars
an=$(sed -n '/^apply_network() {/,/^}/p' deploy.sh)
echo "$an" | grep -q "VANLINK_WAN_NETPLAN='' nm_wan_state_netplan" && ! echo "$an" | grep -E 'nm_wan_state_netplan' | grep -qv "VANLINK_WAN_NETPLAN=''" \
&& ok "19f: apply_network neutralises VANLINK_WAN_NETPLAN" || bad "19f: apply_network must call VANLINK_WAN_NETPLAN='' nm_wan_state_netplan"
if { echo "$an"; sed -n '/^apply_helper_script() {/,/^}/p' deploy.sh; } | grep 'VANLINK_' | grep -vE 'VANLINK_CONF|VANLINK_WAN_NETPLAN=' | grep -q .; then bad "19f: apply path reads a VANLINK_* variable"; else ok "19f: apply path reads no VANLINK_* variable besides VANLINK_CONF"; fi
rm -rf "$tc"
# 20. final-review fixes
t20=$(mktemp -d)
# 20a. (M-7) the files keeping NM off the AP/LAN ports and the uplink are installed before the package that starts NM
l_pkg=$(grep -n 'apt-get install -y network-manager' deploy.sh | head -1 | cut -d: -f1 || true)
for pat in 'ap/van-ap-unmanaged.conf' 'ap/nm-no-auto-default.conf' 'ap/nm-uplink-pending.conf'; do
l=$(grep -n "^[[:space:]]*install[_ -].*$pat" deploy.sh | head -1 | cut -d: -f1 || true)
[ -n "$l" ] && [ -n "$l_pkg" ] && [ "$l" -lt "$l_pkg" ] && ok "20a: $pat installed (line $l) before network-manager (line $l_pkg)" || bad "20a: $pat install line '$l' must precede the network-manager install line '$l_pkg'"
done
# 20b. (M-7) read-only sub-commands never call apt-get
mkdir -p "$t20/apt"
printf '#!/bin/sh\necho "apt-get $*" >> "$APT_LOG"\nexit 1\n' > "$t20/apt/apt-get"; chmod +x "$t20/apt/apt-get"
for sub in --check --diff --post-check --wan-plan --print-apply-helper; do
PATH="$t20/apt:$PATH" APT_LOG="$t20/apt.log" POSTCHECK_WAIT=0 ./deploy.sh "$sub" hosts/van.conf >/dev/null 2>&1 || true
done
[ ! -s "$t20/apt.log" ] && ok "20b: --check/--diff/--post-check/--wan-plan/--print-apply-helper never call apt-get" || { bad "20b: a read-only sub-command called apt-get"; sed 's/^/ /' "$t20/apt.log"; }
# 20c. (M-3) network-manager is detected with dpkg-query's Status, in one helper
grep -qE 'dpkg -s network-manager' deploy.sh && bad "20c: deploy.sh still uses dpkg -s network-manager" || ok "20c: no dpkg -s network-manager"
[ "$(grep -c 'nm_pkg_installed' deploy.sh)" -ge 4 ] && grep -q "dpkg-query -W -f='\${Status}' network-manager" deploy.sh && ok "20c: nm_pkg_installed (dpkg-query) used by deploy, --wan-plan and --apply-network" || bad "20c: nm_pkg_installed missing or not used in all three places"
# 20d. (I-2) uplink-pending NM conf template and its --wan-plan line
[ "$(grep -v '^[[:space:]]*#' ap/nm-uplink-pending.conf 2>/dev/null | grep -v '^[[:space:]]*$')" = "$(printf '[keyfile]\nunmanaged-devices+=interface-name:@STARLINK_IFACE@')" ] && ok "20d: nm-uplink-pending.conf is [keyfile] + unmanaged-devices+=" || bad "20d: ap/nm-uplink-pending.conf missing or wrong"
expect_line van ap/nm-uplink-pending.conf 'unmanaged-devices+=interface-name:enP2p33s0'
if [ -e /etc/NetworkManager/conf.d/zz-van-wan-pending.conf ]; then
ok "20d: wan-plan pending-conf assertions skipped (this machine has the pending conf)"
else
VANLINK_CONF=hosts/van.conf ./deploy.sh --render-wan-netplan > "$t20/np.yaml"
p=$(VANLINK_WAN_NETPLAN="$t20/none.yaml" ./deploy.sh --wan-plan hosts/van.conf 2>&1)
echo "$p" | grep -qxF ' uplink NM pending conf: would install' && ok "20d: netplan missing -> pending conf would install" || bad "20d: netplan missing -> 'would install' expected"
p=$(VANLINK_WAN_NETPLAN="$t20/np.yaml" ./deploy.sh --wan-plan hosts/van.conf 2>&1)
echo "$p" | grep -qxF ' uplink NM pending conf: absent' && ok "20d: netplan matches -> pending conf absent" || bad "20d: netplan matches -> 'absent' expected"
echo '# differs' >> "$t20/np.yaml"
p=$(VANLINK_WAN_NETPLAN="$t20/np.yaml" ./deploy.sh --wan-plan hosts/van.conf 2>&1)
echo "$p" | grep -qxF ' uplink NM pending conf: absent' && ok "20d: netplan differs (already handed to NM) -> pending conf not installed" || bad "20d: netplan differs -> 'absent' expected"
fi
# 20e. (I-4) apply preflight: port present and conf named after this host
mkdir -p "$t20/sys/enP2p33s0" "$t20/nosys"
pf() { VANLINK_SYSNET="$1" VANLINK_HOSTNAME="$2" ./deploy.sh --apply-preflight "$3" 2>&1; }
o=$(pf "$t20/sys" van hosts/van.conf) && rc=0 || rc=$?
[ -z "$o" ] && [ "$rc" = 0 ] && ok "20e: preflight fine -> no output, exit 0" || bad "20e: preflight fine gave '$o' (exit $rc)"
o=$(pf "$t20/sys" wan hosts/van.conf) && rc=0 || rc=$?
[ "$rc" = 1 ] && [ "$(echo "$o" | grep -c .)" = 1 ] && echo "$o" | grep -q 'van.conf.*hostname wan' && ok "20e: preflight wrong host -> one problem" || bad "20e: preflight wrong host gave '$o' (exit $rc)"
o=$(pf "$t20/nosys" van hosts/van.conf) && rc=0 || rc=$?
[ "$rc" = 1 ] && [ "$(echo "$o" | grep -c .)" = 1 ] && echo "$o" | grep -q 'enP2p33s0' && ok "20e: preflight absent port -> one problem" || bad "20e: preflight absent port gave '$o' (exit $rc)"
cp hosts/van.conf "$t20/other.conf"
o=$(pf "$t20/sys" wan "$t20/other.conf") && rc=0 || rc=$?
[ -z "$o" ] && [ "$rc" = 0 ] && ok "20e: preflight skips the host-name check for a conf outside hosts/" || bad "20e: preflight conf outside hosts/ gave '$o' (exit $rc)"
an=$(sed -n '/^apply_network() {/,/^}/p' deploy.sh)
echo "$an" | grep -q 'apply_preflight_problems "$VANLINK_CONF" /sys/class/net "$(hostname -s)"' && ok "20e: apply_network runs the preflight on the real sysfs/hostname" || bad "20e: apply_network does not call apply_preflight_problems with real paths"
# 20f. (M-8) a differing installed netplan is shown redacted and refused without --force
psk=$(sed -n 's/^ *password: *"\(.*\)"$/\1/p' ap/50-van-wan-wifi.yaml || true)
d=$(diff -u /dev/null <(VANLINK_CONF=hosts/wan.conf ./deploy.sh --render-wan-netplan) | ./deploy.sh --redact) || true
[ -n "$psk" ] && ! echo "$d" | grep -qF -- "$psk" && echo "$d" | grep -qE '^\+ +password: <redacted>$' && ok "20f: --redact masks the netplan Wi-Fi password in a diff" || bad "20f: netplan Wi-Fi password not redacted"
echo "$an" | grep -q 'diff -u /etc/netplan/50-van-wan.yaml <(render_wan_netplan) | redact_secrets' && echo "$an" | grep -q 'without --force' && ok "20f: apply_network prints a redacted diff and refuses a differing netplan without --force" || bad "20f: apply_network differs handling"
# 20g. (M-6) root ignores VANLINK_NM_DIR
cpe=$(sed -n '/^cellular_profile_exists() {/,/^}/p' deploy.sh)
grep -q '"$(id -u)" = 0 ] || d=${VANLINK_NM_DIR' <<<"$cpe" && ok "20g: cellular_profile_exists ignores VANLINK_NM_DIR as root" || bad "20g: cellular_profile_exists must ignore VANLINK_NM_DIR as root"
# 20h. (M-5) documented overrides
hdr=$(sed -n '1,/^set -euo pipefail/{/^#/p}' deploy.sh)
for k in 'VANLINK_CONF=' 'VANLINK_HOSTNAME=' '--apply-preflight'; do
grep -qF -- "$k" <<<"$hdr" && ok "20h: header documents $k" || bad "20h: header lacks $k"
done
rm -rf "$t20"
# 21. van-router-mqtt: broker credentials (separate 0600 file) and refusal logging
t21=$(mktemp -d)
if python3 -c 'import ast,sys; ast.parse(open(sys.argv[1]).read())' router-status/van-router-mqtt; then ok "21a: van-router-mqtt parses"; else bad "21a: van-router-mqtt does not parse"; fi
cat > "$t21/t.py" <<'PY'
import importlib.machinery, importlib.util, io, json, os, sys, tempfile, contextlib
ldr = importlib.machinery.SourceFileLoader("vrm", "router-status/van-router-mqtt")
spec = importlib.util.spec_from_loader("vrm", ldr)
m = importlib.util.module_from_spec(spec)
buf = io.StringIO()
res = []
def check(name, cond): res.append((name, bool(cond)))
with contextlib.redirect_stdout(buf):
ldr.exec_module(m) # must not start the service or connect
d = tempfile.mkdtemp(); cfgp = os.path.join(d, "config.json"); cp = os.path.join(d, "credentials.json")
base = {"broker": "b", "username": "old", "password": "oldpw"}
r = m.load_credentials(cfgp, dict(base)); check("missing file -> unchanged", r == base)
open(cp, "w").write('{"username": "homeassistant", "password": "s3cretPW"}')
r = m.load_credentials(cfgp, {"broker": "b"}); check("valid merged", r == {"broker": "b", "username": "homeassistant", "password": "s3cretPW"})
r = m.load_credentials(cfgp, dict(base)); check("credentials override config", r["username"] == "homeassistant")
open(cp, "w").write("{not json"); r = m.load_credentials(cfgp, {"broker": "b"}); check("invalid JSON -> unchanged", r == {"broker": "b"})
open(cp, "w").write("[1]"); r = m.load_credentials(cfgp, {"broker": "b"}); check("non-object -> unchanged", r == {"broker": "b"})
open(cp, "w").write('{"username": "", "password": "x"}'); r = m.load_credentials(cfgp, {"broker": "b"}); check("empty username -> anonymous", "username" not in r and "password" not in r)
os.chmod(cp, 0); r = m.load_credentials(cfgp, {"broker": "b"}); check("unreadable -> unchanged", r == {"broker": "b"} or os.geteuid() == 0)
os.chmod(cp, 0o600); open(cp, "w").write('{"username": "u", "password": "topsecretpw"}')
m.load_credentials(cfgp, {})
check("credentials path beside config", m.credentials_path("/etc/van-router-mqtt/config.json") == "/etc/van-router-mqtt/credentials.json")
check("reason rc4", "bad username or password" in m.refusal_reason(4))
check("reason rc5", "not authorised" in m.refusal_reason(5))
check("reason unknown", "unknown" in m.refusal_reason(99) and "99" in m.refusal_reason(99))
mark = buf.tell()
m.note_connect(5); m.note_connect(5); m.note_disconnect(5); m.note_connect(5)
refused = buf.getvalue()[mark:]
check("refusal logged once, with hint", refused.count("MQTT connect refused by broker (not authorised)") == 1 and "credentials.json" in refused)
mark = buf.tell()
m.note_connect(0); m.note_connect(0); m.note_disconnect(7); m.note_disconnect(7); m.note_connect(0)
conn = buf.getvalue()[mark:]
check("connected once per connect", conn.count("MQTT connected to") == 2)
check("disconnect logged once per outage", conn.count("MQTT disconnected (rc=7)") == 1)
out = buf.getvalue()
check("password/username never printed", "s3cretPW" not in out and "topsecretpw" not in out and "homeassistant" not in out)
check("import printed nothing service-like", "started" not in out)
for n, c in res: print(("ok " if c else "FAIL ") + n)
PY
if o=$(python3 "$t21/t.py" 2>&1); then
echo "$o" | grep -q '^FAIL' && { bad "21b: behavioural checks"; echo "$o" | sed 's/^/ /'; } || ok "21b: credentials merge and refusal logging ($(echo "$o" | grep -c '^ok') checks)"
else bad "21b: test harness crashed"; echo "$o" | sed 's/^/ /'; fi
# 21c. post_check probe against a stub journalctl
mkdir -p "$t21/stub" "$t21/nojd"
for f in /usr/bin/*; do [ "${f##*/}" = journalctl ] || ln -s "$f" "$t21/nojd/${f##*/}" 2>/dev/null || true; done
mkstub() { printf '#!/bin/sh\ncat <<'"'"'EOF'"'"'\n%s\nEOF\n' "$1" > "$t21/stub/journalctl"; chmod +x "$t21/stub/journalctl"; }
pc() { PATH="$1:/usr/bin:/bin" POSTCHECK_WAIT=0 ./deploy.sh --post-check hosts/van.conf 2>&1 || true; }
R='MQTT connect refused by broker (not authorised) — if the broker requires authentication, set username/password in /x'
C='MQTT connected to localhost:1883'
mkstub "$R"; o=$(pc "$t21/stub")
echo "$o" | grep -qF 'van-router-mqtt is refused by the MQTT broker' && ok "21c: refused last -> warn" || bad "21c: refused last: no warn"
mkstub "$R
$C"; o=$(pc "$t21/stub")
{ echo "$o" | grep -qF 'van-router-mqtt: MQTT connection ok' && ! echo "$o" | grep -qF 'refused by the MQTT broker'; } && ok "21c: refused then connected -> ok" || bad "21c: refused then connected"
mkstub "$C
$R"; o=$(pc "$t21/stub")
echo "$o" | grep -qF 'refused by the MQTT broker' && ok "21c: connected then refused -> warn" || bad "21c: connected then refused"
mkstub ""; o=$(pc "$t21/stub")
{ ! echo "$o" | grep -qF 'MQTT'; } && ok "21c: no journal lines -> nothing" || bad "21c: empty journal produced MQTT output"
o=$(PATH="$t21/nojd" POSTCHECK_WAIT=0 ./deploy.sh --post-check hosts/van.conf 2>&1 || true)
{ echo "$o" | grep -qF '== post-deploy check ==' && ! echo "$o" | grep -qF 'MQTT'; } && ok "21c: journalctl missing -> nothing" || bad "21c: journalctl missing"
# 21d. example file, seeding
python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if d.get("username")=="" and d.get("password")=="" else 1)' router-status/credentials.json.example && ok "21d: credentials.json.example is valid JSON with an empty username" || bad "21d: credentials.json.example"
! grep -q CHANGE_ME router-status/credentials.json.example && ! grep -q CHANGE_ME deploy.sh && ok "21d: no CHANGE_ME in the example or deploy.sh" || bad "21d: CHANGE_ME present"
grep -qE '^ +install -D -m0600 router-status/credentials\.json\.example /etc/van-router-mqtt/credentials\.json' deploy.sh && ! grep -E 'credentials\.json' deploy.sh | grep -q -- '-m0644' && ok "21d: deploy.sh seeds credentials.json 0600, never 0644" || bad "21d: deploy.sh credentials seeding"
grep -B1 'install -D -m0600 router-status/credentials.json.example' deploy.sh | grep -qF '[ ! -f /etc/van-router-mqtt/credentials.json ]' && ok "21d: seed only if absent" || bad "21d: seed not guarded"
d=$(./deploy.sh --diff hosts/van.conf 2>&1 || true)
! echo "$d" | grep -q 'credentials.json' && ok "21d: --diff never lists credentials.json" || bad "21d: --diff mentions credentials.json"
rm -rf "$t21"
exit "$fail"