failover: fix enforce_route() to handle gateway-less WANs (cellular)

The EC25/Koodo GSM connection is QMI raw-ip with an on-link /29 and no
gateway at all (nh 0.0.0.0), not a normal DHCP WAN with a temporarily
unknown gateway. enforce_route() required a gw and silently gave up
when none was found, so cellular could be reported up/healthy/preferred
in state.json while never actually getting a default route (Prefer
button had no effect since it only touches metric, not this bailout).
Now falls back to installing a gateway-less `ip route ... dev DEV`
default when the device genuinely has an IPv4 address (carrier truly
up), mirroring what NM itself would install without never-default.
Also fixes stale-route pruning to handle gateway-less old routes,
which the previous gw-only check skipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-08-06 15:10:21 -04:00
co-authored by Claude Sonnet 5
parent 6d5184e2e7
commit b0283f6083
+32 -7
View File
@@ -117,6 +117,28 @@ def routes_on(dev):
return out return out
def _del_default(dev, r):
args = ["ip", "route", "del", "default", "dev", dev, "metric", str(r["metric"])]
if r["gw"]:
args[4:4] = ["via", r["gw"]]
sh(args)
def has_ipv4(dev):
"""True if dev currently carries an IPv4 address (carrier genuinely up), used as the
signal to install a gateway-less default route for point-to-point/on-link WANs (e.g.
the EC25 modem's QMI raw-ip /29, which has no gateway at all — nh 0.0.0.0 — unlike
a normal DHCP WAN whose gateway is just temporarily unknown)."""
r = sh(["ip", "-4", "-j", "addr", "show", "dev", dev])
if not (r and r.stdout.strip()):
return False
try:
data = json.loads(r.stdout)
return bool(data and data[0].get("addr_info"))
except (json.JSONDecodeError, IndexError):
return False
def enforce_route(dev, metric, conn=None): 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
@@ -127,23 +149,26 @@ def enforce_route(dev, metric, conn=None):
if any(r["metric"] == metric for r in rts): if any(r["metric"] == metric for r in rts):
# desired metric already present; just prune any stale others # desired metric already present; just prune any stale others
for r in rts: for r in rts:
if r["metric"] != metric and r["gw"]: if r["metric"] != metric:
sh(["ip", "route", "del", "default", "via", r["gw"], "dev", dev, "metric", str(r["metric"])]) _del_default(dev, r)
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 nm_gateway(dev, conn) gw = next((r["gw"] for r in rts if r["gw"]), None) or nm_gateway(dev, conn)
if not gw: args = ["ip", "route", "add", "default", "dev", dev, "metric", str(metric), "proto", "static"]
return # no gateway known (carrier down); profile metric still set if gw:
r = sh(["ip", "route", "add", "default", "via", gw, "dev", dev, "metric", str(metric), "proto", "static"]) args[4:4] = ["via", gw]
elif not has_ipv4(dev):
return # no gateway known and carrier not actually up; nothing to route via
r = sh(args)
if not (r and r.returncode == 0): if not (r and r.returncode == 0):
# Add failed — most likely another dev transiently holds this exact metric during a # Add failed — most likely another dev transiently holds this exact metric during a
# preference swap. Leave the existing route intact and retry next loop; do NOT prune, # preference swap. Leave the existing route intact and retry next loop; do NOT prune,
# or we'd strand this dev with no default route at all. # or we'd strand this dev with no default route at all.
return return
for old in rts: for old in rts:
if old["metric"] != metric and old["gw"]: if old["metric"] != metric:
sh(["ip", "route", "del", "default", "via", old["gw"], "dev", dev, "metric", str(old["metric"])]) _del_default(dev, old)
def nm_gateway(dev, conn): def nm_gateway(dev, conn):