From b0283f608340560a3ddd85b3bd655bc12d534d37 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Thu, 6 Aug 2026 15:10:21 -0400 Subject: [PATCH] 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 --- failover/van-failover | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/failover/van-failover b/failover/van-failover index 4127ecc..caccc09 100755 --- a/failover/van-failover +++ b/failover/van-failover @@ -117,6 +117,28 @@ def routes_on(dev): 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): """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 @@ -127,23 +149,26 @@ def enforce_route(dev, metric, conn=None): if any(r["metric"] == metric for r in rts): # desired metric already present; just prune any stale others for r in rts: - if r["metric"] != metric and r["gw"]: - sh(["ip", "route", "del", "default", "via", r["gw"], "dev", dev, "metric", str(r["metric"])]) + if r["metric"] != metric: + _del_default(dev, r) 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 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"]) + args = ["ip", "route", "add", "default", "dev", dev, "metric", str(metric), "proto", "static"] + if gw: + 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): # 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, # or we'd strand this dev with no default route at all. return for old in rts: - if old["metric"] != metric and old["gw"]: - sh(["ip", "route", "del", "default", "via", old["gw"], "dev", dev, "metric", str(old["metric"])]) + if old["metric"] != metric: + _del_default(dev, old) def nm_gateway(dev, conn):