feat: WinDHCPD — Windows-native DHCP server
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
This commit is contained in:
+303
@@ -0,0 +1,303 @@
|
||||
"""Main DHCP server loop: sniff packets and dispatch to handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from scapy.all import sniff, send
|
||||
|
||||
from .config import Config, Scope, StaticBinding
|
||||
from .lease import Lease, LeaseDB
|
||||
from .parser import (
|
||||
DHCPDISCOVER, DHCPREQUEST, DHCPRELEASE,
|
||||
DHCPMessage, parse,
|
||||
build_offer, build_ack, build_nak,
|
||||
)
|
||||
|
||||
log = logging.getLogger("dhcpd.server")
|
||||
|
||||
# ── counters ──────────────────────────────────────────────────────────────
|
||||
_msgs = {"DISCOVER": 0, "REQUEST": 0, "RELEASE": 0, "NAK": 0, "total": 0}
|
||||
|
||||
|
||||
class DHCPd:
|
||||
"""
|
||||
Non-blocking DHCP server.
|
||||
|
||||
Runs scapy sniff in a background thread; packet handler runs in the same
|
||||
thread so lease DB access is effectively single-threaded.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
lease_db: LeaseDB,
|
||||
iface: Optional[str] = None,
|
||||
):
|
||||
self.config = config
|
||||
self.db = lease_db
|
||||
self.server_ip = config.server.get("ip", "")
|
||||
if not self.server_ip:
|
||||
# derive from first scope
|
||||
for sc in config.scopes:
|
||||
if sc.enable:
|
||||
# pick router as server IP if set, else first usable
|
||||
self.server_ip = sc.router or str(sc.start_ip - 1 if sc.start_ip > 1 else sc.start_ip)
|
||||
break
|
||||
|
||||
self.iface = iface
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
# Bindings index: ip -> binding, mac -> binding
|
||||
self._binding_by_ip: dict = {}
|
||||
self._binding_by_mac: dict = {}
|
||||
for b in config.bindings:
|
||||
self._binding_by_ip[b.ip] = b
|
||||
self._binding_by_mac[b.mac] = b
|
||||
|
||||
# ── public ───────────────────────────────────────────────────────────
|
||||
|
||||
def start(self) -> None:
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name="dhcpd-sniff")
|
||||
self._thread.start()
|
||||
log.info("DHCP server started (server_ip=%s, interface=%s)",
|
||||
self.server_ip, self.iface or "<default>")
|
||||
|
||||
def stop(self) -> None:
|
||||
log.info("Stopping DHCP server...")
|
||||
self._stop_event.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
self.db.save()
|
||||
log.info("DHCP server stopped.")
|
||||
|
||||
@property
|
||||
def stats(self) -> dict:
|
||||
return dict(_msgs)
|
||||
|
||||
# ── internal ─────────────────────────────────────────────────────────
|
||||
|
||||
def _run(self) -> None:
|
||||
bpf = "udp port 67 or udp port 68"
|
||||
log.info("Starting packet sniff on %s (BPF: %s)",
|
||||
self.iface or "<default>", bpf)
|
||||
try:
|
||||
sniff(
|
||||
iface=self.iface,
|
||||
filter=bpf,
|
||||
prn=self._handle_packet,
|
||||
stopper=lambda: self._stop_event.is_set(),
|
||||
store=False,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error("Sniffer died: %s", e)
|
||||
log.info("Sniffer thread exited.")
|
||||
|
||||
def _handle_packet(self, pkt) -> None:
|
||||
_msgs["total"] += 1
|
||||
msg = parse(pkt)
|
||||
if msg is None:
|
||||
return
|
||||
log.debug("Recv %s from %s xid=0x%x", msg.msg_name, msg.client_mac, msg.xid)
|
||||
|
||||
try:
|
||||
if msg.msg_type == DHCPDISCOVER:
|
||||
_msgs["DISCOVER"] += 1
|
||||
self._handle_discover(msg)
|
||||
elif msg.msg_type == DHCPREQUEST:
|
||||
_msgs["REQUEST"] += 1
|
||||
self._handle_request(msg)
|
||||
elif msg.msg_type == DHCPRELEASE:
|
||||
_msgs["RELEASE"] += 1
|
||||
self._handle_release(msg)
|
||||
except Exception as e:
|
||||
log.exception("Error handling %s", msg.msg_name)
|
||||
|
||||
# ── message handlers ─────────────────────────────────────────────────
|
||||
|
||||
def _handle_discover(self, msg: DHCPMessage) -> None:
|
||||
"""
|
||||
DHCPDISCOVER -> DHCPOFFER
|
||||
|
||||
Pick an address based on:
|
||||
1. Static binding for this MAC
|
||||
2. Requested IP in options (if present and valid)
|
||||
3. Auto-allocate from pool
|
||||
"""
|
||||
sc, offer_ip = self._pick_address(msg)
|
||||
if offer_ip is None:
|
||||
log.warning("DISCOVER: no address available for %s", msg.client_mac)
|
||||
return
|
||||
|
||||
pkt = build_offer(
|
||||
req=msg,
|
||||
offer_ip=offer_ip,
|
||||
lease_seconds=sc.lease_seconds,
|
||||
server_ip=self.server_ip,
|
||||
router=sc.router,
|
||||
dns=sc.dns_servers,
|
||||
subnet_mask=sc.subnet_mask,
|
||||
domain=sc.domain,
|
||||
)
|
||||
send(pkt, iface=self.iface)
|
||||
log.info("OFFER %s -> %s (lease=%ds)", offer_ip, msg.client_mac, sc.lease_seconds)
|
||||
|
||||
def _handle_request(self, msg: DHCPMessage) -> None:
|
||||
"""
|
||||
DHCPREQUEST -> DHCPACK or NAK
|
||||
|
||||
Client may specify the requested IP in two ways:
|
||||
- ciaddr field (non-zero) = client already has an address
|
||||
- "requested_addr" DHCP option = newly requested
|
||||
- yiaddr from the OFFER (server must check)
|
||||
"""
|
||||
# Determine which IP the client wants
|
||||
requested_ip = msg.ciaddr if msg.ciaddr != "0.0.0.0" else None
|
||||
if not requested_ip:
|
||||
for o in (msg.options.get("param_req"),):
|
||||
pass # param_req is what client wants, not the address
|
||||
# Check if there's a "requested_addr" option
|
||||
# (parser currently doesn't expose it; derive from OFFER state)
|
||||
# Fallback: use the lease we offered
|
||||
lease = self.db.find_by_mac(msg.client_mac)
|
||||
if lease:
|
||||
requested_ip = lease.ip
|
||||
|
||||
if requested_ip is None:
|
||||
log.warning("REQUEST from %s has no requested IP", msg.client_mac)
|
||||
return
|
||||
|
||||
sc = self.config.scope_for_ip(requested_ip)
|
||||
if sc is None:
|
||||
log.warning("REQUEST for %s: no matching scope", requested_ip)
|
||||
self._send_nak(msg)
|
||||
_msgs["NAK"] += 1
|
||||
return
|
||||
|
||||
# Check if a static binding maps this IP
|
||||
static = self._binding_by_ip.get(requested_ip)
|
||||
if static and static.mac != msg.client_mac.replace(":", ""):
|
||||
log.warning("REQUEST for %s but IP is statically bound to another MAC",
|
||||
requested_ip)
|
||||
self._send_nak(msg)
|
||||
_msgs["NAK"] += 1
|
||||
return
|
||||
|
||||
# Mark lease as acknowledged
|
||||
now = time.time()
|
||||
lease = self.db.find_by_mac(msg.client_mac)
|
||||
if lease and lease.ip == requested_ip:
|
||||
lease.acknowledged_at = now
|
||||
lease.expires_at = now + sc.lease_seconds
|
||||
lease.scope = sc.name
|
||||
lease.hostname = msg.hostname or lease.hostname
|
||||
self.db.save()
|
||||
else:
|
||||
# New allocation that wasn't pre-allocated
|
||||
lease = Lease(
|
||||
mac=msg.client_mac,
|
||||
ip=requested_ip,
|
||||
hostname=msg.hostname,
|
||||
offered_at=now,
|
||||
acknowledged_at=now,
|
||||
expires_at=now + sc.lease_seconds,
|
||||
static=static is not None,
|
||||
scope=sc.name,
|
||||
)
|
||||
self.db.add(lease)
|
||||
|
||||
pkt = build_ack(
|
||||
req=msg,
|
||||
offered_ip=requested_ip,
|
||||
lease_seconds=sc.lease_seconds,
|
||||
server_ip=self.server_ip,
|
||||
router=sc.router,
|
||||
dns=sc.dns_servers,
|
||||
subnet_mask=sc.subnet_mask,
|
||||
domain=sc.domain,
|
||||
)
|
||||
send(pkt, iface=self.iface)
|
||||
log.info("ACK %s -> %s (hostname=%s)", requested_ip, msg.client_mac, msg.hostname)
|
||||
|
||||
def _handle_release(self, msg: DHCPMessage) -> None:
|
||||
"""Handle DHCPRELEASE: remove lease for this MAC."""
|
||||
lease = self.db.find_by_mac(msg.client_mac)
|
||||
if lease:
|
||||
ip = lease.ip
|
||||
self.db.release(msg.client_mac)
|
||||
log.info("RELEASE acknowledged: %s freed", ip)
|
||||
else:
|
||||
log.info("RELEASE for unknown MAC %s", msg.client_mac)
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _pick_address(self, msg: DHCPMessage) -> tuple:
|
||||
"""
|
||||
Return (Scope, IP) to offer, or (Scope, None).
|
||||
"""
|
||||
# 1. Static binding
|
||||
binding = self.config.binding_for_mac(msg.client_mac)
|
||||
if binding:
|
||||
sc = self.config.scope_for_ip(binding.ip)
|
||||
if not sc:
|
||||
log.warning("Static binding for %s points to IP %s not in any scope",
|
||||
msg.client_mac, binding.ip)
|
||||
sc = self._select_scope(msg) # fallback
|
||||
if sc:
|
||||
# Mark as static in lease if not already
|
||||
lease = self.db.find_by_mac(msg.client_mac)
|
||||
if lease and not lease.static:
|
||||
lease.static = True
|
||||
lease.expires_at = time.time() + sc.lease_seconds
|
||||
self.db.save()
|
||||
return sc, binding.ip
|
||||
|
||||
# 2. Auto-allocate: pick scope based on server/relay context
|
||||
sc = self._select_scope(msg)
|
||||
if sc is None:
|
||||
# fallback: first enabled scope
|
||||
for s in self.config.scopes:
|
||||
if s.enable:
|
||||
sc = s
|
||||
break
|
||||
if sc is None:
|
||||
return sc, None
|
||||
|
||||
# Auto-allocate next free
|
||||
lease = self.db.find_by_mac(msg.client_mac)
|
||||
if lease and not lease.is_expired:
|
||||
return sc, lease.ip
|
||||
|
||||
offer = self.db.allocate_next(
|
||||
start=str(sc.start_ip),
|
||||
end=str(sc.end_ip),
|
||||
mac=msg.client_mac,
|
||||
lease_seconds=sc.lease_seconds,
|
||||
xid=msg.xid,
|
||||
hostname=msg.hostname,
|
||||
scope=sc.name,
|
||||
)
|
||||
return sc, offer.ip if offer else None
|
||||
|
||||
def _select_scope(self, msg: DHCPMessage) -> Optional[Scope]:
|
||||
"""Pick a scope based on giaddr (relay) or first enabled scope."""
|
||||
if msg.giaddr and msg.giaddr != "0.0.0.0":
|
||||
sc = self.config.scope_for_ip(msg.giaddr)
|
||||
if sc and sc.enable:
|
||||
return sc
|
||||
# Return first enabled scope
|
||||
for s in self.config.scopes:
|
||||
if s.enable:
|
||||
return s
|
||||
return None
|
||||
|
||||
def _send_nak(self, msg: DHCPMessage) -> None:
|
||||
pkt = build_nak(msg, self.server_ip)
|
||||
send(pkt, iface=self.iface)
|
||||
Reference in New Issue
Block a user