__version__ = "1.2.7" import asyncio import logging import os import socket from collections.abc import Callable, Iterable from dataclasses import dataclass from functools import partial from typing import TYPE_CHECKING, Any from scapy.config import conf from scapy.error import Scapy_Exception if TYPE_CHECKING: from scapy.packet import Packet _LOGGER = logging.getLogger(__name__) FILTER = "udp and (port 67 or 68)" DHCP_REQUEST = 3 AUTO_RECOVER_TIME = 30 @dataclass(slots=True) class DHCPRequest: """Class to hold a DHCP request.""" ip_address: str hostname: str mac_address: str def make_packet_handler( callback: Callable[[DHCPRequest], None], ) -> Callable[["Packet"], None]: """Create a packet handler.""" # Local import because importing from scapy has side effects such as opening # sockets from scapy.layers.dhcp import DHCP # pylint: disable=import-outside-toplevel from scapy.layers.inet import IP # pylint: disable=import-outside-toplevel from scapy.layers.l2 import Ether # pylint: disable=import-outside-toplevel def _handle_dhcp_packet(packet: "Packet") -> None: """Process a dhcp packet.""" if not (dhcp_packet := packet.getlayer(DHCP)): return options: Iterable[tuple[str, int | bytes | None] | str] = dhcp_packet.options options_dict: dict[str, int | bytes | None] = {} for option in options: if type(option) is tuple and len(option) > 1: options_dict[option[0]] = option[1] if option == "end": break if options_dict.get("message-type") != DHCP_REQUEST: # Not a DHCP request return ip_address: str | None = options_dict.get("requested_addr") # type: ignore[assignment] if not ip_address: # No requested_addr option (e.g. a renewal): fall back to the source # IP. Packets captured on link layers without IP framing have no IP # layer, so guard against getlayer() returning None. ip_layer = packet.getlayer(IP) ip_address = ip_layer.src if ip_layer is not None else None hostname = "" if (hostname_bytes := options_dict.get("hostname")) and isinstance( hostname_bytes, bytes ): try: # The standard uses idna encoding for hostnames, but some clients # do not follow the standard and use utf-8 instead. hostname = hostname_bytes.decode("idna") except UnicodeError: # On Python < 3.13 the idna codec can raise a bare UnicodeError # (e.g. "label empty or too long") rather than UnicodeDecodeError # for crafted hostnames. Catch the base class so an untrusted # DHCP packet cannot crash the packet handler. hostname = hostname_bytes.decode("utf-8", errors="replace") # Not every link layer carries an Ethernet header (e.g. Linux cooked # capture / SLL, PPP, tun), so getlayer(Ether) can be None; without a # MAC there is nothing useful to report. ether_layer = packet.getlayer(Ether) mac_address: str | None = ether_layer.src if ether_layer is not None else None if ip_address is not None and mac_address is not None: callback(DHCPRequest(ip_address, hostname, mac_address)) return _handle_dhcp_packet class AIODHCPWatcher: """Class to watch dhcp requests.""" _init_scapy_done = False def __init__(self, callback: Callable[[DHCPRequest], None]) -> None: """Initialize watcher.""" self._loop = asyncio.get_running_loop() self._socks: list[tuple[int, socket.socket, int]] = [] self._callback = callback self._shutdown: bool = False self._restart_timer: asyncio.TimerHandle | None = None self._restart_task: asyncio.Task[None] | None = None def restart_soon(self) -> None: """Restart the watcher soon.""" if not self._restart_timer: _LOGGER.debug("Restarting watcher in %s seconds", AUTO_RECOVER_TIME) self._restart_timer = self._loop.call_later( AUTO_RECOVER_TIME, self._execute_restart ) def _clear_restart_task(self, task: asyncio.Task[None]) -> None: """Clear the restart task.""" self._restart_task = None def _execute_restart(self) -> None: """Execute the restart.""" self._restart_timer = None if not self._shutdown: _LOGGER.debug("Restarting watcher") self._restart_task = self._loop.create_task(self.async_start()) self._restart_task.add_done_callback(self._clear_restart_task) def shutdown(self) -> None: """Shutdown the watcher.""" self._shutdown = True self.stop() def stop(self) -> None: """Stop watching for DHCP packets.""" if self._restart_timer: self._restart_timer.cancel() self._restart_timer = None if self._restart_task: self._restart_task.cancel() self._restart_task = None if self._socks is not None: for _, sock, fileno in self._socks: self._loop.remove_reader(fileno) sock.close() self._socks = [] def _start( self, if_indexes: Iterable[int] | None = None ) -> Callable[["Packet"], None] | None: """Start watching for dhcp packets.""" _init_scapy() # disable scapy promiscuous mode as we do not need it conf.sniff_promisc = 0 try: self._verify_working_pcap(FILTER) except (Scapy_Exception, ImportError) as ex: _LOGGER.error( "Cannot watch for dhcp packets without a functional packet filter: %s", ex, ) return None for if_index in set(if_indexes) if if_indexes else [None]: try: if sock := self._make_listen_socket(FILTER, if_index): if if_index is None: if_index = sock.iface.index self._socks.append((if_index, sock, sock.fileno())) except (Scapy_Exception, OSError) as ex: if os.geteuid() == 0: _LOGGER.error("Cannot watch for dhcp packets: %s", ex) else: _LOGGER.debug( "Cannot watch for dhcp packets without root or CAP_NET_RAW: %s", ex, ) return None return make_packet_handler(self._callback) async def async_start(self, if_indexes: Iterable[int] | None = None) -> None: """Start watching for dhcp packets.""" if self._shutdown: _LOGGER.debug("Not starting watcher because it is shutdown") return if not ( _handle_dhcp_packet := await self._loop.run_in_executor( None, self._start, if_indexes ) ): return if self._shutdown: # may change during the executor call _LOGGER.debug("Not starting watcher because it is shutdown after init") # type: ignore[unreachable] return for if_index, sock, fileno in list(self._socks): if fileno == -1: # On some platforms (notably Windows) scapy's listen socket does # not expose a selectable file descriptor, so fileno() returns -1 # and add_reader cannot watch it. This platform is unsupported. _LOGGER.error( "Cannot watch for dhcp packets on %s because the socket does " "not have a valid file descriptor; this platform is likely " "unsupported", if_index, ) sock.close() self._socks.remove((if_index, sock, fileno)) continue try: self._loop.add_reader( fileno, partial(self._on_data, _handle_dhcp_packet, sock) ) _LOGGER.debug("Started watching for dhcp packets on %s", if_index) except PermissionError as ex: _LOGGER.error( "Permission denied to watch for dhcp packets on %s: %s", if_index, ex, ) sock.close() self._socks.remove((if_index, sock, fileno)) except (NotImplementedError, ValueError) as ex: # Some event loops (e.g. the Windows Proactor loop) do not # implement add_reader, and some sockets report an invalid file # descriptor. Degrade gracefully instead of crashing. _LOGGER.error( "Cannot watch for dhcp packets on %s: %s", if_index, ex, ) sock.close() self._socks.remove((if_index, sock, fileno)) if len(self._socks) == 0: _LOGGER.debug("Not starting watcher because no readers added") def _on_data( self, handle_dhcp_packet: Callable[["Packet"], None], sock: Any ) -> None: """Handle data from the socket.""" try: data = sock.recv() except (BlockingIOError, InterruptedError): return except OSError as ex: _LOGGER.error("Error while processing dhcp packet: %s", ex) self.stop() self.restart_soon() return except BaseException as ex: # pylint: disable=broad-except _LOGGER.exception("Fatal error while processing dhcp packet: %s", ex) self.shutdown() return if data: handle_dhcp_packet(data) def _make_listen_socket(self, cap_filter: str, if_index: int | None = None) -> Any: """Get a nonblocking listen socket.""" from scapy.data import ETH_P_ALL # pylint: disable=import-outside-toplevel from scapy.interfaces import ( # pylint: disable=import-outside-toplevel dev_from_index, resolve_iface, ) iface = dev_from_index(if_index) if if_index and if_index > -1 else conf.iface sock = resolve_iface(iface).l2listen()( type=ETH_P_ALL, iface=iface, filter=cap_filter ) if hasattr(sock, "set_nonblock"): # Not all classes have set_nonblock so we have to call fcntl directly # in the event its not implemented sock.set_nonblock(True) elif hasattr(sock, "pcap_fd"): sock.pcap_fd.setnonblock(True) else: import fcntl # pylint: disable=import-outside-toplevel fcntl.fcntl(sock.fileno(), fcntl.F_SETFL, os.O_NONBLOCK) return sock def _verify_working_pcap(self, cap_filter: str) -> None: """ Verify we can create a packet filter. If we cannot create a filter we will be listening for all traffic which is too intensive. """ # Local import because importing from scapy has side effects such as opening # sockets from scapy.arch.common import ( # pylint: disable=import-outside-toplevel compile_filter, ) compile_filter(cap_filter) async def async_start( callback: Callable[[DHCPRequest], None], if_indexes: Iterable[int] | None = None ) -> Callable[[], None]: """Listen for DHCP requests.""" watcher = AIODHCPWatcher(callback) await watcher.async_start(if_indexes) return watcher.shutdown async def async_init() -> None: """Init scapy in the executor since it blocks for a bit.""" await asyncio.get_running_loop().run_in_executor(None, _init_scapy) def _init_scapy() -> None: """Init scapy in the executor since it blocks for a bit.""" # Local import because importing from scapy has side effects such as opening # sockets # We must import l2 before testing the filter or it will fail # # Importing scapy.sendrecv will cause a scapy resync which will # import scapy.arch.read_routes which will import scapy.sendrecv # # We avoid this circular import by importing arch above to ensure # the module is loaded and avoid the problem # if AIODHCPWatcher._init_scapy_done: return from scapy import arch # pylint: disable=import-outside-toplevel # noqa: F401 from scapy.arch.common import ( # pylint: disable=import-outside-toplevel # noqa: F401 compile_filter, ) from scapy.layers import ( l2, # pylint: disable=import-outside-toplevel # noqa: F401 ) from scapy.layers.dhcp import ( DHCP, # pylint: disable=import-outside-toplevel # noqa: F401 ) from scapy.layers.inet import ( IP, # pylint: disable=import-outside-toplevel # noqa: F401 ) from scapy.layers.l2 import ( Ether, # pylint: disable=import-outside-toplevel # noqa: F401 ) AIODHCPWatcher._init_scapy_done = True __all__ = ["DHCPRequest", "async_init", "async_start", "make_packet_handler"]