Files
vanlink/docs/superpowers/plans/2026-09-23-nm-wan.md
T

29 KiB
Raw Blame History

NetworkManager-owned WANs (deploy.sh provisioning) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let deploy.sh provision NetworkManager-owned WANs (Starlink port, optional Wi-Fi uplink, cellular modem) on any host with ENABLE_NM_WAN=1, so van-failover works on the R5C exactly as on the Pi 5, without ever flapping uplinks during a routine deploy.

Architecture: New host variables select the behaviour. A routine deploy installs missing packages (NetworkManager only after the AP/LAN "unmanaged" file is in place), enables NM/ModemManager, writes the cellular NM connection as a keyfile if absent, and warns when the WAN netplan file is pending. The WAN netplan change happens only through an explicit ./deploy.sh --apply-network, which backs up /etc/netplan, installs the rendered file, moves Armbian's DHCP rule aside, and applies from a detached transient unit that verifies connectivity and rolls back automatically.

Tech Stack: bash (set -euo pipefail), sed/awk, systemd (systemd-run), NetworkManager keyfiles, netplan, apt. Tests: tests/check-templates.sh (plain bash, ok/FAIL lines).

Spec: docs/superpowers/specs/2026-09-23-nm-wan-provisioning-design.md (builds on 2026-09-23-host-config-design.md)

