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
This commit is contained in:
+189
@@ -0,0 +1,189 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user