# NetworkManager-owned WANs, provisioned by deploy.sh Date: 2026-09-23. Status: design approved in chat; spec for review. Builds on `2026-09-23-host-config-design.md` (host files, `ENABLE_*` flags, `--check/--diff/--post-check`). ## Problem `van-failover` steers multi-WAN through NetworkManager: it reads active connections and gateways with `nmcli`, sets route metrics per NM profile, and the cellular WAN is a NM `gsm` profile (`Koodo`). The Pi 5 runs that model. The R5C has no NetworkManager (networkd only; Armbian's `10-dhcp-all-interfaces.yaml` does DHCP on every `e*` port), so on the R5C `van-failover` sees no connections at all (`present: false`, `connection: null`); it "works" only because networkd's single DHCP default route happens to be on the Starlink port. A GSM modem (Quectel EC25-AF, MBIM via ModemManager) will be attached later, and the home LAN on `enP2p33s0` is a stand-in for the field's Starlink connection, so the R5C needs the same NM-owned WAN model as the Pi, and it must be provisioned by `deploy.sh` rather than by hand. ## Decisions (approved) - NetworkManager owns the WAN side on every host that sets `ENABLE_NM_WAN=1`; `systemd-networkd` keeps br0, the AP radios and the wired LAN port(s), as today. One code path on both hosts. - A routine `deploy.sh` never flaps uplinks. Changing the WAN netplan configuration is an explicit step, `./deploy.sh --apply-network`, with an automatic rollback. - `deploy.sh` installs the packages it needs. ## Design ### 1. Host variables (add to `REQUIRED_VARS` / `OPTIONAL_VARS`; `hosts/vanlink.conf.example` and both host files) | Variable | Kind | Meaning | |---|---|---| | `ENABLE_NM_WAN` | required flag 0/1 | NetworkManager owns the WAN ports and the modem. Both `hosts/van.conf` and `hosts/wan.conf` set 1. | | `CELLULAR_CONN_NAME` | optional | NM connection name of the cellular WAN; `failover/config.json` uses `@CELLULAR_CONN_NAME@` instead of the hardcoded `Koodo`. Default in both host files: `Koodo`. | | `CELLULAR_APN` | optional | APN for the cellular profile (`sp.koodo.com`). Empty: no profile is created. | | `WIFI_WAN_IFACE` | now optional | Empty means the host has no Wi-Fi uplink (the R5C: both radios are APs); the `wifis:` stanza of the netplan template is then omitted. | `ENABLE_NM_WAN=0` means none of the below applies (current R5C behaviour). ### 2. Package installation - Missing REQUIRED packages are installed by `deploy.sh` itself (`DEBIAN_FRONTEND=noninteractive apt-get install -y`) instead of aborting with "apt install X". If apt cannot install one, abort with the apt error (today's outcome). `--check/--diff/--post-check` never install anything. - `network-manager` is the exception in ordering: it is installed **after** the files that keep NM off the AP radios and LAN ports (`van-ap-unmanaged.conf`) are in place, because the package starts NM on install and NM manages Wi-Fi and gsm devices by default. After installing it, `deploy.sh` checks `nmcli -t -f DEVICE,STATE device` and warns if `WIFI_5G_IFACE`, `WIFI_2G_IFACE`, `LAN_IFACE` or `ONBOARD_LAN_IFACE` is not `unmanaged`. - With `ENABLE_NM_WAN=1`: `network-manager` is required; NetworkManager and ModemManager are enabled. ### 3. Routine deploy behaviour (`ENABLE_NM_WAN=1`) - The cellular NM profile is created if absent, matching the Pi's working `Koodo`: type `gsm`, no interface binding, `apn=$CELLULAR_APN`, no credentials, autoconnect yes, `ipv4.method auto`, `ipv4.route-metric 300`, `ipv4.never-default yes`, `ipv6.method auto`. An existing profile is never modified. Creation needs no modem to be present. - `/etc/netplan/50-van-wan.yaml`, the Armbian netplan file and the netplan state are **not** touched. If `/etc/netplan/50-van-wan.yaml` is missing or differs from the rendered template, deploy warns and names `./deploy.sh --apply-network` (this replaces today's "run netplan by hand" text). The Pi 5's live uplinks are therefore never flapped by a routine deploy. ### 4. `./deploy.sh --apply-network` (root, explicit) 1. Refuse unless `ENABLE_NM_WAN=1` and `network-manager` is installed. 2. Back up `/etc/netplan` to `/var/backups/vanlink-netplan-/`. 3. Install the rendered `50-van-wan.yaml`. If `/etc/netplan/10-dhcp-all-interfaces.yaml` exists (Armbian), move it aside as `10-dhcp-all-interfaces.yaml.vanlink-disabled` so networkd stops DHCP-ing the WAN port behind NM's back. 4. Apply from a detached transient unit (`systemd-run`), so the sequence survives the SSH session dropping: `netplan generate && netplan apply`, then wait up to 60 s for: NM reports an active connection on `STARLINK_IFACE` (or the WAN is otherwise up), a default route exists, and `ping -c1 1.1.1.1` works. 5. On success: log, keep the backup. On failure: restore the backup (including the Armbian file), re-apply, and record a warning with the reason. The exit status is non-zero on failure. 6. The detached unit writes its outcome to `/var/log/vanlink-apply-network.log`. ### 5. Template changes - `ap/50-van-wan.yaml` is split: the ethernet (Starlink) part stays; the `wifis:` stanza (with the Wapana access point) moves to a fragment `ap/50-van-wan-wifi.yaml` that `--apply-network` appends only when `WIFI_WAN_IFACE` is non-empty. The rendered result for the Pi 5 must be byte-identical to today's `/etc/netplan/50-van-wan.yaml` there (checked with `--diff` before the Pi is ever touched). The Wapana PSK stays where it is (known, out of scope). - `failover/config.json`: cellular `connection` becomes `@CELLULAR_CONN_NAME@`. ### 6. Post-check additions (only when `ENABLE_NM_WAN=1`) NetworkManager active; ModemManager active; the cellular profile exists (if `CELLULAR_APN` set); AP/LAN devices are `unmanaged` in NM; `van-failover`'s `/run/van-failover/state.json` shows each WAN whose device exists as `present: true`. ## Testing - `tests/check-templates.sh`: new variables in the example (existing sync check); host files pass `--check`; the awk flag-guard check gains `ENABLE_NM_WAN` for `network-manager`, `nmcli`, `50-van-wan` and the netplan-moving code (only inside guards); rendered `failover/config.json` has `Koodo` for both hosts; the rendered netplan for `wan.conf` contains the wifis stanza and for `van.conf` does not; a `--wan-plan` sub-command prints (no changes, no root) the actions `--apply-network` would take, asserted for both hosts; the rollback decision logic is a small function tested through a seam like `--classify-unit`. - Not testable without root/live hosts: the apply and rollback themselves. Those are verified on the R5C (Rollout) with a console or alternate access path available. ## Rollout 1. Build, review, push. Sync to the R5C (excluding `hosts/van.conf`). Set `ENABLE_NM_WAN=1`, `CELLULAR_CONN_NAME`, `CELLULAR_APN` in its `hosts/van.conf` (currently an uncommitted local edit). 2. R5C: routine `sudo ./deploy.sh` (installs `network-manager`, creates the cellular profile, warns about the pending WAN netplan), then `sudo ./deploy.sh --apply-network` once. Uplink today is the home LAN on `enP2p33s0`; the port changes from networkd to NM, a short blip. 3. Pi 5: routine deploy only; `--diff` must show no change to `50-van-wan.yaml`, and no apply is run. 4. When the modem is attached: `mmcli -L`, the profile connects, `cat /run/van-failover/state.json` shows cellular present, route metrics 200 (Starlink) / 300 (cellular). ## Out of scope Moving the Wapana PSK out of git; choosing the modem's USB port; a Wi-Fi WAN on the R5C (needs a spare radio); replacing NetworkManager on either host; cellular credentials/PIN (the Koodo profile has none). ## Risks - The cutover changes the manager of the uplink port on a live host: mitigated by the detached apply, the connectivity gate and automatic rollback; a console or ZeroTier path should exist anyway. - NM installation must not touch AP/LAN ports: mitigated by the install ordering and the unmanaged check. - Auto-installing packages changes deploy's contract: only missing required packages are installed and a failure aborts with the apt error. ## Amendments made during implementation (review-driven; the code and tests are authoritative) - **§3/§4 cellular profile** is written as a NetworkManager keyfile from `ap/cellular.nmconnection` (uuid derived in pure bash from the connection name), only if no connection with that id exists (keyfile scan by `id=` plus `nmcli`); `ap/nm-no-auto-default.conf` (`no-auto-default=*`) is installed before the package so NM never auto-creates DHCP "Wired connection" profiles on ports not yet handed to it. - **§4 `--apply-network`** changes nothing under `/etc/netplan` itself. It validates (NM installed and running, `van-failover` unit present, no apply already running, last status not `running`/`rollback-failed` unless `--force`), no-ops when the netplan already matches and no Armbian file exists (unless `--force`), takes a per-run backup plus a sticky pristine backup `/var/backups/vanlink-netplan-orig`, renders to `/run`, and launches a detached transient unit. The helper installs the file, moves the Armbian file aside, applies, and gates: **stage 1** (≤60 s): apply succeeded, NM device state `100 (connected)` with a `netplan-*` connection, IPv4 address, gateway (`IP4.GATEWAY`, else the DHCP lease's `routers` option, because a never-default profile leaves `IP4.GATEWAY` empty) reachable on-link; **stage 2**: restart `van-failover`, then default route + `ping 1.1.1.1` (≥120 s). Failure → checked rollback (restore, re-apply, connectivity re-check, `ROLLBACK FAILED` if not restored; a last-resort reboot only when the rollback itself failed). Timeouts, a TERM/INT/HUP trap that restores exactly once, budgets derived in one block, log `/var/log/vanlink-apply-network.log`, status file `/var/lib/vanlink/apply-network.status` (`running|ok|rolled-back|rollback-failed …`). Not built: a boot-time recovery unit (power loss mid-apply boots the new config ungated and leaves status `running`), an arping fallback for gateways that do not answer ICMP. - **§6 post-check**: the failover-state rule is refined: `present` in `van-failover`'s state means "has an active NM connection", so an unplugged or out-of-range uplink is normal in a van. Post-check warns only when the WAN's device has link (`carrier` 1) but `present` is false; it also reports the last `--apply-network` status (non-`ok` warns) and checks NM/ModemManager, NM-unmanaged AP/LAN ports, the cellular profile and the netplan state. - Routine deploys also install missing REQUIRED packages via apt (spec §2) and never touch `/etc/netplan`. - **Uplink stays with networkd until the handover.** While the WAN netplan is *missing*, the routine deploy installs `/etc/NetworkManager/conf.d/zz-van-wan-pending.conf` (`unmanaged-devices+=interface-name:`, from `ap/nm-uplink-pending.conf`) so NM, freshly installed, does not adopt the uplink as "connected (externally)" and van-failover does not start steering it. It is removed when the netplan `matches`, and deliberately left alone when it `differs` or is `unreadable` (a host whose uplink NM already manages, such as the Pi 5 after any template edit, must not lose it). The helper removes it just before `netplan apply` and restores it on rollback. This relies on NM honouring `unmanaged-devices+=` after `nmcli general reload conf` (not live-tested; if ignored the behaviour degrades to "NM adopts the uplink early", not an outage). - **`--apply-network` preflight** (die unless `--force`): the uplink port exists on this host, and the resolved host file's name matches `hostname -s`; stage 2 additionally requires van-failover's own `proto static` default route on the port. A netplan file the current user cannot read is reported `unreadable` (not `differs`) by the read-only commands. - **The rendered `ap/50-van-wan.yaml` must stay byte-identical to what live hosts have installed** (measured on the Pi 5): comments in `ap/50-van-wan*.yaml` are part of the rendered bytes, so editing them makes every host read `WAN netplan differs`. Check 14 pins the Pi render against commit 3d117e7.