diff --git a/ap/10-van-ap.network b/ap/10-van-ap.network index 976b150..2fdb8d2 100644 --- a/ap/10-van-ap.network +++ b/ap/10-van-ap.network @@ -1,5 +1,5 @@ [Match] -Name=wlxc83a35a4ee55 +Name=@WIFI_5G_IFACE@ [Link] RequiredForOnline=no diff --git a/ap/11-van-ap-2g.network b/ap/11-van-ap-2g.network index d55f3ce..e1d1342 100644 --- a/ap/11-van-ap-2g.network +++ b/ap/11-van-ap-2g.network @@ -1,5 +1,5 @@ [Match] -Name=wlxd8ec5e2faa8c +Name=@WIFI_2G_IFACE@ [Link] RequiredForOnline=no diff --git a/ap/22-van-lan.network b/ap/22-van-lan.network index 029bf91..357e9b9 100644 --- a/ap/22-van-lan.network +++ b/ap/22-van-lan.network @@ -2,7 +2,7 @@ # gets DHCP/DNS from the same dnsmasq as Wi-Fi clients. NM must leave this device # alone (see van-ap-unmanaged.conf) for networkd to own it here. [Match] -Name=enx00e04c331140 +Name=@LAN_USB_IFACE@ [Link] RequiredForOnline=no diff --git a/ap/50-van-wan.yaml b/ap/50-van-wan.yaml index b660805..d6b5a55 100644 --- a/ap/50-van-wan.yaml +++ b/ap/50-van-wan.yaml @@ -10,10 +10,11 @@ network: version: 2 ethernets: # Starlink dish uplink (RTL8153 USB NIC, MAC-named — travels with the - # adapter). The /32 link route keeps the dish's management address reachable - # no matter which WAN holds the default route: the dish answers on - # 192.168.100.1 (gRPC :9200) even while the uplink sits behind CGNAT. - enxd8ec5eeb3512: + # adapter, see STARLINK_IFACE in deploy.conf). The /32 link route keeps the + # dish's management address reachable no matter which WAN holds the + # default route: the dish answers on 192.168.100.1 (gRPC :9200) even while + # the uplink sits behind CGNAT. + @STARLINK_IFACE@: renderer: NetworkManager optional: true dhcp4: true diff --git a/ap/hostapd-2g.conf b/ap/hostapd-2g.conf index b96800f..299e210 100644 --- a/ap/hostapd-2g.conf +++ b/ap/hostapd-2g.conf @@ -1,4 +1,4 @@ -interface=wlxd8ec5e2faa8c +interface=@WIFI_2G_IFACE@ # Second, independent hostapd instance (hostapd-2g.service) — 2.4GHz band of the same # VanLink network, bridged into the same br0 segment as the 5GHz AP and the wired port. # Separate process on purpose: a USB wedge on one radio must never take down the other. @@ -17,7 +17,7 @@ wmm_enabled=1 # HT20 only — 40MHz in 2.4GHz overlaps most of the band and coexistence would force # it back to 20MHz near any neighbor anyway. ieee80211n=1 -ht_capab=[LDPC][SHORT-GI-20] +ht_capab=[SHORT-GI-20] auth_algs=1 wpa=2 wpa_passphrase=1foot11foot11 diff --git a/ap/hostapd-2g.service b/ap/hostapd-2g.service index f6c3c43..858e7e1 100644 --- a/ap/hostapd-2g.service +++ b/ap/hostapd-2g.service @@ -1,5 +1,5 @@ [Unit] -Description=VanLink 2.4GHz AP (hostapd on wlxd8ec5e2faa8c, RTL8822BU) +Description=VanLink 2.4GHz AP (hostapd on @WIFI_2G_IFACE@ — see deploy.conf for current hardware) # Same USB re-enumeration hazards as the 5GHz AP (see hostapd-restart.conf for the # full story): never stop retrying, and back off a few seconds so the USB device can # re-enumerate before the next attempt. diff --git a/ap/hostapd.conf b/ap/hostapd.conf index a2f9837..f7633c7 100644 --- a/ap/hostapd.conf +++ b/ap/hostapd.conf @@ -1,4 +1,4 @@ -interface=wlxc83a35a4ee55 +interface=@WIFI_5G_IFACE@ # Put the AP into br0 so Wi-Fi and the wired LAN port share one 10.42.0.0/24 segment. # hostapd adds the wlan to the bridge after setting AP mode; the bridge itself + its # wired member + the gateway IP are defined under /etc/systemd/network (2x-van-br0/lan). diff --git a/ap/rtw88.conf b/ap/rtw88.conf deleted file mode 100644 index b552803..0000000 --- a/ap/rtw88.conf +++ /dev/null @@ -1,10 +0,0 @@ -# 2.4GHz AP dongle (Linksys WUSB6300 v2, RTL8822BU, rtw88_8822bu). -# Deep power-save has no business on an always-on AP radio — same reasoning as the -# rtw89 disable_ps_mode gotcha on the 5GHz dongle (rtw89.conf). -options rtw88_core disable_lps_deep=Y -# Don't let the driver switch a USB2-enumerated dongle up to USB3: USB3 signalling -# radiates broadband noise right across the 2.4GHz band, and HT20 rates fit in USB2 -# easily. NOTE: this only blocks the driver's own USB2->3 switch — a dongle sitting in -# a USB3 port still enumerates at SuperSpeed (it currently does); move it to a USB2 -# port if 2.4GHz range/interference ever looks poor. -options rtw88_usb switch_usb_mode=N diff --git a/ap/van-ap-unmanaged.conf b/ap/van-ap-unmanaged.conf index f3c0546..16a2eae 100644 --- a/ap/van-ap-unmanaged.conf +++ b/ap/van-ap-unmanaged.conf @@ -2,4 +2,4 @@ # The AP wlans (5GHz + 2.4GHz) and the wired LAN ports (USB dongle + onboard # eth0) are all owned by systemd-networkd/hostapd (bridged into br0), so # NetworkManager must not touch them. -unmanaged-devices=interface-name:wlxc83a35a4ee55;interface-name:wlxd8ec5e2faa8c;interface-name:enx00e04c331140;interface-name:eth0 +unmanaged-devices=interface-name:@WIFI_5G_IFACE@;interface-name:@WIFI_2G_IFACE@;interface-name:@LAN_USB_IFACE@;interface-name:eth0 diff --git a/cockpit/vanrouter/index.html b/cockpit/vanrouter/index.html index 363256e..bbe47d0 100644 --- a/cockpit/vanrouter/index.html +++ b/cockpit/vanrouter/index.html @@ -15,6 +15,11 @@ loading… + +

