From 640d6d245cb592add0ec156ffeb9c3e9f13a34f6 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Wed, 23 Sep 2026 18:25:49 -0400 Subject: [PATCH] Add implementation plan: NetworkManager-owned WANs Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KqhjP7nud5gdBg25zCiRk9 --- docs/superpowers/plans/2026-09-23-nm-wan.md | 395 ++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-23-nm-wan.md diff --git a/docs/superpowers/plans/2026-09-23-nm-wan.md b/docs/superpowers/plans/2026-09-23-nm-wan.md new file mode 100644 index 0000000..5462271 --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-nm-wan.md @@ -0,0 +1,395 @@ +# 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.