From 751facd51100a55e94f00181acf3aa7195309568 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Sun, 12 Jul 2026 07:06:43 -0400 Subject: [PATCH] starlink: dish route + status card on the Van Router page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Starlink RTL8153 gets a declarative NM profile in 50-van-wan.yaml (replaces the auto 'Wired connection 1') with a 192.168.100.1/32 link route, so the dish's management address stays reachable from the router and — via the existing !br0 masquerade — from the van LAN, regardless of which WAN holds the default route. New Cockpit "Starlink" card queries the dish's gRPC API (:9200, get_status via grpcurl — not packaged in apt, deploy.sh warns when missing) inside the existing single batched spawn: online/obstructed/outage pill, alerts, uptime, sw version, PoP latency, down/up throughput, obstruction %. Sentinels distinguish adapter-absent / no-grpcurl / dish-unreachable. Gotcha captured while cutting over: a WAN profile without ipv4.route-metric makes NM re-assert its DHCP default (metric ~101) against van-failover's enforce_route pruning every cycle; the profile metric must match (failover's set_profile_metric does this on health transitions). Co-Authored-By: Claude Fable 5 --- ap/50-van-wan.yaml | 18 +++++++-- cockpit/vanrouter/index.html | 5 +++ cockpit/vanrouter/vanrouter.js | 69 ++++++++++++++++++++++++++++++++++ deploy.sh | 3 ++ 4 files changed, 92 insertions(+), 3 deletions(-) diff --git a/ap/50-van-wan.yaml b/ap/50-van-wan.yaml index 5bfc238..1fbecd0 100644 --- a/ap/50-van-wan.yaml +++ b/ap/50-van-wan.yaml @@ -1,7 +1,8 @@ # /etc/netplan/50-van-wan.yaml — replaces cloud-init's 50-cloud-init.yaml. -# Both onboard interfaces are WANs handed to NetworkManager so van-failover can -# steer them (mirrors wayback where NM owns all WANs): wlan0 = wifi uplink -# (Wapana at home / campsite wifi), eth0 = ethernet uplink. The AP radios and +# Both onboard interfaces plus the Starlink USB NIC are WANs handed to +# NetworkManager so van-failover can steer them (mirrors wayback where NM owns +# all WANs): wlan0 = wifi uplink (Wapana at home / campsite wifi), eth0 = +# ethernet uplink, enxd8ec5eeb3512 = Starlink. The AP radios and # the wired LAN port (USB dongles, wlx*/enx* MAC-named) are deliberately absent: # systemd-networkd/hostapd own them, and van-ap-unmanaged.conf hides them from NM. # Apply once by hand: netplan generate && netplan apply (flaps both uplinks). @@ -12,6 +13,17 @@ network: renderer: NetworkManager optional: true dhcp4: true + # 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: + renderer: NetworkManager + optional: true + dhcp4: true + routes: + - to: 192.168.100.1/32 + scope: link wifis: wlan0: renderer: NetworkManager diff --git a/cockpit/vanrouter/index.html b/cockpit/vanrouter/index.html index b1bb014..a87aae0 100644 --- a/cockpit/vanrouter/index.html +++ b/cockpit/vanrouter/index.html @@ -35,6 +35,11 @@
+
+

Starlink

+ +
+

WAN / Uplinks

