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