ea8eb2b5f9
Python 3 + Scapy DHCP server with: - Full DORA handshake (DISCOVER/OFFER/REQUEST/ACK) - Multi-scope support with DHCP relay - Static MAC-to-IP bindings - Lease persistence (JSON) with auto-prune - NACK for invalid requests - Flask web management panel (dark theme, live refresh) - Admin-privileged scapy sniff on UDP 67
333 lines
8.8 KiB
Python
333 lines
8.8 KiB
Python
"""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
|