Files
win-dhcpd/dhcpd/config.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

190 lines
6.8 KiB
Python

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