Files
vanlink/docs/superpowers/specs/2026-09-23-nm-wan-provisioning-design.md

138 lines
12 KiB
Markdown

# 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-<timestamp>/`.
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 <ts> …`). 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:<STARLINK_IFACE>`, 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.