Files
win-dhcpd/dhcpd/server.py
T
Your Name ea8eb2b5f9 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
2026-07-13 16:22:19 +08:00

304 lines
11 KiB
Python

"""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)