552d51df77
L2pcapListenSocket on Windows does not support 'stopper' kwarg. Replace with timeout=1.0 polling loop that respects stop_event.
311 lines
11 KiB
Python
311 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)
|
|
# Use timeout loop: L2pcapListenSocket (Windows/Npcap) does NOT
|
|
# support the 'stopper' kwarg, so poll with short timeout instead.
|
|
try:
|
|
while not self._stop_event.is_set():
|
|
packets = sniff(
|
|
iface=self.iface,
|
|
filter=bpf,
|
|
count=100, # cap per batch
|
|
timeout=1.0, # poll every ~1s
|
|
store=False,
|
|
)
|
|
for pkt in packets:
|
|
if self._stop_event.is_set():
|
|
break
|
|
self._handle_packet(pkt)
|
|
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)
|