# 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 ` 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`: ```bash # 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): ```ini # 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: ```bash CELLULAR_UUID=$(uuidgen -s -n @dns -N "vanlink-${CELLULAR_CONN_NAME:-}" 2>/dev/null || true) ``` 5. Add, next to `render()`: ```bash 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 /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: |none (wifis stanza omitted)`, ` packages: network-manager `, ` netplan: /etc/netplan/50-van-wan.yaml `, ` armbian: /etc/netplan/10-dhcp-all-interfaces.yaml would move aside|absent>`, ` cellular profile: apn= ` 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)**: ```bash # 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 ```bash for pkg in "${REQUIRED_PACKAGES[@]}"; do dpkg -s "$pkg" >/dev/null 2>&1 || { echo "MISSING REQUIRED: $pkg (apt install $pkg)"; exit 1; } done ``` with: ```bash 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: ```bash 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`: ```bash 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): ```bash --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 ` 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`: ```bash # 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): ```bash apply_helper_script() { # apply_helper_script — the script the detached unit runs local bak=$1 cat <>/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:** ```bash 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): ```bash --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 …"`; 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 — 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.