From fe9e6d3b30718c40d75d81ddfd7d6e6070fc532c Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Tue, 14 Jul 2026 17:28:12 -0400 Subject: [PATCH] failover: resolve MBIM/QMI modems to their wwan netdev NM reports a gsm connection's device as the control port (cdc-wdm0), but IP + routes live on the wwan netdev (wwu1u2i4 on the Quectel EC25-AF), so probes and route enforcement silently saw nothing (cellular stuck "down", no metric). Map connection-keyed WANs through GENERAL.IP-IFACE, and let the gateway fallback query the connection since the netdev isn't an NM device. Co-Authored-By: Claude Fable 5 --- failover/van-failover | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/failover/van-failover b/failover/van-failover index 8945a84..ae76668 100755 --- a/failover/van-failover +++ b/failover/van-failover @@ -68,7 +68,19 @@ def resolve(wan, actives): return dev, conn conn = wan.get("connection") dev = next((a["device"] for a in actives if a["name"] == conn), None) - return dev, conn + return ip_iface(dev), conn + + +def ip_iface(dev): + """The routed netdev for an NM device. For MBIM/QMI modems NM's device is the + control port (cdc-wdm0) while IP/routes live on the wwan netdev — probing and + `ip route` must use the latter.""" + if not dev: + return dev + r = sh(["nmcli", "-g", "GENERAL.IP-IFACE", "device", "show", dev]) + if r and r.returncode == 0 and r.stdout.strip(): + return r.stdout.strip() + return dev def probe_one(dev, url, timeout): @@ -105,7 +117,7 @@ def routes_on(dev): return out -def enforce_route(dev, metric): +def enforce_route(dev, metric, conn=None): """Ensure exactly one default route on dev at the desired metric, via `ip route`. NEVER use `nmcli device reapply` — it resets r8152 USB-ethernet carriers and causes a failover flap. This is a pure routing change (carrier-safe, verified).""" @@ -120,7 +132,7 @@ def enforce_route(dev, metric): return # Prefer the gw from an existing default route; fall back to NM's known gateway so we can # also *restore* a route that went missing while the carrier is still up (not just rebase one). - gw = next((r["gw"] for r in rts if r["gw"]), None) or device_gateway(dev) + gw = next((r["gw"] for r in rts if r["gw"]), None) or nm_gateway(dev, conn) if not gw: return # no gateway known (carrier down); profile metric still set r = sh(["ip", "route", "add", "default", "via", gw, "dev", dev, "metric", str(metric), "proto", "static"]) @@ -134,11 +146,14 @@ def enforce_route(dev, metric): sh(["ip", "route", "del", "default", "via", old["gw"], "dev", dev, "metric", str(old["metric"])]) -def device_gateway(dev): - """NM's gateway for a device — available even when its default route is missing.""" - r = sh(["nmcli", "-g", "IP4.GATEWAY", "device", "show", dev]) - if r and r.returncode == 0: - return r.stdout.strip() or None +def nm_gateway(dev, conn): + """NM's gateway — available even when the default route is missing. Try the device, + then the connection (a wwan netdev is not an NM device, but its connection is active).""" + for kind, name in (("device", dev), ("connection", conn)): + if name: + r = sh(["nmcli", "-g", "IP4.GATEWAY", kind, "show", name]) + if r and r.returncode == 0 and r.stdout.strip(): + return r.stdout.strip() return None @@ -224,7 +239,7 @@ def main(): desired = base if s["up"] else base + PENALTY # Enforce the live route every loop (carrier-safe, corrects any NM drift); # update the NM profile only on an actual state change. - enforce_route(dev, desired) + enforce_route(dev, desired, conn) if s["applied"] != desired: print(f"{name}: {'UP' if s['up'] else 'DOWN'} -> metric {desired}", flush=True) set_profile_metric(conn, desired)