netinfo: local interface, gateway, mtu and per-adapter resolvers

This commit is contained in:
Andrew Pimenov 2026-08-08 21:56:45 +03:00
parent e61790222d
commit afc32e4599
2 changed files with 211 additions and 1 deletions

View file

@ -3,10 +3,21 @@ from __future__ import annotations
import ctypes import ctypes
import os import os
import platform import platform
import re
import shutil import shutil
import socket import socket
import subprocess
from netcheck.models import Capabilities import psutil
from netcheck.models import Capabilities, LocalNet
_TUNNEL_PATTERNS = (
re.compile(r"^(tun|tap|utun|ppp|wg|nordlynx|proton|ipsec|gpd)\d*", re.IGNORECASE),
re.compile(r"wireguard", re.IGNORECASE),
re.compile(r"tap-windows", re.IGNORECASE),
re.compile(r"openvpn", re.IGNORECASE),
)
_UNIX_REMEDY = ( _UNIX_REMEDY = (
"ICMP is unavailable without privileges, so latency was measured by TCP connect timing " "ICMP is unavailable without privileges, so latency was measured by TCP connect timing "
@ -94,3 +105,156 @@ def detect_capabilities() -> Capabilities:
if note: if note:
caps.notes.append(note) caps.notes.append(note)
return caps return caps
def is_tunnel_iface(name: str) -> bool:
return any(pattern.search(name) for pattern in _TUNNEL_PATTERNS)
def iface_for_ip(ip: str, addrs_by_iface: dict[str, list[tuple[int, str]]]) -> str | None:
for iface, addrs in addrs_by_iface.items():
for _family, address in addrs:
if address == ip:
return iface
return None
def mtu_anomaly(mtu: int | None) -> str | None:
if mtu is None or mtu >= 1500:
return None
if 1405 <= mtu <= 1440:
return "wireguard"
if 1350 <= mtu <= 1404:
return "ipsec"
return "small"
def primary_interface_ip(target: str = "1.1.1.1", family: int = socket.AF_INET) -> str | None:
# Connecting a UDP socket sends nothing; it only asks the kernel which local
# address the route to `target` would use. This is the reliable way to pick
# the *active* interface when several are up.
probe = "2606:4700:4700::1111" if family == socket.AF_INET6 else target
sock = socket.socket(family, socket.SOCK_DGRAM)
try:
sock.connect((probe, 53))
return sock.getsockname()[0]
except OSError:
return None
finally:
sock.close()
def _resolvers_per_adapter() -> dict[str, list[str]]:
if platform.system() == "Windows":
return _resolvers_windows()
return _resolvers_unix()
def _resolvers_windows() -> dict[str, list[str]]:
script = (
"Get-DnsClientServerAddress -AddressFamily IPv4,IPv6 | "
"ForEach-Object { $_.InterfaceAlias + '|' + ($_.ServerAddresses -join ',') }"
)
try:
out = subprocess.run(
["powershell", "-NoProfile", "-Command", script],
capture_output=True,
text=True,
errors="replace",
timeout=15,
).stdout
except (OSError, subprocess.SubprocessError):
return {}
adapters: dict[str, list[str]] = {}
for line in out.splitlines():
if "|" not in line:
continue
alias, _, servers = line.partition("|")
found = [s.strip() for s in servers.split(",") if s.strip()]
if found:
adapters.setdefault(alias.strip(), []).extend(found)
return adapters
def _resolvers_unix() -> dict[str, list[str]]:
adapters: dict[str, list[str]] = {}
try:
text = open("/etc/resolv.conf", encoding="utf-8", errors="replace").read()
except OSError:
text = ""
servers = [line.split()[1] for line in text.splitlines() if line.startswith("nameserver") and len(line.split()) > 1]
if servers:
adapters["system"] = servers
try:
scutil = subprocess.run(
["scutil", "--dns"], capture_output=True, text=True, errors="replace", timeout=15
).stdout
except (OSError, subprocess.SubprocessError):
scutil = ""
current = None
for line in scutil.splitlines():
stripped = line.strip()
if stripped.startswith("if_index"):
current = stripped.split("(")[-1].rstrip(")") or None
elif stripped.startswith("nameserver[") and current:
adapters.setdefault(current, []).append(stripped.split(":", 1)[1].strip())
return adapters
def collect_local_net() -> LocalNet:
v4 = primary_interface_ip()
v6 = primary_interface_ip(family=socket.AF_INET6)
addrs_by_iface = {
name: [(a.family, a.address.split("%")[0]) for a in addrs]
for name, addrs in psutil.net_if_addrs().items()
}
iface = iface_for_ip(v4, addrs_by_iface) if v4 else None
stats = psutil.net_if_stats()
return LocalNet(
iface_name=iface,
local_ipv4=v4,
local_ipv6=v6,
iface_mtu=stats[iface].mtu if iface and iface in stats else None,
default_gateway_v4=_default_gateway(socket.AF_INET),
default_gateway_v6=_default_gateway(socket.AF_INET6),
dns_servers_per_adapter=_resolvers_per_adapter(),
is_dual_stack=bool(v4 and v6),
)
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
for line in out.splitlines():
if needle not in line:
continue
tokens = line.split()
if needle == "via" and "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
def _looks_like_ip(token: str) -> bool:
try:
socket.inet_pton(socket.AF_INET, token)
return True
except OSError:
pass
try:
socket.inet_pton(socket.AF_INET6, token)
return True
except OSError:
return False

View file

@ -65,3 +65,49 @@ def test_degradation_note_on_windows_mentions_the_api_rather_than_sysctl():
note = degradation_note(caps(os_name="Windows")) note = degradation_note(caps(os_name="Windows"))
assert note is not None assert note is not None
assert "Iphlpapi" in note assert "Iphlpapi" in note
import pytest
from netcheck.netinfo import iface_for_ip, is_tunnel_iface, mtu_anomaly
def test_iface_for_ip_matches_the_owning_adapter():
addrs = {
"lo": [(2, "127.0.0.1")],
"eth0": [(2, "192.168.1.34"), (23, "fe80::1")],
"wg0": [(2, "10.7.0.2")],
}
assert iface_for_ip("192.168.1.34", addrs) == "eth0"
assert iface_for_ip("10.7.0.2", addrs) == "wg0"
assert iface_for_ip("203.0.113.1", addrs) is None
@pytest.mark.parametrize(
"name",
["tun0", "tap0", "wg0", "utun3", "ppp0", "WireGuard Tunnel", "TAP-Windows Adapter V9", "nordlynx"],
)
def test_tunnel_interfaces_are_recognised(name):
assert is_tunnel_iface(name) is True
@pytest.mark.parametrize("name", ["eth0", "en0", "Wi-Fi", "Ethernet 2", "lo", "Loopback Pseudo-Interface 1"])
def test_ordinary_interfaces_are_not_flagged_as_tunnels(name):
assert is_tunnel_iface(name) is False
def test_mtu_anomaly_recognises_wireguard_and_ipsec_sizes():
assert mtu_anomaly(1420) == "wireguard"
assert mtu_anomaly(1412) == "wireguard"
assert mtu_anomaly(1400) == "ipsec"
assert mtu_anomaly(1380) == "ipsec"
def test_mtu_anomaly_ignores_normal_and_unknown_values():
assert mtu_anomaly(1500) is None
assert mtu_anomaly(9000) is None
assert mtu_anomaly(None) is None
def test_mtu_anomaly_flags_unusually_small_links_generically():
assert mtu_anomaly(1200) == "small"