ea8eb2b5f9
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
190 lines
6.8 KiB
Python
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)
|