Global Constraints

  • Subagents never touch a live host, never push, and never run ./deploy.sh without a sub-command (--check, --diff, --templates, --post-check, --classify-unit, --classify-radio, --redact, --wan-plan, --print-apply-helper, render are the safe ones; they need no root and change nothing).
  • New required flag ENABLE_NM_WAN (exactly 0 or 1). New optional vars CELLULAR_CONN_NAME, CELLULAR_APN; WIFI_WAN_IFACE becomes optional (empty = no Wi-Fi uplink). Derived var CELLULAR_UUID.
  • Guard convention (test check 6): units/paths of a flagged feature only appear inside a column-0 if [ "$ENABLE_X" = 1 ]; then … fi guard with 4-space-indented contents (comment and echo lines are exempt).
  • A routine ./deploy.sh never changes /etc/netplan/* or restarts the network for WAN purposes. Only --apply-network does.
  • Rendered 50-van-wan.yaml for hosts/wan.conf must stay byte-identical to what the pre-split single template rendered (the Pi 5 must see no change).
  • Hosts: hosts/van.conf (R5C, van) and hosts/wan.conf (Pi 5, wan). Both get ENABLE_NM_WAN=1, CELLULAR_CONN_NAME=Koodo, CELLULAR_APN=sp.koodo.com. hosts/van.conf gets WIFI_WAN_IFACE= (empty); hosts/wan.conf keeps WIFI_WAN_IFACE=wlan0.
  • Commit messages end with two trailer lines: Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> and Claude-Session: https://claude.ai/code/session_01KqhjP7nud5gdBg25zCiRk9.
  • Never put secrets in hosts/*.conf; the Wapana PSK stays exactly where it is (moved verbatim into the fragment).

File Structure

File Responsibility
deploy.sh (modify) vars/flags, apt auto-install, NM section, --wan-plan, --apply-network, --print-apply-helper, drift warning, post_check additions
ap/50-van-wan.yaml (modify) ethernet (Starlink) part only
ap/50-van-wan-wifi.yaml (create) the wifis: stanza (Wapana AP), appended only when WIFI_WAN_IFACE is set
ap/cellular.nmconnection (create) NM keyfile template for the cellular connection
failover/config.json (modify) connection becomes @CELLULAR_CONN_NAME@
hosts/vanlink.conf.example, hosts/van.conf, hosts/wan.conf (modify) new variables
tests/check-templates.sh (modify) new checks 14–19
README.md (modify) short multi-WAN paragraph

Task 1: Variables, template split, cellular keyfile template

Files: modify deploy.sh (variable arrays, load_conf, drift check, new render_wan_netplan), ap/50-van-wan.yaml, failover/config.json, hosts/*.conf*, tests/check-templates.sh; create ap/50-van-wan-wifi.yaml, ap/cellular.nmconnection.

Interfaces:

  • Produces in deploy.sh: ENABLE_NM_WAN in REQUIRED_VARS and in load_conf's flag loop; WIFI_WAN_IFACE moved from REQUIRED_VARS to OPTIONAL_VARS; CELLULAR_CONN_NAME CELLULAR_APN appended to OPTIONAL_VARS; CELLULAR_UUID appended to DERIVED_VARS and set in load_conf; function render_wan_netplan (stdout = rendered ap/50-van-wan.yaml, plus rendered ap/50-van-wan-wifi.yaml when WIFI_WAN_IFACE is non-empty).

  • Consumed later: all of the above.

  • Step 1: Write failing tests (checks 14–16) — insert before the final exit "$fail" of tests/check-templates.sh:

# 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

git show 223652b:… is the last commit before this work touched the netplan template (the spec commit); the test needs that object in history, so do not squash it away. Run: ./tests/check-templates.sh; echo exit=$? — expected FAILs for 14–16 (no --render-wan-netplan, no keyfile template, no variables), exit=1.

  • Step 2: Split the netplan template. In ap/50-van-wan.yaml, cut the whole wifis: block (from the line wifis: through the last line of the file, including the Wapana access-points entry and its password) and paste it verbatim into the new file ap/50-van-wan-wifi.yaml, with no header comment and no blank line before wifis:. Make sure ap/50-van-wan.yaml still ends with a newline. Then render_wan_netplan (Step 5) for hosts/wan.conf must reproduce the old single template byte for byte: check 14 proves it. (Explain the split in a comment next to render_wan_netplan in deploy.sh instead.)

  • Step 3: Cellular keyfile template — create ap/cellular.nmconnection (installed 0600 by Task 2):

# NetworkManager connection for the cellular WAN (see failover/config.json, van-failover).
# Created by deploy.sh only when absent; never rewritten. No modem needs to be present.
# never-default: van-failover is the sole owner of the default route (same as the Wi-Fi/Starlink profiles).
[connection]
id=@CELLULAR_CONN_NAME@
uuid=@CELLULAR_UUID@
type=gsm
autoconnect=true

[gsm]
apn=@CELLULAR_APN@

[ipv4]
method=auto
route-metric=300
never-default=true

[ipv6]
method=auto
  • Step 4: failover/config.json: change "connection": "Koodo", to "connection": "@CELLULAR_CONN_NAME@",.

  • Step 5: deploy.sh variables and functions.

    1. REQUIRED_VARS: remove WIFI_WAN_IFACE, add ENABLE_NM_WAN after ENABLE_FRIGATE.
    2. OPTIONAL_VARS: add WIFI_WAN_IFACE CELLULAR_CONN_NAME CELLULAR_APN.
    3. DERIVED_VARS=(ONBOARD_LAN_UNMANAGED CELLULAR_UUID).
    4. In load_conf's flag loop add ENABLE_NM_WAN; after the ONBOARD_LAN_UNMANAGED= line add:
      CELLULAR_UUID=$(uuidgen -s -n @dns -N "vanlink-${CELLULAR_CONN_NAME:-}" 2>/dev/null || true)
      
    5. Add, next to render():
      render_wan_netplan() {  # ap/50-van-wan.yaml, plus the wifis stanza when the host has a Wi-Fi uplink
          render ap/50-van-wan.yaml
          if [ -n "${WIFI_WAN_IFACE:-}" ]; then
              render ap/50-van-wan-wifi.yaml
          fi
      }
      
    6. In the case block add: --render-wan-netplan) load_conf "$VANLINK_CONF"; render_wan_netplan; exit 0 ;; and document it in the header comment. Change the netplan drift check to use render_wan_netplan instead of render ap/50-van-wan.yaml (all three places, including the diff -u and the two warn texts).
    7. Test check 2's exempt list gains ap/50-van-wan-wifi.yaml and ap/cellular.nmconnection (installed by dedicated code, not install_rendered <src> /abs/dst literal lines) — add a comment saying so.
  • Step 6: Host files. hosts/vanlink.conf.example: document ENABLE_NM_WAN, CELLULAR_CONN_NAME, CELLULAR_APN, and mark WIFI_WAN_IFACE optional (empty = none). hosts/van.conf: WIFI_WAN_IFACE= (empty, with a comment: no spare radio; the R5C's two radios are APs), ENABLE_NM_WAN=1, CELLULAR_CONN_NAME=Koodo, CELLULAR_APN=sp.koodo.com. hosts/wan.conf: same three additions, WIFI_WAN_IFACE=wlan0 unchanged.

  • Step 6b: Fix the existing check-5 assertions that assume the R5C has wlan0. With hosts/van.conf now WIFI_WAN_IFACE= (empty) these existing lines in tests/check-templates.sh fail and must change for van only (leave the wan ones): expect van failover/config.json '"device": "wlan0"' becomes '"device": ""'; expect van router-status/config.json '"wifi_wan_iface": "wlan0"' becomes '"wifi_wan_iface": ""'; expect van failover/van-wlan-watchdog.service 'van-wlan-watchdog wlan0' becomes 'ExecStart=/usr/local/sbin/van-wlan-watchdog' (the script defaults to wlan0 when given no argument, exactly like today's behaviour on a host without that device). Add the matching wan assertions if they are missing ("device": "wlan0", van-wlan-watchdog wlan0). Controller already verified that van-failover handles an empty device safely: resolve() falls to the connection branch, ip_iface(None) returns None, and sh() swallows OSError, so the WAN simply reads "not present".

  • Step 7: Verify. Run ./tests/check-templates.sh (expect all ok, exit 0), ./deploy.sh --check hosts/van.conf, ./deploy.sh --check hosts/wan.conf, bash -n deploy.sh.

  • Step 8: Commit (Template NM WAN pieces: netplan split, cellular keyfile, new host variables).


Task 2: Package auto-install, NM section, --wan-plan

Files: modify deploy.sh, tests/check-templates.sh.

Interfaces:

  • Consumes (Task 1): ENABLE_NM_WAN, CELLULAR_*, render, render_wan_netplan, load_conf.

  • Produces: --wan-plan [conf] (read-only, no root) printing lines wan-plan: ENABLE_NM_WAN=<0|1>, then when 1: wifi WAN: <iface>|none (wifis stanza omitted), packages: network-manager <installed|would install>, netplan: /etc/netplan/50-van-wan.yaml <missing|differs|matches>, armbian: /etc/netplan/10-dhcp-all-interfaces.yaml <present -> would move aside|absent>, cellular profile: <name> apn=<apn> <exists|would create> or cellular profile: skipped (no CELLULAR_APN). Functions nm_wan_state_netplan (echo missing|differs|matches) reused by Task 3.

  • Step 1: Failing tests (check 17):

# 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
VANLINK_CONF=hosts/van.conf ./deploy.sh --wan-plan | grep -q 'wifi WAN: none' && ok "van: wan-plan says no Wi-Fi WAN" || bad "van: wan-plan Wi-Fi WAN"
VANLINK_CONF=hosts/wan.conf ./deploy.sh --wan-plan | grep -q 'wifi WAN: wlan0' && ok "wan: wan-plan Wi-Fi WAN wlan0" || bad "wan: wan-plan Wi-Fi WAN"

Do NOT extend check 6's awk need[...] table for the NM names: network-manager and .nmconnection legitimately appear in unguarded functions and sub-command arms (--wan-plan, --apply-network), and NetworkManager/nmcli already appear in unconditional install paths and lines. The NM section itself still follows the column-0 guard convention. Run: expect FAIL for 17 (no --wan-plan).

  • Step 2: Auto-install of required packages. In deploy.sh, network-manager is NOT in REQUIRED_PACKAGES. Replace the loop

    for pkg in "${REQUIRED_PACKAGES[@]}"; do
        dpkg -s "$pkg" >/dev/null 2>&1 || { echo "MISSING REQUIRED: $pkg (apt install $pkg)"; exit 1; }
    done
    

    with:

    missing_pkgs=()
    for pkg in "${REQUIRED_PACKAGES[@]}"; do
        dpkg -s "$pkg" >/dev/null 2>&1 || missing_pkgs+=("$pkg")
    done
    if [ ${#missing_pkgs[@]} -gt 0 ]; then
        echo "   installing missing required packages: ${missing_pkgs[*]}"
        apt-get update -qq || echo "   -> apt-get update failed (continuing with the cached lists)"
        DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing_pkgs[@]}" \
            || { echo "deploy: apt-get install failed for: ${missing_pkgs[*]}" >&2; exit 1; }
    fi
    
  • Step 3: The NM section. Add a guarded block after the access-point section (so van-ap-unmanaged.conf is already installed) and before the == apply == section:

if [ "$ENABLE_NM_WAN" = 1 ]; then
    echo "== networkmanager WANs =="
    if ! dpkg -s network-manager >/dev/null 2>&1; then
        # Installed only now: the package starts NM, which manages Wi-Fi/gsm devices by
        # default, so van-ap-unmanaged.conf (AP radios, LAN ports) must already be in place.
        apt-get update -qq || true
        DEBIAN_FRONTEND=noninteractive apt-get install -y network-manager \
            || { echo "deploy: apt-get install network-manager failed" >&2; exit 1; }
    fi
    systemctl enable --now NetworkManager ModemManager >/dev/null 2>&1 || true
    nmcli general reload conf 2>/dev/null || true
    for nm_if in "$WIFI_5G_IFACE" "$WIFI_2G_IFACE" "$LAN_IFACE" ${ONBOARD_LAN_IFACE:-}; do
        [ -e "/sys/class/net/$nm_if" ] || continue
        nm_state=$(nmcli -t -f DEVICE,STATE device 2>/dev/null | awk -F: -v d="$nm_if" '$1 == d { print $2 }') || true
        [ "$nm_state" = unmanaged ] \
            || warn "NetworkManager manages $nm_if (state: ${nm_state:-unknown}) — AP/LAN ports must be unmanaged (see ap/van-ap-unmanaged.conf)"
    done
    if [ -n "${CELLULAR_APN:-}" ]; then
        nm_keyfile="/etc/NetworkManager/system-connections/${CELLULAR_CONN_NAME}.nmconnection"
        if [ -e "$nm_keyfile" ]; then
            echo "   cellular connection ${CELLULAR_CONN_NAME} exists — left unchanged"
        else
            install_rendered ap/cellular.nmconnection "$nm_keyfile" 0600
            nmcli connection reload 2>/dev/null || true
            echo "   created cellular connection ${CELLULAR_CONN_NAME} (apn ${CELLULAR_APN}) — no modem needed"
        fi
    fi
    if [ "$(nm_wan_state_netplan)" != matches ]; then
        warn "WAN netplan is not provisioned/up to date: run ./deploy.sh --apply-network (backs up /etc/netplan and rolls back automatically if the uplink does not come back)"
    fi
fi

Requirements: inside the guard, contents indented 4 spaces; the existing drift check (== netplan drift check ==) must be replaced by this block's warning when ENABLE_NM_WAN=1 and left as-is for hosts with the flag at 0 (wrap the old check in if [ "$ENABLE_NM_WAN" != 1 ]; then … fi — a != guard is fine for check 6 since it contains no flagged unit names). Add to the enable list (set_units): when ENABLE_NM_WAN=1, NetworkManager ModemManager are appended to ENABLE_UNITS (keep the column-0 guard style used by set_units). Add near render_wan_netplan:

nm_wan_state_netplan() {  # missing | differs | matches (the installed WAN netplan vs the rendered one)
    if [ ! -f /etc/netplan/50-van-wan.yaml ]; then echo missing
    elif diff -q <(render_wan_netplan) /etc/netplan/50-van-wan.yaml >/dev/null 2>&1; then echo matches
    else echo differs; fi
}
  • Step 4: --wan-plan in the case block (needs load_conf, no root, no changes):
    --wan-plan)  # ./deploy.sh --wan-plan [conf] — what the NM WAN provisioning would do; changes nothing
        load_conf "${2:-$VANLINK_CONF}"
        echo "wan-plan: ENABLE_NM_WAN=$ENABLE_NM_WAN"
        if [ "$ENABLE_NM_WAN" = 1 ]; then
            if [ -n "${WIFI_WAN_IFACE:-}" ]; then echo "  wifi WAN: $WIFI_WAN_IFACE"; else echo "  wifi WAN: none (wifis stanza omitted)"; fi
            if dpkg -s network-manager >/dev/null 2>&1; then echo "  packages: network-manager installed"; else echo "  packages: network-manager would install"; fi
            echo "  netplan: /etc/netplan/50-van-wan.yaml $(nm_wan_state_netplan)"
            if [ -f /etc/netplan/10-dhcp-all-interfaces.yaml ]; then echo "  armbian: /etc/netplan/10-dhcp-all-interfaces.yaml present -> would move aside"; else echo "  armbian: /etc/netplan/10-dhcp-all-interfaces.yaml absent"; fi
            if [ -z "${CELLULAR_APN:-}" ]; then
                echo "  cellular profile: skipped (no CELLULAR_APN)"
            elif [ -e "/etc/NetworkManager/system-connections/${CELLULAR_CONN_NAME}.nmconnection" ]; then
                echo "  cellular profile: ${CELLULAR_CONN_NAME} apn=${CELLULAR_APN} exists"
            else
                echo "  cellular profile: ${CELLULAR_CONN_NAME} apn=${CELLULAR_APN} would create"
            fi
        fi
        exit 0 ;;

Document --wan-plan in the deploy.sh header comment. Because nm_wan_state_netplan and render_wan_netplan are defined before the case block (place both next to render), they are usable here.

  • Step 5: Verify. ./tests/check-templates.sh (all ok, exit 0), bash -n deploy.sh, ./deploy.sh --wan-plan hosts/van.conf and hosts/wan.conf (eyeball output).
  • Step 6: Commit (deploy: install missing packages, NetworkManager WAN section, --wan-plan).

Task 3: --apply-network with rollback

Files: modify deploy.sh, tests/check-templates.sh.

Interfaces:

  • Consumes (Tasks 1–2): render_wan_netplan, nm_wan_state_netplan, load_conf, die, ENABLE_NM_WAN, STARLINK_IFACE.

  • Produces: apply_network (root); apply_helper_script <backup-dir> printing the detached helper script to stdout; sub-commands --apply-network (root) and --print-apply-helper [conf] (no root; for tests and review; uses a fake backup dir path).

  • Step 1: Failing test (check 18) — before the final exit:

# 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

Run: expect FAIL (no sub-command).

  • Step 2: Helper generator (place with the other functions, before the case block):
apply_helper_script() {  # apply_helper_script <backup-dir> — the script the detached unit runs
    local bak=$1
    cat <<HELPER
#!/bin/bash
# Generated by deploy.sh --apply-network: apply the WAN netplan, verify the uplink, roll back if it did not come back.
exec >>/var/log/vanlink-apply-network.log 2>&1
echo "== apply-network \$(date -Is) (backup: $bak)"
netplan generate && netplan apply
ok=0
for i in \$(seq 1 60); do
    have_default=0; ip route show default | grep . >/dev/null && have_default=1
    nm_ok=1
    if [ -e /sys/class/net/$STARLINK_IFACE ]; then
        nm_ok=0
        nmcli -t -f DEVICE,STATE device 2>/dev/null | grep -Fx '$STARLINK_IFACE:connected' >/dev/null && nm_ok=1
    fi
    if [ \$have_default = 1 ] && [ \$nm_ok = 1 ] && ping -c1 -W2 1.1.1.1 >/dev/null 2>&1; then ok=1; break; fi
    sleep 1
done
if [ \$ok = 1 ]; then
    echo "apply-network OK"
else
    echo "apply-network FAILED — restoring $bak"
    rm -f /etc/netplan/*
    cp -a $bak/. /etc/netplan/
    netplan generate && netplan apply
    echo "apply-network ROLLED BACK"
    exit 1
fi
HELPER
}

(Unquoted heredoc on purpose: $bak and $STARLINK_IFACE expand now; \$… stay for run time. The first netplan apply failing must also reach the rollback: change the first netplan generate && netplan apply line to netplan generate && netplan apply || echo "netplan apply failed" so the loop still runs and rollback triggers on failure of the connectivity gate.)

  • Step 3: apply_network and the sub-commands:
apply_network() {  # ./deploy.sh --apply-network — explicit, root; see spec §4
    [ "$(id -u)" = 0 ] || die "--apply-network needs root (sudo)"
    [ "$ENABLE_NM_WAN" = 1 ] || die "ENABLE_NM_WAN is 0 in $VANLINK_CONF — nothing to apply"
    dpkg -s network-manager >/dev/null 2>&1 || die "network-manager is not installed — run ./deploy.sh first"
    local stamp bak tmp
    stamp=$(date +%Y%m%d-%H%M%S)
    bak=/var/backups/vanlink-netplan-$stamp
    install -d -m0700 "$bak"
    cp -a /etc/netplan/. "$bak/"
    tmp=$(mktemp)
    render_wan_netplan > "$tmp"
    check_rendered ap/50-van-wan.yaml "$tmp" || { rm -f "$tmp"; exit 1; }
    install -m0600 "$tmp" /etc/netplan/50-van-wan.yaml
    rm -f "$tmp"
    if [ -f /etc/netplan/10-dhcp-all-interfaces.yaml ]; then
        mv /etc/netplan/10-dhcp-all-interfaces.yaml /etc/netplan/10-dhcp-all-interfaces.yaml.vanlink-disabled
    fi
    : > /var/log/vanlink-apply-network.log
    apply_helper_script "$bak" > /run/vanlink-apply-network.sh
    chmod 0700 /run/vanlink-apply-network.sh
    echo "backup: $bak   log: /var/log/vanlink-apply-network.log"
    echo "applying from a detached unit (survives an SSH drop); waiting up to 90 s ..."
    systemctl reset-failed vanlink-apply-network 2>/dev/null || true
    systemd-run --unit=vanlink-apply-network --collect /bin/bash /run/vanlink-apply-network.sh >/dev/null
    local t=0
    while systemctl is-active --quiet vanlink-apply-network && [ "$t" -lt 90 ]; do sleep 1; t=$((t + 1)); done
    tail -n 6 /var/log/vanlink-apply-network.log 2>/dev/null || true
    grep -q 'apply-network OK' /var/log/vanlink-apply-network.log 2>/dev/null \
        || die "apply-network did not report OK — see /var/log/vanlink-apply-network.log (previous config: $bak)"
}

In the case block add (documented in the header):

    --apply-network)
        load_conf "$VANLINK_CONF"
        apply_network
        exit 0 ;;
    --print-apply-helper)  # for review/tests: the helper script, with a placeholder backup dir; no root, no changes
        load_conf "${2:-$VANLINK_CONF}"
        apply_helper_script /var/backups/vanlink-netplan-EXAMPLE
        exit 0 ;;

The log is truncated at the start of each run (the : > line above), so the apply-network OK grep only ever sees the current run.

  • Step 4: Verify. ./tests/check-templates.sh (all ok, exit 0), bash -n deploy.sh, ./deploy.sh --print-apply-helper hosts/van.conf | bash -n. Read the printed helper once and confirm: rollback removes /etc/netplan/* then restores the backup wholesale (so the moved-aside Armbian file returns), exit status 1 on failure.
  • Step 5: Commit (deploy: --apply-network with detached apply, connectivity gate and automatic rollback).

Task 4: Post-check additions and docs

Files: modify deploy.sh (post_check), tests/check-templates.sh, README.md.

Interfaces: consumes post_check structure, warn, retry/shared deadline, ENABLE_NM_WAN, CELLULAR_*, LAN/AP iface vars, /run/van-failover/state.json.

  • Step 1: Failing test (check 19): with a temp conf outside the repo built from hosts/van.conf with ENABLE_NM_WAN=0 forced (same pattern as check 11(ii)), POSTCHECK_WAIT=0 ./deploy.sh --post-check "$tmpconf" output must not contain NetworkManager; with hosts/van.conf as is, POSTCHECK_WAIT=0 ./deploy.sh --post-check hosts/van.conf output must mention NetworkManager (as a unit check or a warning — on the laptop it will be a warning or an ok line, either way present).
  • Step 2: post_check additions, all inside if [ "$ENABLE_NM_WAN" = 1 ]; then … fi with indented contents, each probe guarded for set -euo pipefail:
    1. NetworkManager/ModemManager: already covered by check (a) because Task 2 appended them to ENABLE_UNITS.
    2. For each of WIFI_5G_IFACE WIFI_2G_IFACE LAN_IFACE ONBOARD_LAN_IFACE that exists under /sys/class/net: NM state must be unmanaged (nmcli -t -f DEVICE,STATE device), else warn "post-deploy: NetworkManager manages <if> …"; ok line otherwise.
    3. If CELLULAR_APN is set: the connection $CELLULAR_CONN_NAME exists (nmcli -t -f NAME connection show, or its keyfile), else warn.
    4. /run/van-failover/state.json: via a guarded python3 -c (no eval, no jq dependency), for each WAN whose device is non-empty and exists in /sys/class/net, present must be true; warn per WAN where it is false; warn once if the file is absent.
    5. Netplan: nm_wan_state_netplan must be matches, else warn post-deploy: WAN netplan <state> — run ./deploy.sh --apply-network (the routine-deploy warning is repeated here so --post-check shows it too).
  • Step 3: README. Add a short section "Multi-WAN (NetworkManager)" under the header note: which hosts use it (ENABLE_NM_WAN), that deploy.sh installs the packages and the cellular NM connection, that the WAN netplan is applied only by ./deploy.sh --apply-network (with automatic rollback and log /var/log/vanlink-apply-network.log), and how to read /run/van-failover/state.json.
  • Step 4: Verify. ./tests/check-templates.sh (exit 0, no FAIL), bash -n deploy.sh, POSTCHECK_WAIT=0 ./deploy.sh --post-check hosts/van.conf (runs without abort on the laptop).
  • Step 5: Commit (post-check and docs for NetworkManager WANs).

Task 5: Roll out to the R5C — controller-run, needs the user's go-ahead

Not delegated. Preconditions to confirm with the user: console or ZeroTier access path (192.168.196.43 survives only if the uplink survives), the test dongle wlx200bd04dc756 set as WIFI_WAN_IFACE in the R5C's local hosts/van.conf (uncommitted edit, like the 2.4GHz temp dongle), and that home Wi-Fi ("Wapana") is in range if the Wi-Fi WAN is to be tested. Steps: rsync the checkout to the R5C excluding hosts/van.conf → ./deploy.sh --check → ./deploy.sh --diff (review; secrets are redacted) → ./deploy.sh --wan-plan → routine sudo ./deploy.sh (installs network-manager, keyfile, warns about pending netplan) → sudo ./deploy.sh --apply-network → cat /var/log/vanlink-apply-network.log, nmcli device, nmcli connection show, cat /run/van-failover/state.json, ./deploy.sh --post-check. Roll back path if it fails and the automatic one did not: restore /var/backups/vanlink-netplan-*.

Task 6: Pi 5 — routine deploy only, needs the user's go-ahead

Preconditions: the open questions from the host-config rollout (which of Frigate/Cockpit/Li3/nvme-watch are live; maintenance window). ./deploy.sh --diff must show 50-van-wan.yaml unchanged; ./deploy.sh --wan-plan must say netplan: … matches. --apply-network is not run on the Pi.


Self-Review (done)

Spec coverage: variables (Task 1); package auto-install incl. NM ordering and unmanaged verification (Task 2); routine-deploy behaviour incl. cellular keyfile, no netplan changes, warning text (Task 2); --apply-network steps 1–6 incl. backup, Armbian file moved aside, detached unit, connectivity gate, rollback, log (Task 3); template split with byte-identical Pi render and omitted wifis on the R5C (Task 1, check 14); post-check additions (Task 4); tests incl. --wan-plan, flag guards, rollback ordering (checks 14–19); rollout order (Tasks 5–6). Deviation from the spec text, deliberate: the cellular profile is written as a NetworkManager keyfile from a template instead of via nmcli connection add (deterministic, reviewable, no nmcli quirks for interface-less gsm profiles). Placeholders: none. Names: ENABLE_NM_WAN, CELLULAR_CONN_NAME, CELLULAR_APN, CELLULAR_UUID, render_wan_netplan, nm_wan_state_netplan, apply_helper_script, apply_network, sub-commands --render-wan-netplan --wan-plan --apply-network --print-apply-helper are used consistently across tasks and tests.