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
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)