from __future__ import annotations import asyncio import logging import re from contextlib import suppress from functools import lru_cache, partial from itertools import islice from typing import TYPE_CHECKING, Any, cast import pycares from aiodns import DNSResolver from .network import SystemNetworkData, _parse_ipv4, resolv_conf_signature # Eagerly import pyroute2 at module-load time so its one-shot side effect — # pyroute2/config/__init__.py calling platform.uname(), which on POSIX # shells out via subprocess — happens before any asyncio loop is running. # Otherwise the first AsyncIPRoute() inside async_discover() blocks the # loop briefly, and the test suite's blockbuster fixture rejects it # outright. pyroute2 isn't importable on Windows (it pulls in fcntl # transitively), which is fine — we never reach AsyncIPRoute() there. with suppress(ImportError): import pyroute2 # noqa: F401 if TYPE_CHECKING: from collections.abc import Iterable from ipaddress import IPv4Address, IPv6Address from types import TracebackType from aiodns import AresQueryPTRResult from pyroute2 import AsyncIPRoute from .network import ResolvConfSignature HOSTNAME = "hostname" MAC_ADDRESS = "macaddress" IP_ADDRESS = "ip" MAX_ADDRESSES = 2048 QUERY_BUCKET_SIZE = 64 DNS_RESPONSE_TIMEOUT = 2 # 24 hours CACHE_CLEAR_INTERVAL = 60 * 60 * 24 _LOGGER = logging.getLogger(__name__) # RFC 1035 preferred-name LDH label: letters, digits, hyphens; up to 63 chars, # must start and end with an alphanumeric. PTR responses are returned from # whichever nameserver answered — including untrusted ones on the local # segment — so any non-conforming label is dropped rather than propagated to # downstream consumers (e.g. Home Assistant's dhcp integration). _VALID_HOSTNAME_LABEL = re.compile( r"^(?=.{1,63}$)[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$" ) @lru_cache(maxsize=MAX_ADDRESSES) def decode_idna(name: str) -> str: """Decode an idna name.""" try: return name.encode().decode("idna") except UnicodeError: return name def dns_message_short_hostname(dns_message: Any | None) -> str | None: """Get the short hostname from a dns message.""" if dns_message is None: return None name: str = dns_message.name short = name.partition(".")[0] # Validate the on-the-wire label against RFC 1035 LDH before any # IDNA decoding. Legitimate punycode labels (xn--*) are themselves # LDH-clean, so this still admits internationalised hostnames. if not _VALID_HOSTNAME_LABEL.match(short): return None if short.startswith("xn--"): short = decode_idna(short) return short async def async_query_for_ptrs( resolver: DNSResolver, ips_to_lookup: list[IPv4Address], ) -> list[AresQueryPTRResult | None]: """Fetch PTR records for a list of ips.""" results: list[AresQueryPTRResult | None] = [] # Track the in-flight futures of the *current* chunk so a cancellation # mid-`asyncio.wait` can be cleaned up in the finally block. asyncio.wait # does not cancel its wrapped futures when its task is cancelled, so we # must do it ourselves to keep pycares from leaking query slots and from # later firing "exception was never retrieved" warnings. in_flight: list[asyncio.Future[AresQueryPTRResult]] = [] try: for ip_chunk in chunked(ips_to_lookup, QUERY_BUCKET_SIZE): if TYPE_CHECKING: ip_chunk = cast("list[IPv4Address]", ip_chunk) futures = [resolver.query(ip.reverse_pointer, "PTR") for ip in ip_chunk] in_flight = futures # Belt-and-braces outer timeout: aiodns/pycares honour the per-query # `DNS_RESPONSE_TIMEOUT` configured at resolver construction, but a # silent UDP black-hole or a future regression in the resolver could # leave futures pending forever and wedge the discovery loop. Cancel # anything still pending after the budget and treat it as failed. _, pending = await asyncio.wait(futures, timeout=DNS_RESPONSE_TIMEOUT + 1) for future in pending: future.cancel() results.extend( None if (future in pending or future.exception()) else future.result() for future in futures ) in_flight = [] finally: for future in in_flight: if not future.done(): future.cancel() elif not future.cancelled(): # Mark exception as retrieved so it doesn't trigger # "exception was never retrieved" warnings on GC. future.exception() resolver.cancel() return results def take(take_num: int, iterable: Iterable[Any]) -> list[Any]: """ Return first n items of the iterable as a list. From itertools recipes """ return list(islice(iterable, take_num)) def chunked(iterable: Iterable[Any], chunked_num: int) -> Iterable[Any]: """ Break *iterable* into lists of length *n*. From more-itertools """ return iter(partial(take, chunked_num, iter(iterable)), []) class DiscoverHosts: """Discover hosts on the network by ARP and PTR lookup.""" def __init__(self, no_recurse: bool = True, local_ip: str | None = None) -> None: """ Init the discovery hosts. Args: no_recurse: If True (default), DNS queries will not request recursion. This prevents routers from forwarding PTR queries to external DNS servers, avoiding potential IP bans from public DNS services. local_ip: Optional IPv4 address (as a string) of the interface to use for discovery. When set, the discovery network and ARP probes are pinned to the subnet this address belongs to, instead of being inferred from the system default route. Pass the IP of the interface a caller (e.g. Home Assistant) has chosen. Raises: ValueError: If ``local_ip`` is provided but is not a valid IPv4 address string. Invalid input would otherwise silently fall back to default-route auto-detection, defeating the purpose of pinning. """ if local_ip is not None and _parse_ipv4(local_ip) is None: msg = f"local_ip must be a valid IPv4 address string, got {local_ip!r}" raise ValueError(msg) loop = asyncio.get_running_loop() self._loop = loop self._sys_network_data: SystemNetworkData | None = None self._resolv_conf_signature: ResolvConfSignature | None = None self._failed_nameservers: set[IPv4Address | IPv6Address] = set() self._last_cache_clear = loop.time() self._closed = False self._local_ip = local_ip # Create resolver with optional no_recurse flag if no_recurse: self._resolver = DNSResolver( timeout=DNS_RESPONSE_TIMEOUT, flags=pycares.ARES_FLAG_NORECURSE, ) else: self._resolver = DNSResolver(timeout=DNS_RESPONSE_TIMEOUT) async def __aenter__(self) -> DiscoverHosts: # noqa: PYI034 return self async def __aexit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, ) -> None: await self.close() async def close(self) -> None: """ Release the underlying DNS resolver and pyroute2 socket. After close() the instance must not be reused: calling async_discover() raises RuntimeError. A second close() is a no-op. """ if self._closed: return self._closed = True try: await self._resolver.close() finally: self._release_sys_network_data() def _release_sys_network_data(self) -> None: """Close the pyroute2 IPRoute socket and drop the cached network data.""" if self._sys_network_data is None: return ip_route = self._sys_network_data.ip_route if ip_route is not None: with suppress(OSError): ip_route.close() self._sys_network_data = None async def _setup_sys_network_data(self) -> SystemNetworkData: ip_route: AsyncIPRoute | None = None with suppress(Exception): from pyroute2 import AsyncIPRoute # noqa: PLC0415 ip_route = AsyncIPRoute() sys_network_data = SystemNetworkData(ip_route, local_ip=self._local_ip) try: await sys_network_data.async_setup() except BaseException: # async_setup() may raise on Linux when resolv.conf is missing or # when no usable local IP can be found. AsyncIPRoute opens a # netlink socket eagerly — close it here so the failure path # doesn't leak it until GC. if ip_route is not None: with suppress(OSError): ip_route.close() raise return sys_network_data def _cleanup_cache(self) -> None: """ Clear the cache of failed nameservers. Just because a nameserver failed once doesn't mean it will fail again as it may have been a transient issue. Our goal is to avoid spamming the same nameservers over and over if they are unresponsive, but not to permanently skip them since they may become responsive again. """ now = self._loop.time() if now - self._last_cache_clear > CACHE_CLEAR_INTERVAL: self._failed_nameservers.clear() self._last_cache_clear = now async def async_discover(self) -> list[dict[str, str]]: """Discover hosts on the network by ARP and PTR lookup.""" if self._closed: msg = "DiscoverHosts instance is closed" raise RuntimeError(msg) current_signature = await self._loop.run_in_executor( None, resolv_conf_signature ) if ( self._sys_network_data is not None and current_signature != self._resolv_conf_signature ): _LOGGER.debug( "resolv.conf changed; reloading network data and " "clearing failed nameservers cache", ) self._release_sys_network_data() self._failed_nameservers.clear() if not self._sys_network_data: self._sys_network_data = await self._setup_sys_network_data() # Cache the in-fd signature — the upfront stat can disagree if # a symlink target was swapped in between. Fall back to the # upfront stat when setup didn't populate one. self._resolv_conf_signature = ( self._sys_network_data.resolv_conf_signature or current_signature ) sys_network_data = self._sys_network_data network = sys_network_data.network if network.num_addresses > MAX_ADDRESSES: _LOGGER.debug( "The network %s exceeds the maximum number of addresses, %s; No scanning performed", network, MAX_ADDRESSES, ) return [] self._cleanup_cache() hostnames = await self.async_get_hostnames(sys_network_data) neighbours = await sys_network_data.async_get_neighbours(hostnames.keys()) return [ { HOSTNAME: hostname, MAC_ADDRESS: neighbours[ip], IP_ADDRESS: ip, } for ip, hostname in hostnames.items() if ip in neighbours ] async def _async_get_nameservers( self, net_data: SystemNetworkData, ) -> list[IPv4Address | IPv6Address]: """Get nameservers to query.""" if ( # If the Router IP is known (router_ip := net_data.router_ip) # And the router IP is not already a nameserver and router_ip not in net_data.nameservers # If there are no in-network nameservers and not any(ip in net_data.network for ip in net_data.nameservers) # And the router responds to ARP and str(router_ip) in await net_data.async_get_neighbours([str(router_ip)]) ): return [*net_data.nameservers, router_ip] return net_data.nameservers async def async_get_hostnames( self, sys_network_data: SystemNetworkData, ) -> dict[str, str]: """Lookup PTR records for all addresses in the network.""" all_nameservers = await self._async_get_nameservers(sys_network_data) _LOGGER.debug("Using nameservers %s", all_nameservers) _LOGGER.debug("Using network %s", sys_network_data.network) _LOGGER.debug("Previous failed nameservers %s", self._failed_nameservers) ips = list(sys_network_data.network.hosts()) hostnames: dict[str, str] = {} failed_nameservers_this_run: set[IPv4Address | IPv6Address] = set() for nameserver in all_nameservers: if nameserver in self._failed_nameservers: _LOGGER.debug("Skipping previously failed nameserver %s", nameserver) continue ips_to_lookup = [ip for ip in ips if str(ip) not in hostnames] self._resolver.nameservers = [str(nameserver)] results = await async_query_for_ptrs(self._resolver, ips_to_lookup) added_any = False for idx, ip in enumerate(ips_to_lookup): short_host = dns_message_short_hostname(results[idx]) if short_host is None: continue hostnames[str(ip)] = short_host added_any = True if not added_any: _LOGGER.debug("No usable PTRs from %s", nameserver) failed_nameservers_this_run.add(nameserver) continue if hostnames: # As soon as we have a responsive nameserver, there # is no need to query additional fallbacks break _LOGGER.debug("Failed nameservers this run %s", failed_nameservers_this_run) if hostnames: # If we have any working nameservers, keep track of which # ones failed this run so we don't try them again self._failed_nameservers.update(failed_nameservers_this_run) return hostnames