from __future__ import annotations import asyncio import os import re import socket import sys from contextlib import suppress from dataclasses import dataclass from ipaddress import IPv4Address, IPv4Network, IPv6Address, ip_network from pathlib import Path from typing import TYPE_CHECKING, Any import ifaddr from cached_ipaddress import cached_ip_addresses from ifaddr import Adapter from .util import asyncio_timeout if TYPE_CHECKING: from collections.abc import Iterable from pyroute2 import AsyncIPRoute # Some MAC addresses will drop the leading zero so # our mac validation must allow a single char VALID_MAC_ADDRESS = re.compile("^([0-9A-Fa-f]{1,2}[:-]){5}([0-9A-Fa-f]{1,2})$") ARP_CACHE_POPULATE_TIME = 10 ARP_TIMEOUT = 10 DEFAULT_NETWORK_PREFIX = 24 PRIVATE_AND_LOCAL_NETWORKS = ( ip_network("127.0.0.0/8"), ip_network("10.0.0.0/8"), ip_network("172.16.0.0/12"), ip_network("192.168.0.0/16"), ) DEFAULT_TARGET = "10.255.255.255" MDNS_TARGET_IP = "224.0.0.251" PUBLIC_TARGET_IP = "8.8.8.8" LOOPBACK_TARGET_IP = "127.0.0.1" IGNORE_MACS = {"00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"} # pyroute2 reports neighbour attributes under different names depending on # the platform stub: Linux netlink uses NDA_*, the macOS stub added in # pyroute2 0.9.2 uses NEIGH_*. Accept both so the netlink path works on # either OS without falling all the way through to the arp -an reader. NEIGHBOUR_IP_KEYS = frozenset({"NDA_DST", "NEIGH_IP"}) NEIGHBOUR_LLADDR_KEYS = frozenset({"NDA_LLADDR", "NEIGH_LLADDR"}) RESOLV_CONF_PATH = "/etc/resolv.conf" @dataclass(frozen=True, slots=True) class ResolvConfSignature: """Signature describing resolv.conf at a point in time.""" mtime_ns: int size: int def load_resolv_conf_with_signature() -> tuple[ ResolvConfSignature, list[IPv4Address | IPv6Address] ]: """Load resolv.conf and return (signature, nameservers) from the same fd.""" with Path(RESOLV_CONF_PATH).open() as file: stat = os.fstat(file.fileno()) lines = tuple(file) return ResolvConfSignature(stat.st_mtime_ns, stat.st_size), parse_resolv_conf(lines) def resolv_conf_signature() -> ResolvConfSignature | None: """Return a signature describing the current resolv.conf, or None if missing.""" try: stat = Path(RESOLV_CONF_PATH).stat() except OSError: return None return ResolvConfSignature(stat.st_mtime_ns, stat.st_size) def parse_resolv_conf(lines: Iterable[str]) -> list[IPv4Address | IPv6Address]: """Parse the resolv.conf.""" nameservers: list[IPv4Address | IPv6Address] = [] for line in lines: line = line.strip() if not len(line): continue if line[0] in ("#", ";"): continue parts = line.split(None, 1) if len(parts) != 2: continue key, value = parts if ( key == "nameserver" and (ip_addr := cached_ip_addresses(value)) and ip_addr not in nameservers ): nameservers.append(ip_addr) return nameservers def _parse_ipv4(value: str) -> IPv4Address | None: """Parse a string into an IPv4Address, dropping IPv6 / invalid input.""" ip_addr = cached_ip_addresses(value) return ip_addr if isinstance(ip_addr, IPv4Address) else None def get_local_ip(target: str = DEFAULT_TARGET) -> IPv4Address | None: """Find the local ip address.""" s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.setblocking(False) try: s.connect((target, 1)) source = s.getsockname()[0] except OSError: return None finally: s.close() return _parse_ipv4(source) def get_network(local_ip: IPv4Address, adapters: list[Adapter]) -> IPv4Network: """Search adapters for the network and broadcast ip.""" network_prefix = ( get_ip_prefix_from_adapters(str(local_ip), adapters) or DEFAULT_NETWORK_PREFIX ) network = ip_network(f"{local_ip}/{network_prefix}", False) if TYPE_CHECKING: assert isinstance(network, IPv4Network) return network def get_ip_prefix_from_adapters(local_ip: str, adapters: list[Adapter]) -> int | None: """Find the network prefix for an adapter.""" for adapter in adapters: for ip in adapter.ips: if local_ip == ip.ip: return ip.network_prefix return None def get_attrs_key(data: Any, key: Any) -> str | None: """Lookup an attrs key in pyroute2 data.""" for attr_key, attr_value in data["attrs"]: if attr_key == key: return attr_value return None async def async_get_router_ip(ipr: AsyncIPRoute) -> IPv4Address | None: """Obtain the router ip from the default route.""" routes = [route async for route in await ipr.get_default_routes()] if not routes: return None gateway = get_attrs_key(routes[0], "RTA_GATEWAY") return _parse_ipv4(gateway) if gateway else None async def _async_get_macos_default_gateway(family: str = "inet") -> str | None: """ Get the default gateway IP on macOS via `route -n get default`. family: "inet" for IPv4, "inet6" for IPv6. """ try: proc = await asyncio.create_subprocess_exec( "route", "-n", "get", f"-{family}", "default", stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, close_fds=False, ) except OSError: return None try: async with asyncio_timeout(2): out_data, _ = await proc.communicate() except (asyncio.TimeoutError, asyncio.CancelledError) as exc: with suppress(ProcessLookupError): proc.kill() with suppress(OSError): await proc.wait() if isinstance(exc, asyncio.CancelledError): raise return None if proc.returncode != 0: return None for raw_line in out_data.decode().splitlines(): line = raw_line.strip() if line.startswith("gateway:"): # IPv6 gateways may include a zone suffix (e.g. fe80::1%en0) return line.split(":", 1)[1].strip().split("%", 1)[0] or None return None def _fill_neighbor(neighbours: dict[str, str], ip: str, mac: str) -> None: """Add a neighbor if it is valid.""" if not (ip_addr := cached_ip_addresses(ip)): return if ( ip_addr.is_loopback or ip_addr.is_link_local or ip_addr.is_multicast or ip_addr.is_unspecified ): return if not VALID_MAC_ADDRESS.match(mac): return mac = ":".join([i.zfill(2) for i in mac.split(":")]) if mac in IGNORE_MACS: return neighbours[ip] = mac def async_populate_arp(ip_addresses: Iterable[str]) -> socket.socket: """Send an empty packet to a host to populate the arp cache.""" sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0) sock.setblocking(False) for ip_addr in ip_addresses: with suppress(Exception): sock.sendto(b"", (ip_addr, 80)) return sock class SystemNetworkData: """Gather system network data.""" network: IPv4Network adapters: list[Adapter] nameservers: list[IPv4Address | IPv6Address] router_ip: IPv4Address | None = None local_ip: IPv4Address | None = None resolv_conf_signature: ResolvConfSignature | None = None def __init__( self, ip_route: AsyncIPRoute | None, local_ip: str | None = None ) -> None: """Init system network data. Raises ValueError if ``local_ip`` is set but not a valid IPv4 string. """ self.ip_route = ip_route if local_ip is None: self.local_ip = None else: parsed = _parse_ipv4(local_ip) if parsed is None: msg = f"local_ip must be a valid IPv4 address string, got {local_ip!r}" raise ValueError(msg) self.local_ip = parsed async def async_setup(self) -> None: """Obtain the local network data.""" # Default to an empty list so attribute access stays safe when # resolv.conf is absent on Windows (the FileNotFoundError below is # swallowed there) and so later code can iterate unconditionally. self.nameservers = [] loop = asyncio.get_running_loop() try: signature, resolvers = await loop.run_in_executor( None, load_resolv_conf_with_signature ) except FileNotFoundError: if sys.platform != "win32": raise else: self.resolv_conf_signature = signature self.nameservers = [ ip_addr for ip_addr in resolvers if any(ip_addr in network for network in PRIVATE_AND_LOCAL_NETWORKS) ] self.adapters = list(ifaddr.get_adapters()) if not self.local_ip: self.local_ip = ( get_local_ip(DEFAULT_TARGET) or get_local_ip(MDNS_TARGET_IP) or get_local_ip(PUBLIC_TARGET_IP) or get_local_ip(LOOPBACK_TARGET_IP) ) assert self.local_ip is not None self.network = get_network(self.local_ip, self.adapters) if self.ip_route: with suppress(Exception): self.router_ip = await async_get_router_ip(self.ip_route) if not self.router_ip and sys.platform == "darwin": # pyroute2 is Linux-only; on macOS parse `route -n get default` gateway = await _async_get_macos_default_gateway() if gateway: self.router_ip = _parse_ipv4(gateway) if not self.router_ip: # First usable host in the network — `network_address + 1` for # any IPv4 prefix /30 or shorter, matching the conventional # router placement. The previous string-slice heuristic # (`network_address[:-1] + "1"`) only happens to produce the # right address when the network address ends in `0`, so it # silently mis-pointed the fallback for /25, /26, /27, /28, # /29, /30 networks whose base is `.64`/`.128`/`.192`/etc. self.router_ip = next(self.network.hosts(), None) async def async_get_neighbours(self, ips: Iterable[str]) -> dict[str, str]: """Get neighbours with best available method.""" neighbours = await self._async_get_neighbours() ips_missing_arp = [ip for ip in ips if ip not in neighbours] if not ips_missing_arp: return neighbours sock = async_populate_arp(ips_missing_arp) try: await asyncio.sleep(ARP_CACHE_POPULATE_TIME) finally: sock.close() neighbours.update(await self._async_get_neighbours()) return neighbours async def _async_get_neighbours(self) -> dict[str, str]: """Get neighbours from the arp table.""" if self.ip_route: return await self._async_get_neighbours_ip_route() return await self._async_get_neighbours_arp() async def _async_get_neighbours_arp(self) -> dict[str, str]: """Get neighbours with arp command.""" neighbours: dict[str, str] = {} try: arp = await asyncio.create_subprocess_exec( "arp", "-a", "-n", stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, close_fds=False, ) except OSError: return neighbours try: async with asyncio_timeout(ARP_TIMEOUT): out_data, _ = await arp.communicate() except (asyncio.TimeoutError, asyncio.CancelledError) as exc: with suppress(ProcessLookupError): arp.kill() with suppress(OSError): await arp.wait() if isinstance(exc, asyncio.CancelledError): raise return neighbours for line in out_data.decode().splitlines(): chomped = line.strip() data = chomped.split() if len(data) < 4: continue ip = data[1].strip("()") mac = data[3] _fill_neighbor(neighbours, ip, mac) return neighbours async def _async_get_neighbours_ip_route(self) -> dict[str, str]: """Get neighbours with pyroute2.""" neighbours: dict[str, str] = {} if TYPE_CHECKING: assert self.ip_route is not None async for neighbour in await self.ip_route.get_neighbours(): ip = None mac = None for key, value in neighbour["attrs"]: if key in NEIGHBOUR_IP_KEYS: ip = value elif key in NEIGHBOUR_LLADDR_KEYS: mac = value if ip and mac: _fill_neighbor(neighbours, ip, mac) return neighbours