diff --git a/cockpit/vanrouter/vanrouter.js b/cockpit/vanrouter/vanrouter.js index dfd589d..6cd5122 100644 --- a/cockpit/vanrouter/vanrouter.js +++ b/cockpit/vanrouter/vanrouter.js @@ -12,6 +12,9 @@ const APS = [ { iface: "wlxd8ec5e2faa8c", unit: "hostapd-2g", band: "2.4GHz" }, // RTL8822BU ]; const PREFER_FILE = "/run/van-failover/prefer"; // van-failover reads this to pick the preferred WAN +// 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" }; function run(args, opts) { return cockpit.spawn(args, Object.assign({ err: "message" }, opts || {})); @@ -43,6 +46,13 @@ const STATUS_SCRIPT = (() => { add("neigh", "ip -j neigh show dev br0"); add("leases", "cat /var/lib/misc/dnsmasq.leases"); add("thermal", "cat /run/van-thermal/state.json"); + // Sentinel words (ABSENT/NOGRPCURL/UNREACHABLE) let the renderer tell the + // three failure modes apart; anything starting with '{' is dish status JSON. + add("starlink", + `if ! ip link show ${STARLINK.iface} >/dev/null 2>&1; then echo ABSENT; ` + + `elif ! command -v grpcurl >/dev/null 2>&1; then echo NOGRPCURL; ` + + `else timeout 4 grpcurl -plaintext -max-time 3 -d '{"get_status":{}}' ` + + `${STARLINK.dish} SpaceX.API.Device.Device/Handle || echo UNREACHABLE; fi`); add("battery", "cat /run/van-battery/state.json"); add("failover", "cat /run/van-failover/state.json"); add("devices", "nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status"); @@ -169,6 +179,64 @@ function renderThermal(th) { el.innerHTML = html; } +/* ---------- Starlink (dish gRPC get_status via grpcurl) ---------- */ + +function fmtMbps(bps) { + return bps == null ? "—" : (bps / 1e6).toFixed(1) + " Mbps"; +} + +function fmtUptime(s) { + s = parseInt(s, 10); + if (isNaN(s)) return "—"; + const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60); + return (d ? `${d}d ` : "") + (d || h ? `${h}h ` : "") + `${m}m`; +} + +function renderStarlink(raw) { + const el = document.getElementById("starlink"); + const t = (raw || "").trim(); + if (!t || t === "ABSENT") { + el.innerHTML = `

Starlink adapter (${esc(STARLINK.iface)}) not plugged in.

`; + return; + } + if (t === "NOGRPCURL") { + el.innerHTML = `

grpcurl not installed — the dish status API is gRPC. ` + + `arm64 binary: github.com/fullstorydev/grpcurl/releases

`; + return; + } + if (t === "UNREACHABLE" || t[0] !== "{") { + el.innerHTML = `

dish unreachable ` + + `adapter present but 192.168.100.1 not answering ` + + `(dish booting / unpowered / route missing?)

`; + return; + } + + const st = (parseJSON(t, {}) || {}).dishGetStatus || {}; + const obs = st.obstructionStats || {}; + const alerts = Object.keys(st.alerts || {}).filter(k => st.alerts[k]); + + let state; + if (st.outage) + state = `${esc(st.outage.cause || "OUTAGE")}`; + else if (obs.currentlyObstructed) + state = `obstructed`; + else + state = `online`; + + let html = `

${state}` + + (alerts.length ? ` alerts: ${esc(alerts.join(", "))}` : "") + + `   uptime ${esc(fmtUptime((st.deviceState || {}).uptimeS))}` + + ` · sw ${esc((st.deviceInfo || {}).softwareVersion || "—")}

`; + html += `
`; + html += `` + + `` + + `` + + ``; + html += `
Latency (PoP)DownUpObstructed
${st.popPingLatencyMs == null || st.popPingLatencyMs < 0 ? "—" : esc(st.popPingLatencyMs.toFixed(0)) + " ms"}${esc(fmtMbps(st.downlinkThroughputBps))}${esc(fmtMbps(st.uplinkThroughputBps))}${obs.fractionObstructed == null ? "—" : esc((obs.fractionObstructed * 100).toFixed(1)) + " %"}
`; + html += `

Dish web UI: http://192.168.100.1 (from the van LAN)

`; + el.innerHTML = html; +} + /* ---------- Battery / power source (van-battery daemon state) ---------- */ function renderBattery(b) { @@ -320,6 +388,7 @@ async function refresh() { const secs = parseSections(await sh(STATUS_SCRIPT)); renderAPs(APS.map((a, i) => parseAP(a, i, secs)), parseClientDir(secs)); renderThermal(parseJSON(secs.thermal, null)); + renderStarlink(secs.starlink); renderBattery(parseJSON(secs.battery, null)); renderFailover(parseJSON(secs.failover, null)); renderWAN(parseWAN(secs)); diff --git a/deploy.sh b/deploy.sh index 0455f0a..2adae6e 100755 --- a/deploy.sh +++ b/deploy.sh @@ -42,6 +42,9 @@ install -d /usr/share/cockpit/vanrouter install -m0644 cockpit/vanrouter/* /usr/share/cockpit/vanrouter/ # 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" echo "== thermal monitor ==" install -D -m0755 power/van-thermal /usr/local/sbin/van-thermal