#!/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: reference copy, # rendered by hand via `deploy.sh render` (deploy.sh only warns when it differs) # 50-van-wan-wifi.yaml, cellular.nmconnection: installed by dedicated code, not `install_rendered /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 if r "$1" "$2" | grep -qF -- "$3"; then ok "$1: $2 has '$3'"; else bad "$1: $2 lacks '$3'"; fi } expect_line() { # expect_line 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 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) git show 223652b: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/^/ /'; 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, ordering, rollback branch 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" ]; then echo "$h" | bash -n && ok "apply helper: bash -n" || bad "apply helper: bash -n" echo "$h" | grep -q 'netplan generate' && echo "$h" | grep -q 'netplan apply' && ok "apply helper applies netplan" || bad "apply helper lacks netplan generate/apply" echo "$h" | grep -q 'restoring' && ok "apply helper has a rollback branch" || bad "apply helper lacks rollback" echo "$h" | grep -q 'enP2p33s0' && ok "apply helper waits on the Starlink port" || bad "apply helper does not reference STARLINK_IFACE" # rollback must come after the wait loop, and the backup is restored wholesale a=$(echo "$h" | grep -n 'netplan apply' | head -1 | cut -d: -f1); r=$(echo "$h" | grep -n 'restoring' | head -1 | cut -d: -f1) [ "$a" -lt "$r" ] && ok "apply helper: apply precedes rollback" || bad "apply helper: rollback ordering" fi exit "$fail"