"""DHCP packet parsing using Scapy.""" from __future__ import annotations import logging from typing import Optional, Tuple from scapy.all import Ether, IP, UDP, BOOTP, DHCP log = logging.getLogger("dhcpd.parser") # DHCP message types (RFC 2132) DHCPDISCOVER = 1 DHCPOFFER = 2 DHCPREQUEST = 3 DHCPDECLINE = 4 DHCPACK = 5 DHCPNAK = 6 DHCPRELEASE = 7 DHCPINFORM = 8 MSG_NAMES = { DHCPDISCOVER: "DISCOVER", DHCPOFFER: "OFFER", DHCPREQUEST: "REQUEST", DHCPDECLINE: "DECLINE", DHCPACK: "ACK", DHCPNAK: "NAK", DHCPRELEASE: "RELEASE", DHCPINFORM: "INFORM", } class DHCPMessage: """Normalized view of a parsed DHCP packet.""" def __init__( self, msg_type: int, xid: int, client_mac: str, client_ip: str = "0.0.0.0", your_ip: str = "0.0.0.0", server_ip: str = "0.0.0.0", server_id: str = "0.0.0.0", giaddr: str = "0.0.0.0", ciaddr: str = "0.0.0.0", options: dict | None = None, hostname: str = "", src_mac: str = "", src_ip: str = "", dst_ip: str = "", ): self.msg_type = msg_type self.msg_name = MSG_NAMES.get(msg_type, f"TYPE-{msg_type}") self.xid = xid self.client_mac = client_mac.upper() self.client_ip = client_ip self.your_ip = your_ip self.server_ip = server_ip self.server_id = server_id self.giaddr = giaddr self.ciaddr = ciaddr self.options = options or {} self.hostname = hostname self.src_mac = src_mac self.src_ip = src_ip self.dst_ip = dst_ip @property def is_broadcast(self) -> bool: return self.dst_ip in ("255.255.255.255", "0.0.0.0") or self.dst_mac == "ff:ff:ff:ff:ff:ff" @property def dst_mac(self) -> str: return self.dst_ip # placeholder def __repr__(self) -> str: return (f"{self.msg_name} xid=0x{self.xid:08x} " f"mac={self.client_mac} ci={self.client_ip} " f"hostname={self.hostname!r}") def _get_option(options: list | None, name: str) -> any: if not options: return None for o in options: if o.name == name: return o return None def parse(pkt) -> Optional[DHCPMessage]: """Parse a scapy packet; return normalized DHCPMessage or None.""" if not pkt.haslayer(DHCP): return None dh = pkt[DHCP] bp = pkt[BOOTP] msg_type = None hostname = "" server_id = "0.0.0.0" options = {} # Scapy stores DHCP options as: # ("option-name", value) or # (code_int, value_bytes) or # "end" (terminator) for o in dh.options: if o == "end": continue if isinstance(o, tuple): name = o[0] val = o[1] else: continue # skip unknown format # Map option codes to names _CODE_NAMES = {53: "message-type", 54: "server_id", 1: "subnet_mask", 3: "router", 6: "domain_name_server", 15: "domain_name", 51: "lease_time", 55: "parameter_request_list", 50: "requested_addr", 12: "hostname"} if name == "message-type" or (isinstance(name, int) and name == 53): msg_type = val elif name == "hostname" or (isinstance(name, int) and name == 12): hostname = val.decode("utf-8", "ignore") if isinstance(val, bytes) else str(val) elif name == "server_id" or (isinstance(name, int) and name == 54): server_id = val elif name == "subnet_mask" or (isinstance(name, int) and name == 1): options["subnet_mask"] = val elif name in ("router",) or (isinstance(name, int) and name == 3): options["router"] = val elif name == "domain_name_server" or (isinstance(name, int) and name == 6): options["dns"] = val elif name == "domain_name" or (isinstance(name, int) and name == 15): options["domain"] = val elif name == "lease_time" or (isinstance(name, int) and name == 51): options["lease_time"] = val elif name == "parameter_request_list" or (isinstance(name, int) and name == 55): options["param_req"] = val if msg_type is None: return None try: client_mac_raw = bp.chaddr except Exception: client_mac_raw = bytes(16) client_mac = ":".join(f"{b:02x}" for b in client_mac_raw[:6]) try: ip_layer = pkt[IP] except Exception: ip_layer = None try: ether = pkt[Ether] src_mac = ether.src except Exception: src_mac = client_mac # BOOTP fields yiaddr = bp.yiaddr or "0.0.0.0" siaddr = bp.siaddr or "0.0.0.0" giaddr = bp.giaddr or "0.0.0.0" ciaddr = bp.ciaddr or "0.0.0.0" if ip_layer: src_ip = ip_layer.src dst_ip = ip_layer.dst else: src_ip = siaddr dst_ip = "255.255.255.255" return DHCPMessage( msg_type=msg_type, xid=bp.xid, client_mac=client_mac, client_ip=ciaddr, your_ip=yiaddr, server_ip=siaddr, server_id=server_id, giaddr=giaddr, ciaddr=ciaddr, options=options, hostname=hostname, src_mac=src_mac, src_ip=src_ip, dst_ip=dst_ip, ) def build_offer( req: DHCPMessage, offer_ip: str, lease_seconds: int, server_ip: str, router: str = "", dns: list = None, subnet_mask: str = "255.255.255.0", domain: str = "", ) -> object: """Build a scapy OFFER packet to reply to *req*.""" dns = dns or [] xid = req.xid client_mac = req.client_mac # Convert MAC hex to bytes mac_bytes = bytes.fromhex(client_mac.replace(":", ""))[:6] chaddr = mac_bytes + bytes(10) options = [ ("message-type", DHCPOFFER), ("server_id", server_ip), ("subnet_mask", subnet_mask), ("lease_time", lease_seconds), ("router", router), ] if dns: options.append(("domain_name_server", dns)) if domain: options.append(("domain_name", domain)) dst_ip = "255.255.255.255" # broadcast by default if req.giaddr and req.giaddr != "0.0.0.0": dst_ip = req.giaddr # relay agent pkt = ( Ether(dst="ff:ff:ff:ff:ff:ff") / IP(src=server_ip, dst=dst_ip) / UDP(sport=67, dport=68) / BOOTP( op=2, # reply xid=xid, chaddr=chaddr, yiaddr=offer_ip, siaddr=server_ip, giaddr=req.giaddr, ) / DHCP(options=options) ) return pkt def build_ack( req: DHCPMessage, offered_ip: str, lease_seconds: int, server_ip: str, router: str = "", dns: list = None, subnet_mask: str = "255.255.255.0", domain: str = "", ) -> object: """Build a scapy ACK packet. Same structure as OFFER but message-type=ACK.""" dns = dns or [] xid = req.xid client_mac = req.client_mac mac_bytes = bytes.fromhex(client_mac.replace(":", ""))[:6] chaddr = mac_bytes + bytes(10) options = [ ("message-type", DHCPACK), ("server_id", server_ip), ("subnet_mask", subnet_mask), ("lease_time", lease_seconds), ("router", router), ] if dns: options.append(("domain_name_server", dns)) if domain: options.append(("domain_name", domain)) dst_ip = "255.255.255.255" if req.giaddr and req.giaddr != "0.0.0.0": dst_ip = req.giaddr pkt = ( Ether(dst="ff:ff:ff:ff:ff:ff") / IP(src=server_ip, dst=dst_ip) / UDP(sport=67, dport=68) / BOOTP( op=2, xid=xid, chaddr=chaddr, yiaddr=offered_ip, siaddr=server_ip, giaddr=req.giaddr, ) / DHCP(options=options) ) return pkt def build_nak(req: DHCPMessage, server_ip: str) -> object: """Build a NAK to reject a request.""" xid = req.xid client_mac = req.client_mac mac_bytes = bytes.fromhex(client_mac.replace(":", ""))[:6] chaddr = mac_bytes + bytes(10) options = [ ("message-type", DHCPNAK), ("server_id", server_ip), ] dst_ip = "255.255.255.255" if req.giaddr and req.giaddr != "0.0.0.0": dst_ip = req.giaddr pkt = ( Ether(dst="ff:ff:ff:ff:ff:ff") / IP(src=server_ip, dst=dst_ip) / UDP(sport=67, dport=68) / BOOTP( op=2, xid=xid, chaddr=chaddr, yiaddr="0.0.0.0", siaddr=server_ip, giaddr=req.giaddr, ) / DHCP(options=options) ) return pkt def build_release_ack(req: DHCPMessage, server_ip: str) -> object: """Optional lightweight ACK to a RELEASE (many servers just silently accept).""" return None # Most DHCP servers don't respond to RELEASE