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:
+11
@@ -0,0 +1,11 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
*.log
|
||||
*.yaml
|
||||
!*.yaml.example
|
||||
.env
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.ipynb_checkpoints/
|
||||
@@ -0,0 +1,223 @@
|
||||
# WinDHCPD — Windows 原生 DHCP 服务器
|
||||
|
||||
基于 **Python 3 + Scapy** 实现的轻量级 DHCP 服务器,可在 Windows 上以管理员权限运行。
|
||||
|
||||
## 功能特性
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **完整 DORA 握手** | DISCOVER → OFFER → REQUEST → ACK,符合 RFC 2131 |
|
||||
| **多作用域** | 支持多个网段(/24),可通过 DHCP Relay 跨网段服务 |
|
||||
| **静态绑定** | MAC → IP 固定分配,优先于动态分配 |
|
||||
| **租约管理** | 内存索引 + JSON 持久化,支持释放、续租、清理过期 |
|
||||
| **Web 管理面板** | Flask 面板,实时查看租约、作用域、统计(端口 8080) |
|
||||
| **NACK 拒绝** | 对不合法请求返回 NACK |
|
||||
| **日志记录** | 控制台 + 文件双输出 |
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
win-dhcpd/
|
||||
├── run.py # 入口脚本
|
||||
├── dhcpd.yaml.example # 配置文件示例
|
||||
├── requirements.txt # Python 依赖
|
||||
├── dhcpd/
|
||||
│ ├── config.py # 配置加载与校验
|
||||
│ ├── lease.py # 租约数据库(内存 + JSON 持久化)
|
||||
│ ├── parser.py # DHCP 包解析与构建(Scapy)
|
||||
│ ├── server.py # DHCP 服务器主循环(Sniffer + 响应)
|
||||
│ └── web/
|
||||
│ ├── app.py # Flask Web 面板
|
||||
│ └── templates/
|
||||
│ └── index.html # 管理界面(深色主题,自动刷新)
|
||||
```
|
||||
|
||||
## 前置条件
|
||||
|
||||
### 1. 安装 Python 3.8+
|
||||
确保 Windows 上已安装 Python 3.8 或更高版本。
|
||||
|
||||
### 2. 安装 Npcap(scapy 依赖)
|
||||
Scapy 在 Windows 上需要 Npcap 来捕获和发送网络数据包:
|
||||
- 下载:https://nmap.org/npcap/
|
||||
- 安装时 **必须勾选 "Install Npcap in WinPcap API-compatible Mode"**
|
||||
- 默认安装即可
|
||||
|
||||
### 3. 安装依赖
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
依赖包:
|
||||
- **scapy** (≥2.5) — 网络数据包捕获与发送
|
||||
- **flask** (≥3.0) — Web 管理面板
|
||||
- **pyyaml** (≥6.0) — 配置文件解析
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 创建配置文件
|
||||
```bash
|
||||
copy dhcpd.yaml.example dhcpd.yaml
|
||||
```
|
||||
编辑 `dhcpd.yaml`,修改作用域(网段、地址池、网关、DNS)为实际网络参数。
|
||||
|
||||
**关键参数说明:**
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `server.ip` | DHCP 服务器自身 IP | `192.168.1.1` |
|
||||
| `scopes[].network` | 网段(CIDR) | `192.168.1.0/24` |
|
||||
| `scopes[].start_ip` | 地址池起始 | `192.168.1.100` |
|
||||
| `scopes[].end_ip` | 地址池结束 | `192.168.1.200` |
|
||||
| `scopes[].router` | 默认网关 | `192.168.1.1` |
|
||||
| `scopes[].dns_servers` | DNS 服务器列表 | `[8.8.8.8, 114.114.114.114]` |
|
||||
| `scopes[].lease_seconds` | 租期(秒) | `86400` (24小时) |
|
||||
| `bindings[].mac` | 静态绑定 MAC | `aa:bb:cc:dd:ee:01` |
|
||||
| `bindings[].ip` | 静态绑定 IP | `192.168.1.10` |
|
||||
|
||||
### 2. 以管理员身份运行
|
||||
**必须使用管理员权限**(捕获端口 67 的广播数据包需要特权):
|
||||
|
||||
```bash
|
||||
# Windows 命令行
|
||||
python run.py
|
||||
|
||||
# 指定配置文件
|
||||
python run.py dhcpd.yaml
|
||||
|
||||
# 指定监听网卡
|
||||
python run.py dhcpd.yaml -i "以太网"
|
||||
|
||||
# 禁用 Web 面板
|
||||
python run.py dhcpd.yaml --no-web
|
||||
|
||||
# 指定 Web 端口
|
||||
python run.py dhcpd.yaml --web-port 9000
|
||||
```
|
||||
|
||||
### 3. 访问 Web 面板
|
||||
打开浏览器访问:
|
||||
```
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
## 配置文件详解
|
||||
|
||||
```yaml
|
||||
# ── 服务器设置 ──
|
||||
server:
|
||||
ip: "192.168.1.1" # DHCP 服务器 IP(响应包中的 server_id)
|
||||
|
||||
# ── 作用域 ──
|
||||
scopes:
|
||||
- name: "Office-LAN" # 作用域名称
|
||||
network: "192.168.1.0/24"
|
||||
start_ip: "192.168.1.100"
|
||||
end_ip: "192.168.1.200"
|
||||
subnet_mask: "255.255.255.0"
|
||||
router: "192.168.1.1"
|
||||
dns_servers:
|
||||
- "8.8.8.8"
|
||||
- "114.114.114.114"
|
||||
lease_seconds: 86400 # 租期 24 小时
|
||||
domain: "local"
|
||||
enable: true
|
||||
|
||||
# ── 静态绑定 ──
|
||||
bindings:
|
||||
- mac: "aa:bb:cc:dd:ee:01"
|
||||
ip: "192.168.1.10"
|
||||
scope: "Office-LAN"
|
||||
hostname: "printer"
|
||||
|
||||
# ── 其他 ──
|
||||
log_file: "dhcpd.log"
|
||||
log_level: "INFO" # DEBUG / INFO / WARNING / ERROR
|
||||
web_host: "0.0.0.0"
|
||||
web_port: 8080
|
||||
# interface: "以太网" # 可选:指定监听网卡
|
||||
```
|
||||
|
||||
## 网络拓扑要求
|
||||
|
||||
### 场景 1:单机 DHCP 服务器
|
||||
```
|
||||
客户端 ────── 交换机/集线器 ────── WinDHCPD (192.168.1.1)
|
||||
↑ ↑
|
||||
│ 广播 DISCOVER │ 响应 OFFER/ACK
|
||||
└────────────────────────────────┘
|
||||
```
|
||||
- WinDHCPD 所在的网卡必须是 **混杂模式** 或连接在 Hub/支持组播的交换机上
|
||||
- 同一网段 **只能有一个 DHCP 服务器**
|
||||
|
||||
### 场景 2:跨网段(DHCP Relay)
|
||||
```
|
||||
子网 A 客户端 ── 路由器(Relay) ── WinDHCPD (192.168.1.1)
|
||||
↑
|
||||
giaddr = 10.0.2.1
|
||||
```
|
||||
- 路由器需配置 `ip helper-address <WinDHCPD_IP>`
|
||||
- WinDHCPD 通过 `giaddr` 字段确定客户端所在网段,选择对应作用域
|
||||
|
||||
## 工作原理
|
||||
|
||||
```
|
||||
客户端 服务器
|
||||
│ │
|
||||
│ DHCPDISCOVER (广播) │
|
||||
│ ─────────────────────► │
|
||||
│ │ 分配 IP → 发 OFFER
|
||||
│ DHCPOFFER (广播) │
|
||||
│ ◄────────────────────── │
|
||||
│ │
|
||||
│ DHCPREQUEST (广播) │
|
||||
│ ─────────────────────► │
|
||||
│ │ 更新租约 → 发 ACK
|
||||
│ DHCPACK (广播) │
|
||||
│ ◄────────────────────── │
|
||||
│ │
|
||||
│ DHCPRELEASE │
|
||||
│ ─────────────────────► │ 释放 IP
|
||||
```
|
||||
|
||||
## 停止服务器
|
||||
|
||||
- 控制台:按 `Ctrl+C`
|
||||
- 任务管理器:结束 `python.exe` 进程
|
||||
|
||||
租约数据自动保存到 `leases.db`,下次启动时自动加载。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 客户端获取不到 IP
|
||||
1. **必须以管理员身份运行** — 端口 67 需要特权
|
||||
2. **检查 Npcap 是否安装** — 且勾选了 WinPcap 兼容模式
|
||||
3. **确认网卡选择** — 用 `-i "网卡名"` 指定正确的网卡
|
||||
4. **同一网段不能有另一个 DHCP 服务器** — Windows 自带的 DHCP Server 服务必须关闭
|
||||
|
||||
### Q: 如何查看日志
|
||||
```bash
|
||||
# 实时查看
|
||||
type dhcpd.log
|
||||
|
||||
# 修改日志级别(配置文件中)
|
||||
log_level: "DEBUG"
|
||||
```
|
||||
|
||||
### Q: 支持 IPv6 吗
|
||||
不支持。本服务器仅处理 IPv4 DHCP。
|
||||
|
||||
### Q: 支持 DHCPv6 吗
|
||||
不支持。如需 IPv6 分配,请配置 SLAAC 或单独的 DHCPv6 服务器。
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
⚠️ **不要在生产网络中直接部署**,除非你完全了解后果:
|
||||
|
||||
1. **单点故障** — 这台机器挂了,所有 DHCP 客户端都无法获取 IP
|
||||
2. **冲突风险** — 同一网段有两个 DHCP 服务器会导致客户端随机获取 IP
|
||||
3. **端口监听** — 服务器会监听 UDP 67 的广播包,其他设备可能无法响应
|
||||
4. **建议** — 先用隔离网络测试,确认无误后再部署
|
||||
|
||||
## 许可
|
||||
|
||||
MIT License
|
||||
@@ -0,0 +1,77 @@
|
||||
# =============================================================================
|
||||
# WinDHCPD 配置文件
|
||||
# =============================================================================
|
||||
# 用法: 将此文件保存为 dhcpd.yaml,修改后运行 python run.py dhcpd.yaml
|
||||
#
|
||||
# 注意:
|
||||
# - 本服务器通过 scapy 以混杂模式捕获并发送以太网帧,需要管理员权限。
|
||||
# - Windows 上需安装 Npcap (带 "WinPcap API-compatible" 选项) 供 scapy 使用。
|
||||
# - 同一网段只能有一个 DHCP 服务器,否则客户端可能获取到错误 IP。
|
||||
# - 不要与 Windows 内置 DHCP Server 服务共用网段。
|
||||
# =============================================================================
|
||||
|
||||
# ── 服务器基本设置 ────────────────────────────────────────────────────────
|
||||
server:
|
||||
# DHCP 服务器本身的 IP 地址(响应包中的 siaddr / server_id)
|
||||
ip: "192.168.1.1"
|
||||
|
||||
# ── 作用域 (Scopes) ──────────────────────────────────────────────────────
|
||||
# 每个作用域 = 一个网段 + 一个 IP 地址池
|
||||
# 多个作用域可以实现多网段 DHCP(需配置 relay)
|
||||
scopes:
|
||||
- name: "Office-LAN"
|
||||
# 网段,格式 network/prefix
|
||||
network: "192.168.1.0/24"
|
||||
# 地址池起止
|
||||
start_ip: "192.168.1.100"
|
||||
end_ip: "192.168.1.200"
|
||||
# 子网掩码
|
||||
subnet_mask: "255.255.255.0"
|
||||
# 默认网关
|
||||
router: "192.168.1.1"
|
||||
# DNS 服务器(可填多个)
|
||||
dns_servers:
|
||||
- "8.8.8.8"
|
||||
- "114.114.114.114"
|
||||
# 租期(秒)
|
||||
lease_seconds: 86400 # 24 小时
|
||||
# 域名
|
||||
domain: "local"
|
||||
# 启用/禁用
|
||||
enable: true
|
||||
|
||||
# # 示例:第二个作用域
|
||||
# - name: "Guest-LAN"
|
||||
# network: "192.168.2.0/24"
|
||||
# start_ip: "192.168.2.50"
|
||||
# end_ip: "192.168.2.150"
|
||||
# subnet_mask: "255.255.255.0"
|
||||
# router: "192.168.2.1"
|
||||
# dns_servers: ["8.8.8.8"]
|
||||
# lease_seconds: 3600
|
||||
# domain: "guest"
|
||||
# enable: true
|
||||
|
||||
# ── 静态绑定 ─────────────────────────────────────────────────────────────
|
||||
# 为特定 MAC 地址固定分配指定 IP
|
||||
bindings:
|
||||
- mac: "aa:bb:cc:dd:ee:01"
|
||||
ip: "192.168.1.10"
|
||||
scope: "Office-LAN"
|
||||
hostname: "printer"
|
||||
- mac: "aa:bb:cc:dd:ee:02"
|
||||
ip: "192.168.1.11"
|
||||
scope: "Office-LAN"
|
||||
hostname: "nas"
|
||||
|
||||
# ── 日志 ─────────────────────────────────────────────────────────────────
|
||||
log_file: "dhcpd.log"
|
||||
log_level: "INFO" # DEBUG / INFO / WARNING / ERROR
|
||||
|
||||
# ── Web 管理面板 ─────────────────────────────────────────────────────────
|
||||
web_host: "0.0.0.0"
|
||||
web_port: 8080
|
||||
|
||||
# ── 网络接口 ─────────────────────────────────────────────────────────────
|
||||
# 留空则 scapy 自动选择; 指定则只监听该网卡
|
||||
# interface: "Ethernet"
|
||||
@@ -0,0 +1,2 @@
|
||||
# WinDHCPD - Windows-native DHCP server
|
||||
# Python 3 + Scapy based
|
||||
+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)
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
"""Lease database: in-memory index + JSON persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
log = logging.getLogger("dhcpd.lease")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Lease:
|
||||
mac: str # "aa:bb:cc:dd:ee:ff"
|
||||
ip: str
|
||||
hostname: str = ""
|
||||
offered_at: float = 0.0
|
||||
acknowledged_at: float = 0.0
|
||||
expires_at: float = 0.0
|
||||
static: bool = False # True = from static binding
|
||||
xid: int = 0
|
||||
scope: str = ""
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return time.time() > self.expires_at
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.expires_at > time.time()
|
||||
|
||||
def to_json(self) -> dict:
|
||||
return {
|
||||
"mac": self.mac,
|
||||
"ip": self.ip,
|
||||
"hostname": self.hostname,
|
||||
"offered_at": self.offered_at,
|
||||
"acknowledged_at": self.acknowledged_at,
|
||||
"expires_at": self.expires_at,
|
||||
"static": self.static,
|
||||
"scope": self.scope,
|
||||
}
|
||||
|
||||
|
||||
class LeaseDB:
|
||||
"""Thread-ish safe lease store. (Single-threaded server, no lock needed.)"""
|
||||
|
||||
def __init__(self, db_path: str = "leases.db"):
|
||||
self.db_path = db_path
|
||||
# mac -> Lease
|
||||
self.by_mac: Dict[str, Lease] = {}
|
||||
# ip -> mac
|
||||
self.by_ip: Dict[str, str] = {}
|
||||
self._load()
|
||||
|
||||
# ── persistence ──────────────────────────────────────────────────────
|
||||
|
||||
def _load(self) -> None:
|
||||
if not os.path.exists(self.db_path):
|
||||
return
|
||||
try:
|
||||
with open(self.db_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
log.warning("Could not load lease DB: %s", e)
|
||||
return
|
||||
if isinstance(data, list):
|
||||
for rec in data:
|
||||
try:
|
||||
lease = Lease(**rec)
|
||||
if lease.is_active or lease.static:
|
||||
self._put(lease)
|
||||
except TypeError:
|
||||
continue
|
||||
log.info("Loaded %d leases from %s", len(self.by_mac), self.db_path)
|
||||
|
||||
def save(self) -> None:
|
||||
try:
|
||||
records = [l.to_json() for l in self.by_mac.values()]
|
||||
with open(self.db_path, "w", encoding="utf-8") as f:
|
||||
json.dump(records, f, indent=2)
|
||||
except OSError as e:
|
||||
log.error("Failed to save lease DB: %s", e)
|
||||
|
||||
# ── CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _put(self, lease: Lease) -> None:
|
||||
# remove stale entry if any
|
||||
if lease.mac in self.by_mac:
|
||||
old = self.by_mac[lease.mac]
|
||||
if old.ip != lease.ip:
|
||||
self.by_ip.pop(old.ip, None)
|
||||
if lease.ip in self.by_ip:
|
||||
del self.by_mac[self.by_ip[lease.ip]]
|
||||
self.by_mac[lease.mac] = lease
|
||||
self.by_ip[lease.ip] = lease.mac
|
||||
|
||||
def find_by_mac(self, mac: str) -> Optional[Lease]:
|
||||
return self.by_mac.get(mac)
|
||||
|
||||
def find_by_ip(self, ip: str) -> Optional[Lease]:
|
||||
mac = self.by_ip.get(ip)
|
||||
if mac:
|
||||
return self.by_mac.get(mac)
|
||||
return None
|
||||
|
||||
def is_ip_taken(self, ip: str) -> bool:
|
||||
return ip in self.by_ip
|
||||
|
||||
def add(self, lease: Lease) -> Lease:
|
||||
self._put(lease)
|
||||
self.save()
|
||||
return lease
|
||||
|
||||
def release(self, mac: str) -> bool:
|
||||
if mac in self.by_mac:
|
||||
lease = self.by_mac[mac]
|
||||
self.by_ip.pop(lease.ip, None)
|
||||
del self.by_mac[mac]
|
||||
self.save()
|
||||
log.info("Released lease: %s -> %s", mac, lease.ip)
|
||||
return True
|
||||
return False
|
||||
|
||||
def extend(self, mac: str, new_expires: float) -> Optional[Lease]:
|
||||
lease = self.by_mac.get(mac)
|
||||
if lease:
|
||||
lease.expires_at = new_expires
|
||||
self.save()
|
||||
return lease
|
||||
return None
|
||||
|
||||
# ── allocation ───────────────────────────────────────────────────────
|
||||
|
||||
def allocate_next(
|
||||
self,
|
||||
start: str,
|
||||
end: str,
|
||||
mac: str,
|
||||
lease_seconds: int,
|
||||
xid: int = 0,
|
||||
hostname: str = "",
|
||||
scope: str = "",
|
||||
) -> Optional[Lease]:
|
||||
"""Walk from start_ip to end_ip, return the first free address."""
|
||||
now = time.time()
|
||||
a_start = ipaddress.ip_address(start)
|
||||
a_end = ipaddress.ip_address(end)
|
||||
cur = a_start
|
||||
while cur <= a_end:
|
||||
cand = str(cur)
|
||||
if not self.is_ip_taken(cand):
|
||||
lease = Lease(
|
||||
mac=mac,
|
||||
ip=cand,
|
||||
hostname=hostname,
|
||||
offered_at=now,
|
||||
expires_at=now + lease_seconds,
|
||||
xid=xid,
|
||||
scope=scope,
|
||||
)
|
||||
self.add(lease)
|
||||
return lease
|
||||
cur = cur + 1
|
||||
return None
|
||||
|
||||
def list_all(self) -> List[Lease]:
|
||||
return sorted(self.by_mac.values(), key=lambda l: l.ip)
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self.by_mac)
|
||||
|
||||
def count_active(self) -> int:
|
||||
return sum(1 for l in self.by_mac.values() if l.is_active)
|
||||
|
||||
def prune_expired(self) -> int:
|
||||
"""Remove expired non-static leases. Returns count removed."""
|
||||
to_remove = [
|
||||
mac for mac, l in self.by_mac.items()
|
||||
if not l.static and l.is_expired
|
||||
]
|
||||
for mac in to_remove:
|
||||
self.release(mac)
|
||||
return len(to_remove)
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
"""DHCP packet parsing using Scapy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from scapy.all import Ether, IP, UDP, BOOTP, DHCP
|
||||
|
||||
log = logging.getLogger("dhcpd.parser")
|
||||
|
||||
|
||||
# DHCP message types (RFC 2132)
|
||||
DHCPDISCOVER = 1
|
||||
DHCPOFFER = 2
|
||||
DHCPREQUEST = 3
|
||||
DHCPDECLINE = 4
|
||||
DHCPACK = 5
|
||||
DHCPNAK = 6
|
||||
DHCPRELEASE = 7
|
||||
DHCPINFORM = 8
|
||||
|
||||
MSG_NAMES = {
|
||||
DHCPDISCOVER: "DISCOVER",
|
||||
DHCPOFFER: "OFFER",
|
||||
DHCPREQUEST: "REQUEST",
|
||||
DHCPDECLINE: "DECLINE",
|
||||
DHCPACK: "ACK",
|
||||
DHCPNAK: "NAK",
|
||||
DHCPRELEASE: "RELEASE",
|
||||
DHCPINFORM: "INFORM",
|
||||
}
|
||||
|
||||
|
||||
class DHCPMessage:
|
||||
"""Normalized view of a parsed DHCP packet."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
msg_type: int,
|
||||
xid: int,
|
||||
client_mac: str,
|
||||
client_ip: str = "0.0.0.0",
|
||||
your_ip: str = "0.0.0.0",
|
||||
server_ip: str = "0.0.0.0",
|
||||
server_id: str = "0.0.0.0",
|
||||
giaddr: str = "0.0.0.0",
|
||||
ciaddr: str = "0.0.0.0",
|
||||
options: dict | None = None,
|
||||
hostname: str = "",
|
||||
src_mac: str = "",
|
||||
src_ip: str = "",
|
||||
dst_ip: str = "",
|
||||
):
|
||||
self.msg_type = msg_type
|
||||
self.msg_name = MSG_NAMES.get(msg_type, f"TYPE-{msg_type}")
|
||||
self.xid = xid
|
||||
self.client_mac = client_mac.upper()
|
||||
self.client_ip = client_ip
|
||||
self.your_ip = your_ip
|
||||
self.server_ip = server_ip
|
||||
self.server_id = server_id
|
||||
self.giaddr = giaddr
|
||||
self.ciaddr = ciaddr
|
||||
self.options = options or {}
|
||||
self.hostname = hostname
|
||||
self.src_mac = src_mac
|
||||
self.src_ip = src_ip
|
||||
self.dst_ip = dst_ip
|
||||
|
||||
@property
|
||||
def is_broadcast(self) -> bool:
|
||||
return self.dst_ip in ("255.255.255.255", "0.0.0.0") or self.dst_mac == "ff:ff:ff:ff:ff:ff"
|
||||
|
||||
@property
|
||||
def dst_mac(self) -> str:
|
||||
return self.dst_ip # placeholder
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (f"{self.msg_name} xid=0x{self.xid:08x} "
|
||||
f"mac={self.client_mac} ci={self.client_ip} "
|
||||
f"hostname={self.hostname!r}")
|
||||
|
||||
|
||||
def _get_option(options: list | None, name: str) -> any:
|
||||
if not options:
|
||||
return None
|
||||
for o in options:
|
||||
if o.name == name:
|
||||
return o
|
||||
return None
|
||||
|
||||
|
||||
def parse(pkt) -> Optional[DHCPMessage]:
|
||||
"""Parse a scapy packet; return normalized DHCPMessage or None."""
|
||||
if not pkt.haslayer(DHCP):
|
||||
return None
|
||||
|
||||
dh = pkt[DHCP]
|
||||
bp = pkt[BOOTP]
|
||||
|
||||
msg_type = None
|
||||
hostname = ""
|
||||
server_id = "0.0.0.0"
|
||||
options = {}
|
||||
|
||||
# Scapy stores DHCP options as:
|
||||
# ("option-name", value) or
|
||||
# (code_int, value_bytes) or
|
||||
# "end" (terminator)
|
||||
for o in dh.options:
|
||||
if o == "end":
|
||||
continue
|
||||
if isinstance(o, tuple):
|
||||
name = o[0]
|
||||
val = o[1]
|
||||
else:
|
||||
continue # skip unknown format
|
||||
|
||||
# Map option codes to names
|
||||
_CODE_NAMES = {53: "message-type", 54: "server_id", 1: "subnet_mask",
|
||||
3: "router", 6: "domain_name_server", 15: "domain_name",
|
||||
51: "lease_time", 55: "parameter_request_list",
|
||||
50: "requested_addr", 12: "hostname"}
|
||||
|
||||
if name == "message-type" or (isinstance(name, int) and name == 53):
|
||||
msg_type = val
|
||||
elif name == "hostname" or (isinstance(name, int) and name == 12):
|
||||
hostname = val.decode("utf-8", "ignore") if isinstance(val, bytes) else str(val)
|
||||
elif name == "server_id" or (isinstance(name, int) and name == 54):
|
||||
server_id = val
|
||||
elif name == "subnet_mask" or (isinstance(name, int) and name == 1):
|
||||
options["subnet_mask"] = val
|
||||
elif name in ("router",) or (isinstance(name, int) and name == 3):
|
||||
options["router"] = val
|
||||
elif name == "domain_name_server" or (isinstance(name, int) and name == 6):
|
||||
options["dns"] = val
|
||||
elif name == "domain_name" or (isinstance(name, int) and name == 15):
|
||||
options["domain"] = val
|
||||
elif name == "lease_time" or (isinstance(name, int) and name == 51):
|
||||
options["lease_time"] = val
|
||||
elif name == "parameter_request_list" or (isinstance(name, int) and name == 55):
|
||||
options["param_req"] = val
|
||||
|
||||
if msg_type is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
client_mac_raw = bp.chaddr
|
||||
except Exception:
|
||||
client_mac_raw = bytes(16)
|
||||
|
||||
client_mac = ":".join(f"{b:02x}" for b in client_mac_raw[:6])
|
||||
|
||||
try:
|
||||
ip_layer = pkt[IP]
|
||||
except Exception:
|
||||
ip_layer = None
|
||||
|
||||
try:
|
||||
ether = pkt[Ether]
|
||||
src_mac = ether.src
|
||||
except Exception:
|
||||
src_mac = client_mac
|
||||
|
||||
# BOOTP fields
|
||||
yiaddr = bp.yiaddr or "0.0.0.0"
|
||||
siaddr = bp.siaddr or "0.0.0.0"
|
||||
giaddr = bp.giaddr or "0.0.0.0"
|
||||
ciaddr = bp.ciaddr or "0.0.0.0"
|
||||
|
||||
if ip_layer:
|
||||
src_ip = ip_layer.src
|
||||
dst_ip = ip_layer.dst
|
||||
else:
|
||||
src_ip = siaddr
|
||||
dst_ip = "255.255.255.255"
|
||||
|
||||
return DHCPMessage(
|
||||
msg_type=msg_type,
|
||||
xid=bp.xid,
|
||||
client_mac=client_mac,
|
||||
client_ip=ciaddr,
|
||||
your_ip=yiaddr,
|
||||
server_ip=siaddr,
|
||||
server_id=server_id,
|
||||
giaddr=giaddr,
|
||||
ciaddr=ciaddr,
|
||||
options=options,
|
||||
hostname=hostname,
|
||||
src_mac=src_mac,
|
||||
src_ip=src_ip,
|
||||
dst_ip=dst_ip,
|
||||
)
|
||||
|
||||
|
||||
def build_offer(
|
||||
req: DHCPMessage,
|
||||
offer_ip: str,
|
||||
lease_seconds: int,
|
||||
server_ip: str,
|
||||
router: str = "",
|
||||
dns: list = None,
|
||||
subnet_mask: str = "255.255.255.0",
|
||||
domain: str = "",
|
||||
) -> object:
|
||||
"""Build a scapy OFFER packet to reply to *req*."""
|
||||
dns = dns or []
|
||||
xid = req.xid
|
||||
client_mac = req.client_mac
|
||||
# Convert MAC hex to bytes
|
||||
mac_bytes = bytes.fromhex(client_mac.replace(":", ""))[:6]
|
||||
chaddr = mac_bytes + bytes(10)
|
||||
|
||||
options = [
|
||||
("message-type", DHCPOFFER),
|
||||
("server_id", server_ip),
|
||||
("subnet_mask", subnet_mask),
|
||||
("lease_time", lease_seconds),
|
||||
("router", router),
|
||||
]
|
||||
if dns:
|
||||
options.append(("domain_name_server", dns))
|
||||
if domain:
|
||||
options.append(("domain_name", domain))
|
||||
|
||||
dst_ip = "255.255.255.255" # broadcast by default
|
||||
if req.giaddr and req.giaddr != "0.0.0.0":
|
||||
dst_ip = req.giaddr # relay agent
|
||||
|
||||
pkt = (
|
||||
Ether(dst="ff:ff:ff:ff:ff:ff")
|
||||
/ IP(src=server_ip, dst=dst_ip)
|
||||
/ UDP(sport=67, dport=68)
|
||||
/ BOOTP(
|
||||
op=2, # reply
|
||||
xid=xid,
|
||||
chaddr=chaddr,
|
||||
yiaddr=offer_ip,
|
||||
siaddr=server_ip,
|
||||
giaddr=req.giaddr,
|
||||
)
|
||||
/ DHCP(options=options)
|
||||
)
|
||||
return pkt
|
||||
|
||||
|
||||
def build_ack(
|
||||
req: DHCPMessage,
|
||||
offered_ip: str,
|
||||
lease_seconds: int,
|
||||
server_ip: str,
|
||||
router: str = "",
|
||||
dns: list = None,
|
||||
subnet_mask: str = "255.255.255.0",
|
||||
domain: str = "",
|
||||
) -> object:
|
||||
"""Build a scapy ACK packet. Same structure as OFFER but message-type=ACK."""
|
||||
dns = dns or []
|
||||
xid = req.xid
|
||||
client_mac = req.client_mac
|
||||
mac_bytes = bytes.fromhex(client_mac.replace(":", ""))[:6]
|
||||
chaddr = mac_bytes + bytes(10)
|
||||
|
||||
options = [
|
||||
("message-type", DHCPACK),
|
||||
("server_id", server_ip),
|
||||
("subnet_mask", subnet_mask),
|
||||
("lease_time", lease_seconds),
|
||||
("router", router),
|
||||
]
|
||||
if dns:
|
||||
options.append(("domain_name_server", dns))
|
||||
if domain:
|
||||
options.append(("domain_name", domain))
|
||||
|
||||
dst_ip = "255.255.255.255"
|
||||
if req.giaddr and req.giaddr != "0.0.0.0":
|
||||
dst_ip = req.giaddr
|
||||
|
||||
pkt = (
|
||||
Ether(dst="ff:ff:ff:ff:ff:ff")
|
||||
/ IP(src=server_ip, dst=dst_ip)
|
||||
/ UDP(sport=67, dport=68)
|
||||
/ BOOTP(
|
||||
op=2,
|
||||
xid=xid,
|
||||
chaddr=chaddr,
|
||||
yiaddr=offered_ip,
|
||||
siaddr=server_ip,
|
||||
giaddr=req.giaddr,
|
||||
)
|
||||
/ DHCP(options=options)
|
||||
)
|
||||
return pkt
|
||||
|
||||
|
||||
def build_nak(req: DHCPMessage, server_ip: str) -> object:
|
||||
"""Build a NAK to reject a request."""
|
||||
xid = req.xid
|
||||
client_mac = req.client_mac
|
||||
mac_bytes = bytes.fromhex(client_mac.replace(":", ""))[:6]
|
||||
chaddr = mac_bytes + bytes(10)
|
||||
|
||||
options = [
|
||||
("message-type", DHCPNAK),
|
||||
("server_id", server_ip),
|
||||
]
|
||||
dst_ip = "255.255.255.255"
|
||||
if req.giaddr and req.giaddr != "0.0.0.0":
|
||||
dst_ip = req.giaddr
|
||||
|
||||
pkt = (
|
||||
Ether(dst="ff:ff:ff:ff:ff:ff")
|
||||
/ IP(src=server_ip, dst=dst_ip)
|
||||
/ UDP(sport=67, dport=68)
|
||||
/ BOOTP(
|
||||
op=2,
|
||||
xid=xid,
|
||||
chaddr=chaddr,
|
||||
yiaddr="0.0.0.0",
|
||||
siaddr=server_ip,
|
||||
giaddr=req.giaddr,
|
||||
)
|
||||
/ DHCP(options=options)
|
||||
)
|
||||
return pkt
|
||||
|
||||
|
||||
def build_release_ack(req: DHCPMessage, server_ip: str) -> object:
|
||||
"""Optional lightweight ACK to a RELEASE (many servers just silently accept)."""
|
||||
return None # Most DHCP servers don't respond to RELEASE
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
"""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 "<default>")
|
||||
|
||||
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 "<default>", 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)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Flask web management panel for WinDHCPD."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from flask import Flask, jsonify, request, render_template
|
||||
|
||||
from ..config import Config, StaticBinding
|
||||
from ..lease import Lease, LeaseDB
|
||||
from ..server import DHCPd
|
||||
|
||||
log = logging.getLogger("dhcpd.web")
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# ── global state (set by run.py) ────────────────────────────────────────
|
||||
_server: Optional[DHCPd] = None
|
||||
_config: Optional[Config] = None
|
||||
_db: Optional[LeaseDB] = None
|
||||
|
||||
|
||||
def init(srv: DHCPd, cfg: Config, db: LeaseDB) -> None:
|
||||
global _server, _config, _db
|
||||
_server = srv
|
||||
_config = cfg
|
||||
_db = db
|
||||
|
||||
|
||||
def run(host: str, port: int) -> None:
|
||||
log.info("Web panel on http://%s:%d", host, port)
|
||||
app.run(host=host, port=port, threaded=True, debug=False)
|
||||
|
||||
|
||||
# ── routes ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _safe_db() -> LeaseDB:
|
||||
return _db # type: ignore
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
cfg = _config # type: ignore
|
||||
db = _safe_db()
|
||||
srv = _server # type: ignore
|
||||
leases = db.list_all() if db else []
|
||||
scopes = cfg.scopes if cfg else []
|
||||
return render_template("index.html",
|
||||
scopes=scopes,
|
||||
leases=leases,
|
||||
stats=srv.stats if srv else {},
|
||||
bindings=cfg.bindings if cfg else [],
|
||||
count_active=db.count_active() if db else 0,
|
||||
count_total=db.count() if db else 0,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/leases")
|
||||
def api_leases():
|
||||
db = _safe_db()
|
||||
leases = db.list_all() if db else []
|
||||
return jsonify([l.to_json() for l in leases])
|
||||
|
||||
|
||||
@app.route("/api/leases/<mac>/release", methods=["POST"])
|
||||
def api_release(mac: str):
|
||||
db = _safe_db()
|
||||
if db:
|
||||
db.release(mac)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/api/leases/prune", methods=["POST"])
|
||||
def api_prune():
|
||||
db = _safe_db()
|
||||
n = 0
|
||||
if db:
|
||||
n = db.prune_expired()
|
||||
return jsonify({"pruned": n})
|
||||
|
||||
|
||||
@app.route("/api/scopes")
|
||||
def api_scopes():
|
||||
cfg = _config # type: ignore
|
||||
out = []
|
||||
for sc in cfg.scopes:
|
||||
out.append({
|
||||
"name": sc.name,
|
||||
"network": sc.network,
|
||||
"start_ip": str(sc.start_ip),
|
||||
"end_ip": str(sc.end_ip),
|
||||
"subnet_mask": sc.subnet_mask,
|
||||
"router": sc.router,
|
||||
"dns_servers": sc.dns_servers,
|
||||
"lease_seconds": sc.lease_seconds,
|
||||
"enable": sc.enable,
|
||||
})
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@app.route("/api/stats")
|
||||
def api_stats():
|
||||
srv = _server # type: ignore
|
||||
db = _safe_db()
|
||||
stats: dict = {}
|
||||
if srv:
|
||||
stats.update(srv.stats)
|
||||
if db:
|
||||
stats["leases_total"] = db.count()
|
||||
stats["leases_active"] = db.count_active()
|
||||
return jsonify(stats)
|
||||
|
||||
|
||||
@app.route("/api/bindings")
|
||||
def api_bindings():
|
||||
cfg = _config # type: ignore
|
||||
return jsonify([{"mac": b.mac_display, "ip": b.ip,
|
||||
"scope": b.scope_name, "hostname": b.hostname}
|
||||
for b in cfg.bindings])
|
||||
@@ -0,0 +1,150 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>WinDHCPD 管理面板</title>
|
||||
<style>
|
||||
:root { --bg: #0f1117; --card: #1a1d27; --border: #2a2d3a; --text: #e2e4ea; --muted: #8a8d9a; --accent: #4f8cff; --green: #3fb950; --red: #f85149; --yellow: #d29922; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); padding: 24px; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 4px; }
|
||||
.sub { color: var(--muted); font-size: 0.85rem; margin-bottom: 24px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
||||
.stat-card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
|
||||
.stat-card .v { font-size: 1.8rem; font-weight: 700; color: var(--accent); }
|
||||
.stat-card .l { font-size: 0.78rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
h2 { font-size: 1rem; margin-bottom: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.table-wrap { background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: auto; margin-bottom: 24px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
th { color: var(--muted); font-weight: 600; position: sticky; top: 0; background: var(--card); }
|
||||
tr:hover { background: rgba(255,255,255,0.03); }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 0.75rem; font-weight: 600; }
|
||||
.badge.green { background: rgba(63,185,80,0.15); color: var(--green); }
|
||||
.badge.red { background: rgba(248,81,73,0.15); color: var(--red); }
|
||||
.badge.yellow { background: rgba(210,153,34,0.15); color: var(--yellow); }
|
||||
.btn { display: inline-block; padding: 4px 10px; font-size: 0.78rem; border-radius: 4px; border: 1px solid var(--border); background: var(--card); color: var(--text); cursor: pointer; }
|
||||
.btn.danger { color: var(--red); border-color: var(--red); }
|
||||
.scopes { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
||||
.scope-card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 14px; }
|
||||
.scope-card h3 { font-size: 0.95rem; margin-bottom: 6px; }
|
||||
.scope-card .meta { font-size: 0.8rem; color: var(--muted); line-height: 1.7; }
|
||||
.scope-card .meta span { color: var(--text); }
|
||||
code { background: rgba(255,255,255,0.06); padding: 1px 4px; border-radius: 3px; font-size: 0.82rem; }
|
||||
.actions { margin-bottom: 24px; }
|
||||
#auto-refresh { margin-top: 8px; font-size: 0.8rem; color: var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>🖥 WinDHCPD 管理面板</h1>
|
||||
<p class="sub">Windows DHCP 服务器 — 实时监控</p>
|
||||
|
||||
<div class="grid">
|
||||
<div class="stat-card"><div class="v" id="s-discover">{{ stats.get("DISCOVER", 0) }}</div><div class="l">DISCOVER</div></div>
|
||||
<div class="stat-card"><div class="v" id="s-request">{{ stats.get("REQUEST", 0) }}</div><div class="l">REQUEST</div></div>
|
||||
<div class="stat-card"><div class="v" id="s-lease-total">{{ count_total }}</div><div class="l">租约总数</div></div>
|
||||
<div class="stat-card"><div class="v" id="s-lease-active">{{ count_active }}</div><div class="l">活跃租约</div></div>
|
||||
<div class="stat-card"><div class="v" id="s-nak">{{ stats.get("NAK", 0) }}</div><div class="l">NAK (拒绝)</div></div>
|
||||
</div>
|
||||
|
||||
<div class="scopes">
|
||||
{% for sc in scopes %}
|
||||
<div class="scope-card">
|
||||
<h3>{{ sc.name }} {% if not sc.enable %}<span class="badge red">禁用</span>{% endif %}</h3>
|
||||
<div class="meta">
|
||||
网段: <span>{{ sc.network }}</span><br>
|
||||
地址池: <span>{{ sc.start_ip }} → {{ sc.end_ip }}</span><br>
|
||||
网关: <span>{{ sc.router or "—" }}</span><br>
|
||||
DNS: <span>{{ ", ".join(sc.dns_servers) or "—" }}</span><br>
|
||||
租期: <span>{{ sc.lease_seconds }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if bindings %}
|
||||
<h2>静态绑定</h2>
|
||||
<div class="table-wrap">
|
||||
<table><thead><tr><th>MAC</th><th>IP</th><th>作用域</th><th>主机名</th></tr></thead><tbody>
|
||||
{% for b in bindings %}
|
||||
<tr><td><code>{{ b.mac_display }}</code></td><td><code>{{ b.ip }}</code></td><td>{{ b.scope_name or "—" }}</td><td>{{ b.hostname or "—" }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody></table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>租约列表</h2>
|
||||
<div class="table-wrap">
|
||||
<table><thead><tr><th>MAC</th><th>IP</th><th>主机名</th><th>状态</th><th>到期</th><th></th></tr></thead><tbody id="leases">
|
||||
{% for l in leases %}
|
||||
<tr data-mac="{{ l.mac }}">
|
||||
<td><code>{{ l.mac }}</code></td>
|
||||
<td><code>{{ l.ip }}</code></td>
|
||||
<td>{{ l.hostname or "—" }}</td>
|
||||
<td>{% if l.is_active %}<span class="badge green">活跃</span>{% else %}<span class="badge red">已过期</span>{% endif %}</td>
|
||||
<td>{{ l.expires_at }}</td>
|
||||
<td><button class="btn danger" onclick="release('{{ l.mac }}')">释放</button></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody></table>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn" onclick="prune()">🧹 清理过期租约</button>
|
||||
<button class="btn" onclick="refresh()">🔄 刷新</button>
|
||||
<span id="auto-refresh">自动刷新: 5s</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let timer = null;
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [stats, leases, scopes] = await Promise.all([
|
||||
fetch('/api/stats').then(r => r.json()),
|
||||
fetch('/api/leases').then(r => r.json()),
|
||||
fetch('/api/scopes').then(r => r.json()),
|
||||
]);
|
||||
document.getElementById('s-discover').textContent = stats.DISCOVER || 0;
|
||||
document.getElementById('s-request').textContent = stats.REQUEST || 0;
|
||||
document.getElementById('s-lease-total').textContent = stats.leases_total || 0;
|
||||
document.getElementById('s-lease-active').textContent = stats.leases_active || 0;
|
||||
document.getElementById('s-nak').textContent = stats.NAK || 0;
|
||||
|
||||
const tbody = document.getElementById('leases');
|
||||
const now = Date.now() / 1000;
|
||||
tbody.innerHTML = leases.map(l => {
|
||||
const active = l.expires_at > now;
|
||||
return `<tr data-mac="${l.mac}">
|
||||
<td><code>${l.mac}</code></td>
|
||||
<td><code>${l.ip}</code></td>
|
||||
<td>${l.hostname || "—"}</td>
|
||||
<td>${active ? '<span class="badge green">活跃</span>' : '<span class="badge red">已过期</span>'}</td>
|
||||
<td>${new Date(l.expires_at * 1000).toLocaleString()}</td>
|
||||
<td><button class="btn danger" onclick="release('${l.mac}')">释放</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function release(mac) {
|
||||
if (!confirm('确定释放 ' + mac + ' 的租约?')) return;
|
||||
await fetch('/api/leases/' + mac + '/release', {method: 'POST'});
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function prune() {
|
||||
if (!confirm('清理所有已过期租约?')) return;
|
||||
const r = await fetch('/api/leases/prune', {method: 'POST'}).then(x => x.json());
|
||||
alert('已清理 ' + r.pruned + ' 条过期租约');
|
||||
refresh();
|
||||
}
|
||||
|
||||
setInterval(refresh, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
# WinDHCPD 依赖
|
||||
scapy>=2.5.0
|
||||
flask>=3.0
|
||||
pyyaml>=6.0
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WinDHCPD - Windows-native DHCP server
|
||||
======================================
|
||||
Python 3 + Scapy based lightweight DHCP server for Windows.
|
||||
|
||||
Usage:
|
||||
python run.py # uses dhcpd.yaml in cwd
|
||||
python run.py config.yaml # specify config file
|
||||
python run.py config.yaml -i # specify interface
|
||||
python run.py --help
|
||||
|
||||
Web panel: http://localhost:8080
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from dhcpd.config import Config
|
||||
from dhcpd.lease import LeaseDB
|
||||
from dhcpd.server import DHCPd
|
||||
from dhcpd.web import app as web_app
|
||||
|
||||
# ── logging setup ────────────────────────────────────────────────────────
|
||||
|
||||
def setup_logging(level: str = "INFO", log_file: str = "dhcpd.log") -> None:
|
||||
"""Configure logging to both console and file."""
|
||||
numeric = getattr(logging, level.upper(), logging.INFO)
|
||||
fmt = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
# Console handler
|
||||
ch = logging.StreamHandler(sys.stdout)
|
||||
ch.setFormatter(fmt)
|
||||
ch.setLevel(numeric)
|
||||
|
||||
# File handler
|
||||
try:
|
||||
fh = logging.FileHandler(log_file, encoding="utf-8")
|
||||
fh.setFormatter(fmt)
|
||||
fh.setLevel(numeric)
|
||||
except OSError:
|
||||
fh = None
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(numeric)
|
||||
root.addHandler(ch)
|
||||
if fh:
|
||||
root.addHandler(fh)
|
||||
|
||||
# Scapy is chatty
|
||||
logging.getLogger("scapy").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="WinDHCPD - DHCP server for Windows")
|
||||
parser.add_argument("config", nargs="?", default="dhcpd.yaml",
|
||||
help="Path to YAML config file (default: dhcpd.yaml)")
|
||||
parser.add_argument("-i", "--interface", default=None,
|
||||
help="Network interface name to listen on")
|
||||
parser.add_argument("--no-web", action="store_true",
|
||||
help="Disable web management panel")
|
||||
parser.add_argument("--web-port", type=int, default=0,
|
||||
help="Web panel port (overrides config)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load config
|
||||
config_path = args.config
|
||||
if not os.path.exists(config_path):
|
||||
print(f"[ERROR] Config not found: {config_path}")
|
||||
print(f" Create one from dhcpd.yaml.example")
|
||||
sys.exit(1)
|
||||
|
||||
cfg = Config(config_path)
|
||||
setup_logging(cfg.log_level, cfg.log_file)
|
||||
logging.getLogger("dhcpd").info("=" * 60)
|
||||
logging.getLogger("dhcpd").info("WinDHCPD starting")
|
||||
logging.getLogger("dhcpd").info("Config: %s", config_path)
|
||||
logging.getLogger("dhcpd").info("Scopes: %s", ", ".join(s.name for s in cfg.scopes))
|
||||
logging.getLogger("dhcpd").info("Bindings: %d", len(cfg.bindings))
|
||||
|
||||
# Lease database
|
||||
db_path = os.path.join(os.path.dirname(os.path.abspath(config_path)), "leases.db")
|
||||
db = LeaseDB(db_path)
|
||||
logging.getLogger("dhcpd").info("Lease DB: %s (%d leases loaded)",
|
||||
db_path, db.count())
|
||||
|
||||
# Server
|
||||
srv = DHCPd(cfg, db, iface=args.interface)
|
||||
srv.start()
|
||||
|
||||
# Web panel
|
||||
if not args.no_web:
|
||||
web_port = args.web_port or cfg.web_port
|
||||
web_host = cfg.web_host
|
||||
web_app.init(srv, cfg, db)
|
||||
t = threading.Thread(
|
||||
target=web_app.run,
|
||||
args=(web_host, web_port),
|
||||
daemon=True,
|
||||
name="web-panel",
|
||||
)
|
||||
t.start()
|
||||
logging.getLogger("dhcpd").info("Web panel: http://%s:%d", web_host, web_port)
|
||||
|
||||
# Graceful shutdown
|
||||
def shutdown(sig, frame):
|
||||
logging.getLogger("dhcpd").info("Received signal %s, shutting down...", sig)
|
||||
srv.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, shutdown)
|
||||
signal.signal(signal.SIGTERM, shutdown)
|
||||
|
||||
logging.getLogger("dhcpd").info("Server running. Press Ctrl+C to stop.")
|
||||
# Keep main thread alive
|
||||
try:
|
||||
while True:
|
||||
import time as _t
|
||||
_t.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
shutdown(signal.SIGINT, None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user