Access Points

@@ -45,6 +50,14 @@
+
+
+

Wi-Fi Networks

+ +
+

Click Scan to search for networks.

+
+

WAN / Uplinks

diff --git a/cockpit/vanrouter/vanrouter.css b/cockpit/vanrouter/vanrouter.css index 0d30f91..f8178ae 100644 --- a/cockpit/vanrouter/vanrouter.css +++ b/cockpit/vanrouter/vanrouter.css @@ -23,3 +23,7 @@ th { color: #6a6e73; font-weight: 600; } .active-wan { font-weight: 700; color: #0066cc; } .btn { margin-left: 6px; padding: 3px 10px; cursor: pointer; } .btn[disabled] { cursor: default; opacity: 0.5; } +#deploywarn-card { border-color: #e08a00; } +.warn-list { margin: 0; padding-left: 20px; } +.warn-list li { background: #f5d9a8; color: #5f4414; border-radius: 4px; + padding: 4px 8px; margin: 4px 0; list-style: none; margin-left: -20px; } diff --git a/cockpit/vanrouter/vanrouter.js b/cockpit/vanrouter/vanrouter.js index 49dd9c3..3bf4f82 100644 --- a/cockpit/vanrouter/vanrouter.js +++ b/cockpit/vanrouter/vanrouter.js @@ -7,18 +7,20 @@ // { superuser: "require" } which triggers Cockpit's admin (polkit) escalation. // AP radios (MAC-derived iface names, stable) — each runs its own hostapd unit. +// Interface names/vendor ID below are templated from deploy.conf at deploy +// time (see deploy.sh's render()) — edit deploy.conf, not the values here. const APS = [ - { iface: "wlxc83a35a4ee55", unit: "hostapd", band: "5GHz" }, // RTL8852BU - { iface: "wlxd8ec5e2faa8c", unit: "hostapd-2g", band: "2.4GHz" }, // RTL8822BU + { iface: "@WIFI_5G_IFACE@", unit: "hostapd", band: "5GHz" }, + { iface: "@WIFI_2G_IFACE@", unit: "hostapd-2g", band: "2.4GHz" }, ]; const PREFER_FILE = "/run/van-failover/prefer"; // van-failover reads this to pick the preferred WAN // Wired LAN bridge ports: clients behind them are found via the bridge FDB. -const LAN_PORTS = { "eth0": "LAN (eth0)", "enx00e04c331140": "LAN (USB)" }; +const LAN_PORTS = { "eth0": "LAN (eth0)", "@LAN_USB_IFACE@": "LAN (USB)" }; // Starlink dish: gRPC status API on the fixed management IP (reached via the /32 // link route the netplan profile installs on the RTL8153 uplink). -const STARLINK = { iface: "enxd8ec5eeb3512", dish: "192.168.100.1:9200" }; +const STARLINK = { iface: "@STARLINK_IFACE@", dish: "192.168.100.1:9200" }; // Cellular modem (Quectel EC25-AF): used to find the USB device for a hard restart. -const MODEM_USB_VENDOR = "2c7c"; +const MODEM_USB_VENDOR = "@MODEM_USB_VENDOR@"; function run(args, opts) { return cockpit.spawn(args, Object.assign({ err: "message" }, opts || {})); @@ -47,6 +49,7 @@ const STATUS_SCRIPT = (() => { add(`info${i}`, `iw dev ${a.iface} info`); add(`stations${i}`, `iw dev ${a.iface} station dump`); }); + add("deploywarn", "cat /var/lib/vanlink/deploy-warnings.json"); add("neigh", "ip -j neigh show dev br0"); add("fdb", "bridge -j fdb show br br0"); add("leases", "cat /var/lib/misc/dnsmasq.leases"); @@ -83,6 +86,21 @@ function parseJSON(text, fallback) { try { return JSON.parse(text); } catch (e) { return fallback; } } +/* ---------- Deploy warnings (deploy.sh's collected warn() calls) ---------- */ + +function renderDeployWarnings(dw) { + const card = document.getElementById("deploywarn-card"); + const warnings = (dw && dw.warnings) || []; + if (!warnings.length) { card.style.display = "none"; return; } + card.style.display = ""; + let html = ``; + html += `

From last deploy (${esc(dw.deployed || "—")}) — ` + + `re-run sudo ./deploy.sh after fixing to clear.

`; + document.getElementById("deploywarn").innerHTML = html; +} + /* ---------- Access Point ---------- */ function parseAP(a, i, secs) { @@ -321,7 +339,7 @@ function renderFailover(fo) { ` · updated ${esc(fo.updated || "")}

`; html += `
`; fo.wans.forEach(w => { - const status = !w.present ? `absent` + const status = !w.present ? `absent` : w.up ? `up` : `down`; const prio = w.preferred ? `${esc(w.priority)} (preferred)` : esc(w.priority); @@ -333,6 +351,106 @@ function renderFailover(fo) { el.innerHTML = html; } +/* ---------- Wi-Fi network selector (wlan0 WAN) ---------- */ + +// The onboard radio used for the WiFi WAN — unlike the AP dongles it's never +// templated, this host only ever has the one. +const WIFI_IFACE = "wlan0"; + +// SSIDs the van's own AP radios are currently beaconing (kept live from refresh()'s +// `iw dev ... info` parse, not hardcoded) — wlan0 "connecting" to its own AP would be +// a nonsensical loop, so those rows get their Connect button disabled below. +let ownAPSSIDs = new Set(); + +// nmcli -t escapes ':' inside field values as '\:' — split on unescaped ':' only. +// SSIDs are environment-controlled strings (the van parks near arbitrary APs), so +// worth handling properly rather than a naive split(":"). +function splitNmcli(line) { + const parts = []; + let cur = ""; + for (let i = 0; i < line.length; i++) { + if (line[i] === "\\" && line[i + 1] === ":") { cur += ":"; i++; } + else if (line[i] === ":") { parts.push(cur); cur = ""; } + else cur += line[i]; + } + parts.push(cur); + return parts; +} + +function renderWifiList(rows) { + const el = document.getElementById("wifi"); + if (!rows.length) { + el.innerHTML = `

No networks found. Try Scan.

`; + return; + } + let html = `
WANPriorityDeviceStatusMetric
`; + rows.forEach(r => { + const ownAP = ownAPSSIDs.has(r.ssid); + const label = r.inUse ? "Connected" : ownAP ? "Own AP" : "Connect"; + html += `` + + `` + + ``; + }); + html += `
SSIDSignalSecurity
${r.inUse ? "★ " : ""}${esc(r.ssid)}${esc(r.signal)}%${esc(r.security || "open")}
`; + el.innerHTML = html; + el.querySelectorAll(".wifi-connect").forEach((btn, i) => { + const r = rows[i]; + btn.disabled = r.inUse || ownAPSSIDs.has(r.ssid); + btn.onclick = () => connectWifi(r); + }); +} + +async function scanWifi() { + const el = document.getElementById("wifi"); + el.innerHTML = `

Scanning…

`; + try { + // Actually triggering a rescan (not just listing NM's cache) needs the + // org.freedesktop.NetworkManager.wifi.scan polkit action, which a plain + // Cockpit session doesn't have — without escalation this silently returns + // only the cached/connected AP instead of erroring. + const out = await run(["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", + "device", "wifi", "list", "ifname", WIFI_IFACE, "--rescan", "yes"], + { superuser: "require" }); + const seen = new Set(); + const rows = out.trim().split("\n").filter(Boolean).map(splitNmcli).map(f => ({ + inUse: f[0].indexOf("*") !== -1, ssid: f[1], signal: f[2], security: f[3], + })).filter(r => { + if (!r.ssid || seen.has(r.ssid)) return false; + seen.add(r.ssid); + return true; + }).sort((a, b) => (b.inUse - a.inUse) || (parseInt(b.signal, 10) - parseInt(a.signal, 10))); + renderWifiList(rows); + } catch (e) { + el.innerHTML = `

Scan failed: ${esc(e.message)}

`; + } +} + +async function connectWifi(r) { + if (ownAPSSIDs.has(r.ssid)) return; // belt-and-suspenders; button is disabled too + // Try without a password first — reuses a saved profile's stored secret (or + // just works for an open network); only prompt if NM actually needs one. + try { + await run(["nmcli", "device", "wifi", "connect", r.ssid, "ifname", WIFI_IFACE], + { superuser: "require" }); + scanWifi(); + return; + } catch (e) { + if (!/secret|password|psk/i.test(e.message || "")) { + window.alert("Connect failed: " + e.message); + return; + } + } + const pwd = window.prompt(`Password for "${r.ssid}":`); + if (!pwd) return; + try { + await run(["nmcli", "device", "wifi", "connect", r.ssid, "password", pwd, "ifname", WIFI_IFACE], + { superuser: "require" }); + } catch (e) { window.alert("Connect failed: " + e.message); } + scanWifi(); +} + +document.getElementById("wifi-scan").onclick = scanWifi; + /* ---------- WAN / uplinks ---------- */ // mmcli -K key-values -> { netdev, signal, tech, operator } (null if no modem). @@ -402,16 +520,24 @@ function parseWAN(secs) { return devs; } +// NM device states that count as "ready to serve traffic" vs. transient vs. +// broken/unusable — drives the state-column pill color. +const WAN_STATE_PILL = { connected: "ok", connecting: "warn" }; + function renderWAN(devs) { const tb = document.getElementById("wan"); tb.innerHTML = ""; devs.forEach(d => { - const isUp = d.state === "connected"; + const isConnected = d.state === "connected"; + // No carrier: nmcli can't connect a device with nothing on the other + // end of the wire, so the button would just fail — disable it rather + // than let it produce a silent "Toggle failed" alert. + const noCarrier = d.state === "unavailable"; const tr = document.createElement("tr"); tr.innerHTML = `${d.active ? "★ " : ""}${esc(d.device)}` + `${esc(d.type)}` + - `${esc(d.state)}` + + `${esc(d.state)}` + `${esc(d.ip || "—")}` + `${d.signal || "—"}` + `${d.metric != null ? esc(d.metric) : "—"}` + @@ -428,8 +554,9 @@ function renderWAN(devs) { } const tog = document.createElement("button"); tog.className = "btn"; - tog.textContent = isUp ? "Down" : "Up"; - tog.onclick = () => toggleWAN(d, isUp); + tog.textContent = isConnected ? "Disconnect" : "Connect"; + tog.disabled = !isConnected && noCarrier; + tog.onclick = () => toggleWAN(d, isConnected); acts.appendChild(tog); if (d.type === "gsm") { @@ -456,9 +583,9 @@ async function preferWAN(chosen) { setTimeout(refresh, 4500); // daemon enforces on its next probe loop } -async function toggleWAN(d, isUp) { +async function toggleWAN(d, isConnected) { try { - const verb = isUp ? "disconnect" : "connect"; + const verb = isConnected ? "disconnect" : "connect"; await run(["nmcli", "device", verb, d.nmdev], { superuser: "require" }); } catch (e) { window.alert("Toggle failed: " + e.message); } refresh(); @@ -492,7 +619,9 @@ async function restartAP(unit) { async function refresh() { try { const secs = parseSections(await sh(STATUS_SCRIPT)); + renderDeployWarnings(parseJSON(secs.deploywarn, null)); const aps = APS.map((a, i) => parseAP(a, i, secs)); + ownAPSSIDs = new Set(aps.map(a => a.ssid).filter(Boolean)); renderAPs(aps); renderClients(collectClients(aps, secs, parseClientDir(secs))); renderThermal(parseJSON(secs.thermal, null)); diff --git a/deploy.conf b/deploy.conf new file mode 100644 index 0000000..f9d2968 --- /dev/null +++ b/deploy.conf @@ -0,0 +1,26 @@ +# deploy.conf — hardware-instance identifiers for this Pi's vanlink deployment. +# +# These are the values that change when a USB Wi-Fi dongle, LAN adapter, or +# modem gets physically swapped (MAC-derived interface names are stable per +# physical device, but change when the device changes). Edit here and run +# `sudo ./deploy.sh` — it substitutes @TOKEN@ placeholders in the repo's +# config templates with these values before installing them. +# +# Find a new device's interface name after plugging it in: `iw dev` (wifi) or +# `ip -br link` (wired). USB vendor ID: `lsusb`. +# Preview a rendered file without deploying: `sudo ./deploy.sh render ` + +# 5GHz AP radio (currently: Realtek RTL8852BU, driver rtw89_8852bu) +WIFI_5G_IFACE=wlxc83a35a4ee55 + +# 2.4GHz AP radio (currently: D-Link DWA-171, RTL8821CU, driver rtw88_8821cu) +WIFI_2G_IFACE=wlx3c3332002066 + +# Wired LAN port, USB-attached gigabit adapter (RTL8153-family) +LAN_USB_IFACE=enx00e04c331140 + +# Starlink dish uplink, USB-attached gigabit adapter (RTL8153-family) +STARLINK_IFACE=enxd8ec5eeb3512 + +# Cellular modem USB vendor ID (Quectel EC25-AF) +MODEM_USB_VENDOR=2c7c diff --git a/deploy.sh b/deploy.sh index 2a54061..4e6f4a5 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,49 +1,98 @@ #!/usr/bin/env bash # Deploy vanlink configs/scripts (Pi 4 "wan" port) to their system locations. # Usage: cd ~/vanlink && sudo ./deploy.sh +# sudo ./deploy.sh render # preview a templated file on stdout # Idempotent. Netplan (wlan0 + Starlink NIC = NM-managed WANs; eth0 is a LAN # port on br0) is NOT deployed here — reference copy in ap/50-van-wan.yaml, # applied once manually (apply flaps uplinks). We do check it for drift below. set -euo pipefail cd "$(dirname "$(readlink -f "$0")")" + +# deploy.conf holds hardware-instance identifiers (interface names, USB vendor +# IDs) that change when a dongle/adapter gets physically swapped. Config files +# below carry @TOKEN@ placeholders substituted from these variables via +# render()/install_rendered() — edit deploy.conf, not the individual configs. +set -a +source ./deploy.conf +set +a + +render() { # render -> stdout, with @TOKEN@ placeholders substituted + sed -e "s|@WIFI_5G_IFACE@|$WIFI_5G_IFACE|g" \ + -e "s|@WIFI_2G_IFACE@|$WIFI_2G_IFACE|g" \ + -e "s|@LAN_USB_IFACE@|$LAN_USB_IFACE|g" \ + -e "s|@STARLINK_IFACE@|$STARLINK_IFACE|g" \ + -e "s|@MODEM_USB_VENDOR@|$MODEM_USB_VENDOR|g" \ + "$1" +} +install_rendered() { # install_rendered [mode] + local tmp + tmp=$(mktemp) + render "$1" > "$tmp" + install -D -m"${3:-0644}" "$tmp" "$2" + rm -f "$tmp" +} + +if [ "${1:-}" = "render" ]; then + [ -n "${2:-}" ] || { echo "Usage: $0 render "; exit 1; } + render "$2" + exit 0 +fi + [ "$(id -u)" = 0 ] || { echo "Run with sudo (writes to /etc, /usr)."; exit 1; } +# Collected below and persisted to WARNINGS_FILE so the Cockpit vanrouter page +# can surface deploy-time issues (missing deps, unedited example configs, drift) +# without someone having to remember to scroll back through deploy output. +WARNINGS=() +WARNINGS_FILE=/var/lib/vanlink/deploy-warnings.json +warn() { # warn — prints " -> " (as before) and records it + echo " -> $*" + WARNINGS+=("$*") +} +write_warnings() { + install -d -m0755 "$(dirname "$WARNINGS_FILE")" + { + printf '{\n "deployed": "%s",\n "warnings": [' "$(date -Iseconds)" + local first=1 w e + for w in "${WARNINGS[@]}"; do + [ "$first" = 1 ] || printf ',' + first=0 + e=${w//\\/\\\\}; e=${e//\"/\\\"} + printf '\n "%s"' "$e" + done + printf '\n ]\n}\n' + } > "$WARNINGS_FILE" +} + echo "== netplan drift check ==" # Netplan is never installed by this script (applying it flaps the uplinks — # see note below), so it's easy to edit ap/50-van-wan.yaml and forget the # manual `netplan apply` step. Warn loudly rather than silently drifting. if [ ! -f /etc/netplan/50-van-wan.yaml ]; then - echo " -> WARNING: /etc/netplan/50-van-wan.yaml is missing. Repo config" - echo " was never deployed to this system. Run manually:" - echo " sudo cp ap/50-van-wan.yaml /etc/netplan/50-van-wan.yaml" - echo " sudo netplan generate && sudo netplan apply" -elif ! diff -q ap/50-van-wan.yaml /etc/netplan/50-van-wan.yaml >/dev/null 2>&1; then - echo " -> WARNING: /etc/netplan/50-van-wan.yaml differs from ap/50-van-wan.yaml." - diff -u /etc/netplan/50-van-wan.yaml ap/50-van-wan.yaml || true - echo " Deploy the change manually:" - echo " sudo cp ap/50-van-wan.yaml /etc/netplan/50-van-wan.yaml" - echo " sudo netplan generate && sudo netplan apply" + warn "netplan: /etc/netplan/50-van-wan.yaml is missing — repo config was never deployed. Run: sudo ./deploy.sh render ap/50-van-wan.yaml | sudo tee /etc/netplan/50-van-wan.yaml && sudo netplan generate && sudo netplan apply" +elif ! diff -q <(render ap/50-van-wan.yaml) /etc/netplan/50-van-wan.yaml >/dev/null 2>&1; then + warn "netplan: /etc/netplan/50-van-wan.yaml differs from ap/50-van-wan.yaml (rendered). Deploy manually: sudo ./deploy.sh render ap/50-van-wan.yaml | sudo tee /etc/netplan/50-van-wan.yaml && sudo netplan generate && sudo netplan apply" + diff -u /etc/netplan/50-van-wan.yaml <(render ap/50-van-wan.yaml) || true fi echo "== access point ==" -install -D -m0644 ap/hostapd.conf /etc/hostapd/hostapd.conf +install_rendered ap/hostapd.conf /etc/hostapd/hostapd.conf install -D -m0644 ap/hostapd-restart.conf /etc/systemd/system/hostapd.service.d/restart.conf install -D -m0644 ap/default-hostapd /etc/default/hostapd -install -D -m0644 ap/hostapd-2g.conf /etc/hostapd/hostapd-2g.conf -install -D -m0644 ap/hostapd-2g.service /etc/systemd/system/hostapd-2g.service -install -D -m0644 ap/11-van-ap-2g.network /etc/systemd/network/11-van-ap-2g.network +install_rendered ap/hostapd-2g.conf /etc/hostapd/hostapd-2g.conf +install_rendered ap/hostapd-2g.service /etc/systemd/system/hostapd-2g.service +install_rendered ap/11-van-ap-2g.network /etc/systemd/network/11-van-ap-2g.network install -D -m0644 ap/van-ap-watchdog-2g.service /etc/systemd/system/van-ap-watchdog-2g.service -install -D -m0644 ap/rtw88.conf /etc/modprobe.d/rtw88.conf install -D -m0755 ap/van-ap-watchdog /usr/local/sbin/van-ap-watchdog install -D -m0644 ap/van-ap-watchdog.service /etc/systemd/system/van-ap-watchdog.service install -D -m0644 ap/van-ap-dnsmasq.conf /etc/van-ap/dnsmasq.conf install -D -m0644 ap/van-ap-dnsmasq.service /etc/systemd/system/van-ap-dnsmasq.service -install -D -m0644 ap/10-van-ap.network /etc/systemd/network/10-van-ap.network +install_rendered ap/10-van-ap.network /etc/systemd/network/10-van-ap.network install -D -m0644 ap/20-van-br0.netdev /etc/systemd/network/20-van-br0.netdev install -D -m0644 ap/21-van-br0.network /etc/systemd/network/21-van-br0.network -install -D -m0644 ap/22-van-lan.network /etc/systemd/network/22-van-lan.network +install_rendered ap/22-van-lan.network /etc/systemd/network/22-van-lan.network install -D -m0644 ap/23-van-lan-eth0.network /etc/systemd/network/23-van-lan-eth0.network -install -D -m0644 ap/van-ap-unmanaged.conf /etc/NetworkManager/conf.d/van-ap-unmanaged.conf +install_rendered ap/van-ap-unmanaged.conf /etc/NetworkManager/conf.d/van-ap-unmanaged.conf # Mask dracut's initramfs-generated catch-all (/run/systemd/network/ # zzzz-dracut-default.network, regenerated every boot): it matches every # unconfigured link, so networkd co-managed the NM-owned WANs — a second @@ -61,15 +110,34 @@ install -D -m0644 failover/config.json /etc/van-failover/config.json install -D -m0644 failover/van-failover.service /etc/systemd/system/van-failover.service install -D -m0755 failover/50-disable-eee /etc/NetworkManager/dispatcher.d/50-disable-eee install -D -m0644 failover/99-van-arp.conf /etc/sysctl.d/99-van-arp.conf +# Backstop for wlan0's post-boot NM no-secrets wedge (see the script's docstring). +install -D -m0755 failover/van-wlan-watchdog /usr/local/sbin/van-wlan-watchdog +install -D -m0644 failover/van-wlan-watchdog.service /etc/systemd/system/van-wlan-watchdog.service + +echo "== cellular modem (GSM/LTE) ==" +# NM's gsm.auto-config APN lookup needs this apt-only carrier database; without +# it even the right APN can't be auto-detected, and MVNOs (e.g. Koodo, which +# isn't listed under its own name — only under host network "Telus Mobility") +# often aren't in it anyway, so the modem's NM connection profile may still +# need an explicit gsm.apn set by hand regardless. +dpkg -s mobile-broadband-provider-info >/dev/null 2>&1 \ + || warn "mobile-broadband-provider-info missing (apt install mobile-broadband-provider-info) — GSM APN auto-config will fail" echo "== cockpit plugin ==" install -d /usr/share/cockpit/vanrouter -install -m0644 cockpit/vanrouter/* /usr/share/cockpit/vanrouter/ +for f in cockpit/vanrouter/*; do + b=$(basename "$f") + if [ "$b" = "vanrouter.js" ]; then + install_rendered "$f" "/usr/share/cockpit/vanrouter/$b" + else + install -m0644 "$f" "/usr/share/cockpit/vanrouter/$b" + fi +done # Bridge fd headroom (Python bridge frees spawn pipes only at GC; 1024 is too tight) install -D -m0644 cockpit/cockpit-session-nofile.conf /etc/systemd/system/cockpit-session@.service.d/nofile.conf # The Starlink card queries the dish's gRPC API; grpcurl isn't packaged in apt. command -v grpcurl >/dev/null 2>&1 \ - || echo " -> grpcurl missing (Starlink card will say so): install linux_arm64 binary from github.com/fullstorydev/grpcurl/releases" + || warn "grpcurl missing (Starlink card will say so): install linux_arm64 binary from github.com/fullstorydev/grpcurl/releases" echo "== gps (cellular modem GNSS -> gpsd) ==" if dpkg -s gpsd >/dev/null 2>&1; then @@ -77,7 +145,7 @@ if dpkg -s gpsd >/dev/null 2>&1; then install -D -m0644 gps/gpsd.default /etc/default/gpsd udevadm control --reload else - echo " -> gpsd not installed (apt install gpsd gpsd-clients) — skipping GPS setup" + warn "gpsd not installed (apt install gpsd gpsd-clients) — skipping GPS setup" fi # OwnTracks publisher: gpsd fix -> MQTT (broker + creds live only on the # system, 0600 — same pattern as pushover.json). @@ -85,10 +153,12 @@ install -D -m0755 gps/van-gps-owntracks /usr/local/sbin/van-gps-owntrack install -D -m0644 gps/van-gps-owntracks.service /etc/systemd/system/van-gps-owntracks.service if [ ! -f /etc/van-gps/config.json ]; then install -D -m0600 gps/config.json.example /etc/van-gps/config.json - echo " -> seeded /etc/van-gps/config.json (EDIT IT: add MQTT username + password)" + warn "seeded /etc/van-gps/config.json (EDIT IT: add MQTT username + password)" fi +python3 -c 'import gps' 2>/dev/null \ + || warn "python3-gps missing (apt install python3-gps) — van-gps-owntracks won't start" python3 -c 'import paho.mqtt' 2>/dev/null \ - || echo " -> python3-paho-mqtt missing (apt install python3-paho-mqtt) — van-gps-owntracks won't start" + || warn "python3-paho-mqtt missing (apt install python3-paho-mqtt) — van-gps-owntracks won't start" echo "== thermal monitor ==" install -D -m0755 power/van-thermal /usr/local/sbin/van-thermal @@ -98,7 +168,7 @@ install -D -m0644 power/van-thermal.service /etc/systemd/system/van-thermal.serv # under /etc/van-battery/ for parity with wayback's van-thermal default. if [ ! -f /etc/van-battery/pushover.json ]; then install -D -m0600 power/pushover.json.example /etc/van-battery/pushover.json - echo " -> seeded /etc/van-battery/pushover.json (EDIT IT: add Pushover token + user)" + warn "seeded /etc/van-battery/pushover.json (EDIT IT: add Pushover token + user)" fi echo "== home assistant ==" @@ -121,10 +191,10 @@ systemctl daemon-reexec # its wait-online would just stall network-online.target. NM-wait-online covers WANs. systemctl mask systemd-networkd-wait-online.service >/dev/null 2>&1 || true systemctl unmask hostapd >/dev/null 2>&1 || true -systemctl enable regdomain.service hostapd hostapd-2g van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-ap-watchdog van-ap-watchdog-2g van-gps-owntracks >/dev/null 2>&1 || true +systemctl enable regdomain.service hostapd hostapd-2g van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-ap-watchdog van-ap-watchdog-2g van-wlan-watchdog van-gps-owntracks >/dev/null 2>&1 || true # bluetooth: host BlueZ serves the onboard hci0 to the HA container over D-Bus systemctl enable --now bluetooth >/dev/null 2>&1 || true -systemctl start homeassistant || echo " -> homeassistant failed to start (podman/quadlet — check journalctl -u homeassistant)" +systemctl start homeassistant || warn "homeassistant failed to start (podman/quadlet — check journalctl -u homeassistant)" systemctl restart van-thermal systemctl restart van-gps-owntracks # Pick up unmanaged-devices changes so NM releases/keeps the right interfaces. @@ -132,7 +202,7 @@ nmcli general reload 2>/dev/null || systemctl reload NetworkManager 2>/dev/null # restart in dependency order: bridge + members first, then hostapd enslaves the # radios, then dnsmasq binds br0, then NAT/failover systemctl restart systemd-networkd -systemctl restart van-ap-dnsmasq nftables van-failover +systemctl restart van-ap-dnsmasq nftables van-failover van-wlan-watchdog # The AP radios live on the USB hub and may be absent; the start then fails but # Restart=always keeps retrying and claims them the moment they enumerate. systemctl restart hostapd hostapd-2g \ @@ -141,11 +211,13 @@ systemctl restart hostapd hostapd-2g \ systemctl restart van-ap-watchdog van-ap-watchdog-2g networkctl reload 2>/dev/null || true +write_warnings + echo echo "Deployed. NOTE: until the USB hub (AP radios + LAN/Starlink adapters) is" echo "plugged in, hostapd/hostapd-2g just retry every 5s — that is by design." echo "Verify (with hub present):" -echo " iw dev wlxc83a35a4ee55 info | grep -E 'ssid|channel|width'" -echo " iw dev wlxd8ec5e2faa8c info | grep -E 'ssid|channel|width'" +echo " iw dev $WIFI_5G_IFACE info | grep -E 'ssid|channel|width'" +echo " iw dev $WIFI_2G_IFACE info | grep -E 'ssid|channel|width'" echo " cat /run/van-failover/state.json" echo " cat /run/van-thermal/state.json" diff --git a/failover/van-wlan-watchdog b/failover/van-wlan-watchdog new file mode 100644 index 0000000..ac3a68a --- /dev/null +++ b/failover/van-wlan-watchdog @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""van-wlan-watchdog — recover wlan0 from NetworkManager's post-boot no-secrets wedge. + +Seen on 2026-07-30: wlan0 (onboard radio, used as the WiFi WAN) sometimes fails its +very first post-boot association attempt with a spurious supplicant "psk mismatch" +(a boot-time brcmfmac firmware/regulatory race, not a real credential problem — a +second attempt with the *same* stored secret succeeds immediately). NetworkManager +treats any handshake failure it reads as bad secrets as terminal: it does not retry +autoconnect after a no-secrets failure, so the device just sits in `disconnected` +until something explicitly re-triggers it. This is that trigger. + +Deliberately does NOT hardcode a connection/SSID name: which WiFi network wlan0 uses +changes with wherever the van is parked (home, a campsite, a neighbour's AP — see +config profiles managed from Cockpit's Networking tab or the Wi-Fi selector on the Van +Router page). `nmcli device connect wlan0` lets NM pick amongst whatever profiles are +saved and in range on its own, exactly like its normal autoconnect would. + +Bounded to a few minutes after boot, then exits — this is a backstop for the boot race +above, not a permanent watcher. It must NOT fight a deliberate later disconnect (e.g. +the Cockpit "Disconnect" button), which is why it doesn't loop forever. + +Usage: van-wlan-watchdog [device] +Defaults to wlan0 (the AP radios are separate USB dongles, watched by +van-ap-watchdog instead). +""" + +import subprocess +import sys +import time + +DEVICE = sys.argv[1] if len(sys.argv) > 1 else "wlan0" +INTERVAL = 15 # seconds between checks +BUDGET_S = 8 * INTERVAL # ~2 minutes of retries after boot, then give up quietly + + +def log(msg, level="info"): + # systemd journal severity prefixes (sd-daemon), same convention as van-ap-watchdog. + pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>") + print(pri + msg, flush=True) + + +def device_state(dev): + """NM's state word for dev (e.g. 'connected', 'disconnected', 'unavailable'), or + None if nmcli failed or the device isn't known to NM yet.""" + try: + out = subprocess.run( + ["nmcli", "-t", "-f", "GENERAL.STATE", "device", "show", dev], + capture_output=True, text=True, timeout=10, + ).stdout.strip() + except (OSError, subprocess.SubprocessError) as e: + log(f"nmcli device show {dev} failed: {e}", "warn") + return None + # terse output looks like "GENERAL.STATE:30 (disconnected)" + if "(" in out and out.endswith(")"): + return out.rsplit("(", 1)[1][:-1] + return None + + +def connect(dev): + log(f"{dev} is disconnected — nudging NM to reconnect (autoconnect doesn't retry " + f"after a no-secrets failure, only manual/dispatcher re-activation does)", "warn") + r = subprocess.run(["nmcli", "device", "connect", dev], + capture_output=True, text=True, timeout=45) + if r.returncode == 0: + log(f"{dev} reconnected") + else: + log(f"nmcli device connect {dev} returned {r.returncode}: {r.stderr.strip()}", "warn") + + +def main(): + log(f"van-wlan-watchdog up: watching {DEVICE} for up to {BUDGET_S}s after boot") + elapsed = 0 + while elapsed < BUDGET_S: + state = device_state(DEVICE) + if state == "disconnected": + connect(DEVICE) + elif state == "connected": + log(f"{DEVICE} connected — exiting") + return + # "unavailable" (no known/in-range AP), "unmanaged", or unreadable: nothing to + # nudge — retrying nmcli device connect would just fail again. + time.sleep(INTERVAL) + elapsed += INTERVAL + log(f"{DEVICE} still not connected after {BUDGET_S}s — giving up " + f"(leaving it for manual/Cockpit reconnect from here on)", "warn") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + sys.exit(0) diff --git a/failover/van-wlan-watchdog.service b/failover/van-wlan-watchdog.service new file mode 100644 index 0000000..a10a064 --- /dev/null +++ b/failover/van-wlan-watchdog.service @@ -0,0 +1,11 @@ +[Unit] +Description=wlan0 boot-reconnect backstop (NM no-secrets wedge after a boot-time supplicant glitch) +After=NetworkManager.service +Wants=NetworkManager.service + +[Service] +Type=simple +ExecStart=/usr/local/sbin/van-wlan-watchdog + +[Install] +WantedBy=multi-user.target