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 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-07-14 17:28:12 -04:00
co-authored by Claude Fable 5
parent 7c36757d35
commit fe9e6d3b30
+24 -9
View File
@@ -68,7 +68,19 @@ def resolve(wan, actives):
return dev, conn return dev, conn
conn = wan.get("connection") conn = wan.get("connection")
dev = next((a["device"] for a in actives if a["name"] == conn), None) 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): def probe_one(dev, url, timeout):
@@ -105,7 +117,7 @@ def routes_on(dev):
return out 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`. """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 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).""" a failover flap. This is a pure routing change (carrier-safe, verified)."""
@@ -120,7 +132,7 @@ def enforce_route(dev, metric):
return return
# Prefer the gw from an existing default route; fall back to NM's known gateway so we can # 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). # 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: if not gw:
return # no gateway known (carrier down); profile metric still set 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"]) 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"])]) sh(["ip", "route", "del", "default", "via", old["gw"], "dev", dev, "metric", str(old["metric"])])
def device_gateway(dev): def nm_gateway(dev, conn):
"""NM's gateway for a device — available even when its default route is missing.""" """NM's gateway — available even when the default route is missing. Try the device,
r = sh(["nmcli", "-g", "IP4.GATEWAY", "device", "show", dev]) then the connection (a wwan netdev is not an NM device, but its connection is active)."""
if r and r.returncode == 0: for kind, name in (("device", dev), ("connection", conn)):
return r.stdout.strip() or None 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 return None
@@ -224,7 +239,7 @@ def main():
desired = base if s["up"] else base + PENALTY desired = base if s["up"] else base + PENALTY
# Enforce the live route every loop (carrier-safe, corrects any NM drift); # Enforce the live route every loop (carrier-safe, corrects any NM drift);
# update the NM profile only on an actual state change. # update the NM profile only on an actual state change.
enforce_route(dev, desired) enforce_route(dev, desired, conn)
if s["applied"] != desired: if s["applied"] != desired:
print(f"{name}: {'UP' if s['up'] else 'DOWN'} -> metric {desired}", flush=True) print(f"{name}: {'UP' if s['up'] else 'DOWN'} -> metric {desired}", flush=True)
set_profile_metric(conn, desired) set_profile_metric(conn, desired)