"""Configuration loading and validation.""" from __future__ import annotations import copy import ipaddress import logging import os import socket from typing import Any, Dict, List, Optional import yaml log = logging.getLogger("dhcpd.config") # ── Defaults ────────────────────────────────────────────────────────────── DEFAULT_LEASE_SECONDS = 86400 # 24 hours DEFAULT_OFFER_LEASE_SECONDS = 7200 # 2 hours (offer expires faster) DEFAULT_LOG_LEVEL = "INFO" class Scope: """One DHCP scope (one /24 subnet or similar).""" def __init__( self, name: str, network: str, start_ip: str, end_ip: str, subnet_mask: str = "255.255.255.0", router: str = "", dns_servers: List[str] | None = None, lease_seconds: int = DEFAULT_LEASE_SECONDS, domain: str = "", enable: bool = True, ): self.name = name self.subnet = ipaddress.ip_network(network, strict=False) self.network = str(self.subnet) self.start_ip = ipaddress.ip_address(start_ip) self.end_ip = ipaddress.ip_address(end_ip) self.subnet_mask = subnet_mask self.router = router self.dns_servers = dns_servers or [] self.lease_seconds = lease_seconds self.domain = domain self.enable = enable # Validate pool is inside the subnet assert self.start_ip in self.subnet, f"{start_ip} not in {network}" assert self.end_ip in self.subnet, f"{end_ip} not in {network}" assert self.start_ip <= self.end_ip, f"start > end in scope {name}" if self.router: assert ipaddress.ip_address(self.router) in self.subnet, \ f"router {self.router} not in {network}" for d in self.dns_servers: # DNS may be outside the subnet (e.g. public DNS), only warn if ipaddress.ip_address(d) not in self.subnet: logging.getLogger("dhcpd.config").warning( "Scope %s: DNS %s is outside subnet %s (ok for public DNS)", self.name, d, self.subnet) def __repr__(self) -> str: return f"Scope({self.name} {self.network} [{self.start_ip}-{self.end_ip}])" def is_for_network(self, ip: str) -> bool: try: return ipaddress.ip_address(ip) in self.subnet except ValueError: return False class StaticBinding: """Fixed IP-for-MAC assignment.""" def __init__(self, mac: str, ip: str, scope_name: str, hostname: str = ""): self.mac = mac.lower().replace(":", "").replace("-", "") self.ip = str(ipaddress.ip_address(ip)) self.scope_name = scope_name self.hostname = hostname # Normalize MAC display self.mac_display = ":".join( self.mac[i:i+2] for i in range(0, 12, 2) ) def __repr__(self) -> str: return f"StaticBinding({self.mac_display} -> {self.ip})" class Config: """Top-level configuration.""" def __init__(self, path: str): self.path = path self.raw: Dict[str, Any] = {} self.server: Dict[str, Any] = {} self.scopes: List[Scope] = [] self.bindings: List[StaticBinding] = [] self.log_file: str = "dhcpd.log" self.log_level: str = DEFAULT_LOG_LEVEL self.web_port: int = 8080 self.web_host: str = "0.0.0.0" self._load(path) # ── loading ────────────────────────────────────────────────────────── def _load(self, path: str) -> None: if not os.path.exists(path): raise FileNotFoundError(f"Config not found: {path}") with open(path, "r", encoding="utf-8") as f: self.raw = yaml.safe_load(f) or {} self._apply(self.raw) def _apply(self, d: Dict[str, Any]) -> None: self.server = d.get("server", {}) self.log_file = d.get("log_file", "dhcpd.log") self.log_level = d.get("log_level", DEFAULT_LOG_LEVEL) self.web_host = d.get("web_host", "0.0.0.0") self.web_port = int(d.get("web_port", 8080)) scopes = d.get("scopes", []) for s in scopes: sc = Scope( name=s["name"], network=s["network"], start_ip=s["start_ip"], end_ip=s["end_ip"], subnet_mask=s.get("subnet_mask", "255.255.255.0"), router=s.get("router", ""), dns_servers=s.get("dns_servers", None), lease_seconds=s.get("lease_seconds", DEFAULT_LEASE_SECONDS), domain=s.get("domain", ""), enable=s.get("enable", True), ) self.scopes.append(sc) for b in d.get("bindings", []): self.bindings.append(StaticBinding( mac=b["mac"], ip=b["ip"], scope_name=b.get("scope", ""), hostname=b.get("hostname", ""), )) self._validate() log.info("Loaded %d scopes, %d bindings from %s", len(self.scopes), len(self.bindings), self.path) def _validate(self) -> None: if not self.scopes: raise ValueError("No scopes defined in config") for sc in self.scopes: if not sc.enable: continue # Check no overlapping scopes for other in self.scopes: if other is sc or not other.enable: continue if sc.subnet.overlaps(other.subnet): raise ValueError( f"Overlapping enabled scopes: {sc.name} vs {other.name}") # Check all bindings reference a valid scope scope_names = {s.name for s in self.scopes} for b in self.bindings: if b.scope_name and b.scope_name not in scope_names: log.warning("Binding for %s references unknown scope '%s'", b.mac_display, b.scope_name) # ── queries ────────────────────────────────────────────────────────── def scope_for_ip(self, ip: str) -> Optional[Scope]: """Return the (enabled) scope whose network contains *ip*.""" for sc in self.scopes: if sc.enable and sc.is_for_network(ip): return sc return None def binding_for_mac(self, mac: str) -> Optional[StaticBinding]: raw = mac.lower().replace(":", "").replace("-", "") for b in self.bindings: if b.mac == raw: return b return None def clone(self) -> "Config": return copy.deepcopy(self)