From 2601472576785c3f93aba8080a6b467edb8eb50b Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Wed, 23 Sep 2026 19:06:43 -0400 Subject: [PATCH] post-check and docs for NetworkManager WANs Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KqhjP7nud5gdBg25zCiRk9 --- README.md | 20 ++++++++++++++++ deploy.sh | 52 ++++++++++++++++++++++++++++++++++++++++ tests/check-templates.sh | 33 +++++++++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/README.md b/README.md index 233f0f2..dd29fcc 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,26 @@ > Edit that file (and commit it), not the individual configs. Before a deploy: > `./deploy.sh --check` and `./deploy.sh --diff`. Static checks: `tests/check-templates.sh`. +## Multi-WAN (NetworkManager) + +Hosts with `ENABLE_NM_WAN=1` (`hosts/*.conf`) run their uplinks (Starlink, Wi-Fi, cellular) under +NetworkManager. `deploy.sh` installs the packages and the cellular NM connection +(`CELLULAR_CONN_NAME`/`CELLULAR_APN`), but a routine deploy never touches `/etc/netplan/*` or flaps +an uplink; it only warns when the WAN netplan is missing or differs. Apply it with +`sudo ./deploy.sh --apply-network` (add `--force` to override a blocking status): a detached +helper installs the netplan, gates on uplink health and rolls back automatically on failure. Its log +is `/var/log/vanlink-apply-network.log` and its status line is in +`/var/lib/vanlink/apply-network.status` (`ok|running|rolled-back|rollback-failed`). `./deploy.sh --wan-plan` +shows, read-only, what the provisioning would do. `--post-check` reports the last apply status. + +Live WAN state: `cat /run/van-failover/state.json` lists each WAN with `device`, `connection`, +`present` (device and connection both resolved), `up`, `healthy`, `active` (holds the best default +route) and `route_metric`. `present: false` for an existing device is a problem; `--post-check` warns. + +Known limitation: there is no boot-time recovery yet. If power is lost during an apply, the new +config boots ungated and the status file still says `running`; `--apply-network` then refuses to run +without `--force`. Inspect the log and the uplinks first. + Turns **wayback** (Asus ZenBook UX391U, Ubuntu 24.04, zabbly kernel) into a self-contained campervan hub/router/AP: diff --git a/deploy.sh b/deploy.sh index b2e3772..d4dedc7 100755 --- a/deploy.sh +++ b/deploy.sh @@ -19,6 +19,9 @@ # # test-only flags: --backup --netplan-dir --log --status --sysnet --rendered --deadlines "s1 s2 rc" --stamp # ./deploy.sh render # preview a templated file on stdout # ./deploy.sh --redact # filter stdin, masking secret values (used by --diff; for tests) +# VANLINK_SYSNET= # test override: sysfs net dir used by --post-check (default /sys/class/net) +# VANLINK_FAILOVER_STATE= # test override: van-failover state file read by --post-check (default /run/van-failover/state.json) +# VANLINK_APPLY_STATUS= # test override: apply-network status file read by --post-check (default /var/lib/vanlink/apply-network.status) # VANLINK_NM_DIR= # test override: where existing NM *.nmconnection files are looked up (default /etc/NetworkManager/system-connections) # Idempotent. Netplan (the NM-managed WANs; eth0 is a LAN port on br0) is never # changed by a routine deploy (applying it flaps the uplinks). ENABLE_NM_WAN=0: @@ -280,6 +283,55 @@ post_check() { # read-only live checks; every probe is guarded, warns instead o if [ -z "$out" ]; then warn "post-deploy: no default route" elif ping -c1 -W2 1.1.1.1 >/dev/null 2>&1; then echo " ok uplink: default route, 1.1.1.1 reachable" else warn "post-deploy: default route present but 1.1.1.1 does not answer ping"; fi + if [ "$ENABLE_NM_WAN" = 1 ]; then + local sysnet=${VANLINK_SYSNET:-/sys/class/net} nmst fst as_f as_l + # h. NetworkManager must leave the AP/LAN radios and ports alone + nmst=$(nmcli -t -f DEVICE,STATE device 2>/dev/null) || nmst= + for n in "$WIFI_5G_IFACE" "$WIFI_2G_IFACE" "$LAN_IFACE" "${ONBOARD_LAN_IFACE:-}"; do + [ -n "$n" ] && [ -e "$sysnet/$n" ] || continue + if [[ $'\n'$nmst == *$'\n'"$n:unmanaged"* ]]; then echo " ok NetworkManager leaves $n unmanaged" + else warn "post-deploy: NetworkManager manages $n (or its state is unknown) — expected unmanaged; check /etc/NetworkManager/conf.d/"; fi + done + # i. cellular connection + if [ -n "${CELLULAR_APN:-}" ]; then + if { nmcli -t -f NAME connection show 2>/dev/null || true; } | grep -qxF "$CELLULAR_CONN_NAME" || cellular_profile_exists; then + echo " ok NetworkManager connection $CELLULAR_CONN_NAME exists" + else warn "post-deploy: NetworkManager connection $CELLULAR_CONN_NAME not found"; fi + fi + # j. van-failover state: every WAN whose device exists must be present + fst=${VANLINK_FAILOVER_STATE:-/run/van-failover/state.json} + if out=$(python3 - "$fst" "$sysnet" 2>/dev/null <<'PY' +import json, os, sys +try: + with open(sys.argv[1]) as f: + wans = json.load(f)["wans"] + for w in wans: + dev = w.get("device") or "" + if dev and os.path.exists(os.path.join(sys.argv[2], dev)): + print(("ok" if w.get("present") is True else "notpresent"), w.get("name", "?"), dev) +except Exception: + sys.exit(3) +PY + ); then + while read -r as_f as_l n; do + [ -n "$as_f" ] || continue + if [ "$as_f" = ok ]; then echo " ok van-failover: WAN $as_l ($n) present" + else warn "post-deploy: van-failover reports WAN $as_l ($n) not present although the device exists"; fi + done <<<"$out" + else warn "post-deploy: van-failover state $fst missing or unreadable"; fi + # k. netplan drift + f=$(nm_wan_state_netplan) + [ "$f" = matches ] && echo " ok WAN netplan matches" || warn "post-deploy: WAN netplan $f — run ./deploy.sh --apply-network" + # l. last --apply-network + as_f=${VANLINK_APPLY_STATUS:-/var/lib/vanlink/apply-network.status} + if [ -r "$as_f" ]; then + as_l=$(head -n1 "$as_f" 2>/dev/null) || as_l= + case "${as_l%% *}" in + ok) echo " ok last --apply-network: $as_l" ;; + running|rolled-back|rollback-failed) warn "post-deploy: last --apply-network status: $as_l — see /var/log/vanlink-apply-network.log" ;; + esac + fi + fi } # --apply-network time budgets (seconds), in one place; all derived from the AN_* constants. diff --git a/tests/check-templates.sh b/tests/check-templates.sh index 8cb0f8a..3a0aeaf 100755 --- a/tests/check-templates.sh +++ b/tests/check-templates.sh @@ -433,5 +433,38 @@ done sed 's/^STARLINK_IFACE=.*/STARLINK_IFACE="a'"'"'b"/' hosts/van.conf > "$gs/bad.conf" if ./deploy.sh --check "$gs/bad.conf" >/dev/null 2>&1; then bad "STARLINK_IFACE with a quote must be rejected"; else ok "STARLINK_IFACE with a quote is rejected"; fi rm -rf "$gs" +# 19. post_check NetworkManager additions +tc=$(mktemp -d) +sed 's/^ENABLE_NM_WAN=.*/ENABLE_NM_WAN=0/' hosts/van.conf > "$tc/off.conf" +pc=$(POSTCHECK_WAIT=0 ./deploy.sh --post-check "$tc/off.conf" 2>&1) || true +if ! echo "$pc" | grep -q '^== post-deploy check ==$'; then bad "19: --post-check (ENABLE_NM_WAN=0) did not run" +elif echo "$pc" | grep -q 'NetworkManager'; then bad "19: --post-check mentions NetworkManager with ENABLE_NM_WAN=0" +else ok "19: --post-check omits NetworkManager when ENABLE_NM_WAN=0"; fi +pc=$(POSTCHECK_WAIT=0 ./deploy.sh --post-check hosts/van.conf 2>&1) || true +echo "$pc" | grep -q 'NetworkManager' && ok "19: --post-check mentions NetworkManager when ENABLE_NM_WAN=1" || bad "19: --post-check lacks NetworkManager with ENABLE_NM_WAN=1" +# 19b. apply status file (VANLINK_APPLY_STATUS) +pcs() { POSTCHECK_WAIT=0 VANLINK_APPLY_STATUS="$1" ./deploy.sh --post-check hosts/van.conf 2>&1 || true; } +echo "ok 2026-01-01T00:00:00+00:00" > "$tc/st" +pcs "$tc/st" | grep -qF ' ok last --apply-network: ok 2026-01-01T00:00:00+00:00' && ok "19b: status ok -> ok line" || bad "19b: status ok line missing" +for w in "running 2026-01-01T00:00:00+00:00 /bak" "rolled-back 2026-01-01T00:00:00+00:00 no-uplink" "rollback-failed 2026-01-01T00:00:00+00:00 boom"; do + echo "$w" > "$tc/st" + pcs "$tc/st" | grep -qF -- "-> post-deploy: last --apply-network status: $w — see /var/log/vanlink-apply-network.log" && ok "19b: status ${w%% *} -> warn" || bad "19b: status ${w%% *} warn missing" +done +pcs "$tc/none" | grep -q 'last --apply-network' && bad "19b: absent status file must print nothing" || ok "19b: absent status file prints nothing" +# 19c. van-failover state.json (VANLINK_FAILOVER_STATE, VANLINK_SYSNET) +mkdir -p "$tc/sys/dev1" "$tc/sys/dev2" +fo() { POSTCHECK_WAIT=0 VANLINK_SYSNET="$tc/sys" VANLINK_FAILOVER_STATE="$1" VANLINK_APPLY_STATUS="$tc/none" ./deploy.sh --post-check hosts/van.conf 2>&1 || true; } +cat > "$tc/f.json" <<'J' +{"wans":[{"name":"starlink","device":"dev1","present":false},{"name":"cell","device":"dev2","present":true}, + {"name":"wifi","device":"","present":false},{"name":"gone","device":"nodev","present":false}]} +J +o=$(fo "$tc/f.json") +echo "$o" | grep -q -- '-> post-deploy: .*WAN starlink' && ok "19c: existing device present:false -> warn" || bad "19c: starlink warn missing" +echo "$o" | grep -q ' ok .*WAN cell' && ok "19c: present:true -> ok" || bad "19c: cell ok missing" +echo "$o" | grep -qE 'WAN (wifi|gone)' && bad "19c: empty/nonexistent device must be ignored" || ok "19c: empty/nonexistent device ignored" +echo '{ not json' > "$tc/bad.json" +o=$(fo "$tc/bad.json"); [ "$(echo "$o" | grep -- '-> post-deploy: .*failover state' | sort -u | wc -l)" = 1 ] && ok "19c: invalid JSON -> one warn" || bad "19c: invalid JSON warn count" +o=$(fo "$tc/nofile"); [ "$(echo "$o" | grep -- '-> post-deploy: .*failover state' | sort -u | wc -l)" = 1 ] && ok "19c: missing file -> one warn" || bad "19c: missing file warn count" +rm -rf "$tc" exit "$fail"