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.
This commit is contained in:
Andrew Pimenov 2026-08-09 13:00:24 +03:00
parent 4888862305
commit 51c01bd8c3
2 changed files with 74 additions and 8 deletions

View file

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

View file

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