From 51c01bd8c3d96a5f3d0a4ce0a9661fb78b8276c2 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 9 Aug 2026 13:00:24 +0300 Subject: [PATCH] fix default gateway detection picking wrong route with multiple adapters _default_gateway() took whichever 0.0.0.0 line route print printed first, not the one Windows actually uses (lowest metric). With a VPN adapter installed (Radmin VPN, Tailscale, ...) its default-ish route can print before the real one. Verified live: was reporting Radmin VPN's 26.0.0.1 while Ethernet (the interface actually carrying traffic, confirmed via primary_interface_ip()) has gateway 192.168.3.1; now reports that correctly. Parsing extracted into pure _parse_windows_default_gateway(), tested against a captured multi-adapter route print sample. --- src/netsleuth/netinfo.py | 36 ++++++++++++++++++++++++------- tests/test_netinfo.py | 46 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/netsleuth/netinfo.py b/src/netsleuth/netinfo.py index 84e16f9..efc16c7 100644 --- a/src/netsleuth/netinfo.py +++ b/src/netsleuth/netinfo.py @@ -234,28 +234,48 @@ def collect_local_net() -> LocalNet: ) +def _parse_windows_default_gateway(route_print_output: str, family: int) -> str | None: + # `route print` lists routes in table order, not metric order, and every VPN + # adapter (Radmin, Tailscale, ...) contributes its own 0.0.0.0 row -- the row + # Windows actually uses for outbound traffic is whichever has the lowest + # metric, which is not necessarily the first one printed. + needle = "::/0" if family == socket.AF_INET6 else "0.0.0.0" + best_gateway: str | None = None + best_metric: float | None = None + for line in route_print_output.splitlines(): + if needle not in line: + continue + tokens = line.split() + candidates = [t for t in tokens if _looks_like_ip(t)] + if len(candidates) < 3: + continue + try: + metric = float(tokens[-1]) + except ValueError: + continue + if best_metric is None or metric < best_metric: + best_metric = metric + best_gateway = candidates[2] + return best_gateway + + def _default_gateway(family: int) -> str | None: if platform.system() == "Windows": args = ["route", "print", "-6" if family == socket.AF_INET6 else "-4"] - needle = "::/0" if family == socket.AF_INET6 else "0.0.0.0" else: args = ["ip", "-6", "route", "show", "default"] if family == socket.AF_INET6 else ["ip", "route", "show", "default"] - needle = "via" try: out = subprocess.run( args, capture_output=True, text=True, errors="replace", timeout=15 ).stdout except (OSError, subprocess.SubprocessError): return None + if platform.system() == "Windows": + return _parse_windows_default_gateway(out, family) for line in out.splitlines(): - if needle not in line: - continue tokens = line.split() - if needle == "via" and "via" in tokens: + if "via" in tokens: return tokens[tokens.index("via") + 1] - candidates = [t for t in tokens if _looks_like_ip(t)] - if len(candidates) >= 3: - return candidates[2] return None diff --git a/tests/test_netinfo.py b/tests/test_netinfo.py index e1a349f..db14619 100644 --- a/tests/test_netinfo.py +++ b/tests/test_netinfo.py @@ -111,3 +111,49 @@ def test_mtu_anomaly_ignores_normal_and_unknown_values(): def test_mtu_anomaly_flags_unusually_small_links_generically(): assert mtu_anomaly(1200) == "small" + + +import socket + +from netsleuth.netinfo import _parse_windows_default_gateway + +_ROUTE_PRINT_WITH_A_VPN_ADAPTER = """ +=========================================================================== +Interface List + 12...00 15 5d 01 ab cd ......Ethernet + 15...00 ff 21 4a bc de ......Radmin VPN +=========================================================================== + +IPv4 Route Table +=========================================================================== +Active Routes: +Network Destination Netmask Gateway Interface Metric + 0.0.0.0 0.0.0.0 26.0.0.1 26.0.0.5 35 + 0.0.0.0 0.0.0.0 192.168.3.1 192.168.3.72 25 + 26.0.0.0 255.0.0.0 On-link 26.0.0.5 291 + 192.168.3.0 255.255.255.0 On-link 192.168.3.72 281 +=========================================================================== +Persistent Routes: + None +""" + + +def test_windows_default_gateway_picks_the_lowest_metric_route_not_the_first_listed(): + # Radmin VPN's 0.0.0.0 route (metric 35) is listed before the real Ethernet + # default (metric 25); Windows itself prefers the lower metric for outbound + # traffic, so the parser must too, regardless of listing order. + assert ( + _parse_windows_default_gateway(_ROUTE_PRINT_WITH_A_VPN_ADAPTER, socket.AF_INET) == "192.168.3.1" + ) + + +def test_windows_default_gateway_returns_the_only_route_when_there_is_one(): + single = """ +Network Destination Netmask Gateway Interface Metric + 0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.34 25 +""" + assert _parse_windows_default_gateway(single, socket.AF_INET) == "192.168.1.1" + + +def test_windows_default_gateway_returns_none_when_no_default_route_exists(): + assert _parse_windows_default_gateway("Active Routes:\nNone\n", socket.AF_INET) is None