Initial commit: DHCP Web Manager
Flask-based web UI for managing ISC DHCP server (dhcpd). Features: - subnet / static host binding CRUD via web - listen interface configuration (INTERFACESv4) - active lease query + pool utilization stats - service status, reload/restart, syntax check - user auth + operation logs - automatic backup before each config change - safe change pipeline: write file → dhcpd -t → systemctl restart + is-active verify - error messages include journalctl tail for diagnosis
This commit is contained in:
+518
@@ -0,0 +1,518 @@
|
||||
"""
|
||||
dhcp_ops.py
|
||||
===========
|
||||
对 ISC DHCP 服务 (dhcpd) 的配置读写与租约解析封装。
|
||||
- DHCPD_CONF /etc/dhcp/dhcpd.conf
|
||||
- DHCP_LEASES /var/lib/dhcp/dhcpd.leases
|
||||
- INTERFACES /etc/default/isc-dhcp-server
|
||||
所有改动均经"写文件 -> 备份 -> dhcpd -t 语法检查 -> systemctl reload"流程。
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Tuple
|
||||
|
||||
DHCPD_CONF = "/etc/dhcp/dhcpd.conf"
|
||||
DHCP_LEASES = "/var/lib/dhcp/dhcpd.leases"
|
||||
INTERFACES_CONF = "/etc/default/isc-dhcp-server"
|
||||
BACKUP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backups")
|
||||
SERVICE_NAME = "isc-dhcp-server"
|
||||
|
||||
# 自定义配置区段标记 —— 我们维护的 subnet/host 块全部包含在 BEGIN/END 标记之间,
|
||||
# 保留 dhcpd.conf 顶部原有的 option domain-name 等通用配置不动。
|
||||
BEGIN_MARKER = "# >>> DHCP-WEB-MANAGER BEGIN >>>"
|
||||
END_MARKER = "# <<< DHCP-WEB-MANAGER END <<<"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 数据类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Subnet:
|
||||
network: str # e.g. "192.168.1.0"
|
||||
netmask: str # e.g. "255.255.255.0"
|
||||
routers: str # e.g. "192.168.1.1"
|
||||
dns: str # e.g. "8.8.8.8, 8.8.4.4"
|
||||
range_start: str # e.g. "192.168.1.100"
|
||||
range_end: str # e.g. "192.168.1.200"
|
||||
default_lease: int = 600
|
||||
max_lease: int = 7200
|
||||
domain_name: str = ""
|
||||
authoritative: bool = False
|
||||
|
||||
def validate(self) -> List[str]:
|
||||
"""返回错误信息列表,空表示通过"""
|
||||
errs = []
|
||||
try:
|
||||
net = ipaddress.ip_network(f"{self.network}/{self.netmask}", strict=False)
|
||||
except ValueError as e:
|
||||
errs.append(f"网段/掩码无效: {e}")
|
||||
return errs
|
||||
try:
|
||||
start = ipaddress.ip_address(self.range_start)
|
||||
end = ipaddress.ip_address(self.range_end)
|
||||
except ValueError as e:
|
||||
errs.append(f"地址池范围无效: {e}")
|
||||
return errs
|
||||
if start >= end:
|
||||
errs.append("地址池起始必须小于结束")
|
||||
if start not in net or end not in net:
|
||||
errs.append("地址池必须在 subnet 范围内")
|
||||
try:
|
||||
routers_ip = ipaddress.ip_address(self.routers)
|
||||
except ValueError:
|
||||
errs.append(f"网关地址无效: {self.routers}")
|
||||
else:
|
||||
if routers_ip not in net:
|
||||
errs.append(f"网关 {self.routers} 不在 subnet {self.network}/{self.netmask} 内")
|
||||
for d in [x.strip() for x in self.dns.split(",") if x.strip()]:
|
||||
try:
|
||||
ipaddress.ip_address(d)
|
||||
except ValueError:
|
||||
errs.append(f"DNS 格式错误: {d}")
|
||||
if self.default_lease <= 0 or self.max_lease <= 0:
|
||||
errs.append("租期时间必须为正数")
|
||||
if self.default_lease > self.max_lease:
|
||||
errs.append("默认租期不能大于最大租期")
|
||||
return errs
|
||||
|
||||
def to_conf(self) -> str:
|
||||
"""生成 dhcpd.conf 子网块"""
|
||||
lines = []
|
||||
if self.authoritative:
|
||||
lines.append("authoritative;")
|
||||
lines.append(f"subnet {self.network} netmask {self.netmask} {{")
|
||||
if self.domain_name:
|
||||
lines.append(f" option domain-name \"{self.domain_name}\";")
|
||||
lines.append(f" option routers {self.routers};")
|
||||
dns_list = ", ".join(x.strip() for x in self.dns.split(",") if x.strip())
|
||||
lines.append(f" option subnet-mask {self.netmask};")
|
||||
lines.append(f" option domain-name-servers {dns_list};")
|
||||
lines.append(f" default-lease-time {self.default_lease};")
|
||||
lines.append(f" max-lease-time {self.max_lease};")
|
||||
lines.append(f" range {self.range_start} {self.range_end};")
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Host:
|
||||
name: str # e.g. "printer-hall"
|
||||
mac: str # e.g. "aa:bb:cc:dd:ee:ff"
|
||||
ip: str # e.g. "192.168.1.50"
|
||||
subnet_network: str = "" # 关联 subnet network(可选,不填则不强制匹配)
|
||||
|
||||
def validate(self) -> List[str]:
|
||||
errs = []
|
||||
if not re.match(r"^[A-Za-z0-9_\-\.]{1,63}$", self.name):
|
||||
errs.append("主机名仅允许字母数字、下划线、连字符、点,长度 1-63")
|
||||
if not re.match(r"^([0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}$", self.mac):
|
||||
errs.append(f"MAC 格式错误: {self.mac} (应为 aa:bb:cc:dd:ee:ff)")
|
||||
try:
|
||||
ipaddress.ip_address(self.ip)
|
||||
except ValueError:
|
||||
errs.append(f"IP 无效: {self.ip}")
|
||||
return errs
|
||||
|
||||
def to_conf(self) -> str:
|
||||
return (
|
||||
f"host {self.name} {{\n"
|
||||
f" hardware ethernet {self.mac};\n"
|
||||
f" fixed-address {self.ip};\n"
|
||||
f"}}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Lease:
|
||||
ip: str
|
||||
starts: str # 原始文本
|
||||
ends: str
|
||||
tstp: str # 硬过期
|
||||
mac: str
|
||||
client_hostname: str = ""
|
||||
state: str = "active" # active / abandoned / free
|
||||
binding_state: str = "active"
|
||||
next_event: str = ""
|
||||
raw: str = ""
|
||||
|
||||
def expires_at_dt(self) -> Optional[datetime]:
|
||||
"""解析 ends/tstp 时间为 datetime,无法解析返回 None"""
|
||||
candidates = [self.ends, self.tstp]
|
||||
for raw in candidates:
|
||||
if not raw or raw == "never":
|
||||
continue
|
||||
raw = raw.strip()
|
||||
# ISC 格式: 2024/07/09 14:23:01 或带时区
|
||||
for fmt in ("%Y/%m/%d %H:%M:%S %Z", "%Y/%m/%d %H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S"):
|
||||
try:
|
||||
return datetime.strptime(raw, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
def remaining_seconds(self) -> Optional[int]:
|
||||
dt = self.expires_at_dt()
|
||||
if dt is None:
|
||||
return None
|
||||
return int((dt - datetime.now()).total_seconds())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配置文件读写
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _backup_file(path: str) -> Optional[str]:
|
||||
"""复制文件到 BACKUP_DIR,保留最近 20 份。返回备份路径"""
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
os.makedirs(BACKUP_DIR, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
dst = os.path.join(BACKUP_DIR, f"{os.path.basename(path)}.{ts}.bak")
|
||||
shutil.copy2(path, dst)
|
||||
# 清理老备份
|
||||
backups = sorted(
|
||||
[f for f in os.listdir(BACKUP_DIR) if f.startswith(os.path.basename(path) + ".")],
|
||||
reverse=True,
|
||||
)
|
||||
for old in backups[20:]:
|
||||
try:
|
||||
os.remove(os.path.join(BACKUP_DIR, old))
|
||||
except OSError:
|
||||
pass
|
||||
return dst
|
||||
|
||||
|
||||
def read_conf_header() -> str:
|
||||
"""读取 dhcpd.conf 中 BEGIN_MARKER 之前的部分(保留通用配置)"""
|
||||
if not os.path.exists(DHCPD_CONF):
|
||||
return ""
|
||||
with open(DHCPD_CONF, "r") as f:
|
||||
content = f.read()
|
||||
idx = content.find(BEGIN_MARKER)
|
||||
if idx == -1:
|
||||
return content.rstrip() + "\n"
|
||||
return content[:idx].rstrip() + "\n"
|
||||
|
||||
|
||||
def read_managed_block() -> str:
|
||||
"""读取 BEGIN/END 标记之间的内容(去掉标记本身)"""
|
||||
if not os.path.exists(DHCPD_CONF):
|
||||
return ""
|
||||
with open(DHCPD_CONF, "r") as f:
|
||||
content = f.read()
|
||||
begin = content.find(BEGIN_MARKER)
|
||||
end = content.find(END_MARKER)
|
||||
if begin == -1 or end == -1:
|
||||
return ""
|
||||
return content[begin + len(BEGIN_MARKER):end].strip()
|
||||
|
||||
|
||||
def write_managed_block(subnets: List[Subnet], hosts: List[Host]) -> Tuple[bool, str]:
|
||||
"""
|
||||
把 subnets + hosts 写入 dhcpd.conf,保留原有通用配置。
|
||||
返回 (success, message)
|
||||
"""
|
||||
header = read_conf_header()
|
||||
lines = [header.rstrip(), "", BEGIN_MARKER]
|
||||
if subnets:
|
||||
for s in subnets:
|
||||
lines.append(s.to_conf())
|
||||
lines.append("")
|
||||
if hosts:
|
||||
for h in hosts:
|
||||
lines.append(h.to_conf())
|
||||
lines.append("")
|
||||
lines.append(END_MARKER)
|
||||
lines.append("")
|
||||
new_content = "\n".join(lines)
|
||||
|
||||
backup = _backup_file(DHCPD_CONF)
|
||||
try:
|
||||
with open(DHCPD_CONF, "w") as f:
|
||||
f.write(new_content)
|
||||
except PermissionError as e:
|
||||
return False, f"写入失败(权限不足): {e}"
|
||||
msg = "已写入"
|
||||
if backup:
|
||||
msg += f",备份至 {backup}"
|
||||
return True, msg
|
||||
|
||||
|
||||
def parse_managed_block(block: str) -> Tuple[List[Subnet], List[Host]]:
|
||||
"""从 BEGIN/END 块文本中解析出 subnets 和 hosts 列表"""
|
||||
subnets: List[Subnet] = []
|
||||
hosts: List[Host] = []
|
||||
|
||||
# subnet 块
|
||||
for m in re.finditer(
|
||||
r"subnet\s+(\S+)\s+netmask\s+(\S+)\s*\{([^}]*)\}",
|
||||
block,
|
||||
re.DOTALL,
|
||||
):
|
||||
network, netmask, body = m.group(1), m.group(2), m.group(3)
|
||||
d = {"network": network, "netmask": netmask}
|
||||
for line in body.splitlines():
|
||||
line = line.strip().rstrip(";").strip()
|
||||
if line.startswith("option routers "):
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
d["routers"] = parts[2]
|
||||
elif line.startswith("option domain-name-servers "):
|
||||
parts = line.split(None, 2)
|
||||
if len(parts) >= 3:
|
||||
dns_parts = parts[2].split(",")
|
||||
d["dns"] = ", ".join(p.strip() for p in dns_parts)
|
||||
elif line.startswith("option domain-name ") and not line.startswith("option domain-name-servers"):
|
||||
if "\"" in line:
|
||||
d["domain_name"] = line.split("\"")[1]
|
||||
elif line.startswith("range "):
|
||||
_, start, end = line.split()
|
||||
d["range_start"], d["range_end"] = start, end
|
||||
elif line.startswith("default-lease-time"):
|
||||
try:
|
||||
d["default_lease"] = int(line.split()[1])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
elif line.startswith("max-lease-time"):
|
||||
try:
|
||||
d["max_lease"] = int(line.split()[1])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
# authoritative 在 subnet 外也可能 —— 我们生成时会放到 subnet 块外
|
||||
defaults = {
|
||||
"network": "", "netmask": "", "routers": "", "dns": "",
|
||||
"range_start": "", "range_end": "", "default_lease": 600,
|
||||
"max_lease": 7200, "domain_name": "", "authoritative": False,
|
||||
}
|
||||
subnets.append(Subnet(**{k: d.get(k, defaults[k]) for k in defaults}))
|
||||
|
||||
# authoritative
|
||||
if "authoritative;" in block:
|
||||
if subnets:
|
||||
subnets[0].authoritative = True
|
||||
|
||||
# host 块
|
||||
for m in re.finditer(
|
||||
r"host\s+(\S+)\s*\{([^}]*)\}",
|
||||
block,
|
||||
re.DOTALL,
|
||||
):
|
||||
name = m.group(1)
|
||||
body = m.group(2)
|
||||
d = {"name": name}
|
||||
for line in body.splitlines():
|
||||
line = line.strip().rstrip(";").strip()
|
||||
if line.startswith("hardware ethernet"):
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
d["mac"] = parts[2]
|
||||
elif line.startswith("fixed-address"):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
d["ip"] = parts[1]
|
||||
if "mac" in d and "ip" in d:
|
||||
hosts.append(Host(**d))
|
||||
|
||||
return subnets, hosts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 租约文件解析
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_leases() -> List[Lease]:
|
||||
"""解析 dhcpd.leases,返回所有 lease(含重复的 binding state 变更)"""
|
||||
if not os.path.exists(DHCP_LEASES):
|
||||
return []
|
||||
with open(DHCP_LEASES, "r") as f:
|
||||
content = f.read()
|
||||
leases: List[Lease] = []
|
||||
for m in re.finditer(r"lease\s+(\S+)\s*\{([^}]*)\}", content, re.DOTALL):
|
||||
ip = m.group(1)
|
||||
body = m.group(2)
|
||||
d = {"ip": ip}
|
||||
for line in body.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
key = line.split(None, 1)[0].rstrip(";")
|
||||
val = line.split(None, 1)[1].rstrip(";").strip() if " " in line else ""
|
||||
if key == "starts":
|
||||
d["starts"] = val
|
||||
elif key == "ends":
|
||||
d["ends"] = val
|
||||
elif key == "tstp":
|
||||
d["tstp"] = val
|
||||
elif key == "binding":
|
||||
d["binding_state"] = val.split()[0] if val else "active"
|
||||
d["state"] = val.split()[0] if val else "active"
|
||||
elif key == "next":
|
||||
d["next_event"] = val
|
||||
elif key == "hardware":
|
||||
# hardware ethernet aa:bb:cc:dd:ee:ff
|
||||
parts = val.split()
|
||||
if len(parts) == 2:
|
||||
d["mac"] = parts[1]
|
||||
elif key == "client-hostname":
|
||||
d["client_hostname"] = val.strip('"')
|
||||
d["raw"] = m.group(0)
|
||||
leases.append(Lease(**{k: d.get(k, "") for k in
|
||||
["ip", "starts", "ends", "tstp", "mac",
|
||||
"client_hostname", "state", "binding_state",
|
||||
"next_event", "raw"]}))
|
||||
return leases
|
||||
|
||||
|
||||
def active_leases() -> List[Lease]:
|
||||
"""仅返回 binding state = active 且未过期的 lease"""
|
||||
result = []
|
||||
for lease in parse_leases():
|
||||
if lease.binding_state != "active":
|
||||
continue
|
||||
rem = lease.remaining_seconds()
|
||||
if rem is not None and rem <= 0:
|
||||
continue
|
||||
result.append(lease)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 服务控制 / 语法检查
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_syntax() -> Tuple[bool, str]:
|
||||
"""运行 dhcpd -t 语法检查"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["dhcpd", "-t", "-cf", DHCPD_CONF],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
if r.returncode == 0:
|
||||
return True, out or "语法 OK"
|
||||
return False, out
|
||||
except FileNotFoundError:
|
||||
return False, "未找到 dhcpd 二进制"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "dhcpd -t 超时"
|
||||
|
||||
|
||||
def reload_service() -> Tuple[bool, str]:
|
||||
"""重载 DHCP 服务(不丢连接)。
|
||||
|
||||
isc-dhcp-server.service 是 sysv-init 转 systemd 的,**不支持 reload**,
|
||||
所以直接用 try-reload-or-restart —— 它会优先 reload,不支持就 restart。
|
||||
失败时附带 journalctl 最近日志,便于排查。
|
||||
"""
|
||||
# isc-dhcp-server.service 是 sysv-init 转 systemd 的,**不支持 reload**,
|
||||
# 且 systemctl try-reload-or-restart 对 failed unit 也会返回 0,
|
||||
# 不能反映启动失败。策略:直接 restart + 主动 is-active 检查。
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["systemctl", "restart", SERVICE_NAME],
|
||||
capture_output=True, text=True, timeout=20,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
diag = _last_journal_lines(15)
|
||||
return False, f"restart 命令失败: {out}\n\n--- journalctl -xeu {SERVICE_NAME} (最近) ---\n{diag}".strip()
|
||||
time.sleep(1.5)
|
||||
active = subprocess.run(
|
||||
["systemctl", "is-active", "--quiet", SERVICE_NAME],
|
||||
timeout=5,
|
||||
).returncode == 0
|
||||
if active:
|
||||
return True, "服务已 restart"
|
||||
diag = _last_journal_lines(15)
|
||||
return False, f"restart 命令返回 0 但服务未进入 active 状态。\n\n--- journalctl -xeu {SERVICE_NAME} (最近) ---\n{diag}".strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "systemctl 调用超时"
|
||||
except FileNotFoundError as e:
|
||||
return False, f"未找到 systemctl: {e}"
|
||||
|
||||
|
||||
def restart_service() -> Tuple[bool, str]:
|
||||
"""强制重启 DHCP 服务(与 reload_service 同语义,因本服务不支持 reload)"""
|
||||
return reload_service()
|
||||
|
||||
|
||||
def _last_journal_lines(n: int = 15) -> str:
|
||||
"""获取 service 的最近日志(用于错误诊断)"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["journalctl", "-xeu", SERVICE_NAME, "-n", str(n), "--no-pager"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return r.stdout.strip() or r.stderr.strip()
|
||||
except Exception as e:
|
||||
return f"(读取日志失败: {e})"
|
||||
|
||||
|
||||
def get_interfaces() -> List[str]:
|
||||
"""读取 INTERFACESv4 配置"""
|
||||
if not os.path.exists(INTERFACES_CONF):
|
||||
return []
|
||||
v4 = []
|
||||
for line in open(INTERFACES_CONF):
|
||||
line = line.strip()
|
||||
if line.startswith("INTERFACESv4="):
|
||||
val = line.split("=", 1)[1].strip().strip('"')
|
||||
v4 = [x for x in val.split() if x]
|
||||
return v4
|
||||
|
||||
|
||||
def save_interfaces(ifaces: List[str]) -> Tuple[bool, str]:
|
||||
"""保存 INTERFACESv4 配置。空列表表示监听全部"""
|
||||
if not os.path.exists(INTERFACES_CONF):
|
||||
return False, f"文件不存在: {INTERFACES_CONF}"
|
||||
backup = _backup_file(INTERFACES_CONF)
|
||||
with open(INTERFACES_CONF, "r") as f:
|
||||
lines = f.readlines()
|
||||
new_val = " ".join(ifaces)
|
||||
found = False
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith("INTERFACESv4="):
|
||||
lines[i] = f'INTERFACESv4="{new_val}"\n'
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
lines.append(f'INTERFACESv4="{new_val}"\n')
|
||||
with open(INTERFACES_CONF, "w") as f:
|
||||
f.writelines(lines)
|
||||
msg = "已保存接口配置"
|
||||
if backup:
|
||||
msg += f",备份至 {backup}"
|
||||
return True, msg
|
||||
|
||||
|
||||
def get_service_status() -> Dict:
|
||||
"""返回 dhcpd 服务状态摘要"""
|
||||
info = {
|
||||
"active": False,
|
||||
"status_text": "",
|
||||
"interfaces": get_interfaces(),
|
||||
}
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["systemctl", "is-active", SERVICE_NAME],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
info["active"] = r.stdout.strip() == "active"
|
||||
r2 = subprocess.run(
|
||||
["systemctl", "status", SERVICE_NAME, "--no-pager", "-n", "3"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
info["status_text"] = (r2.stdout or r2.stderr).strip().split("\n", 3)
|
||||
info["status_text"] = "\n".join(info["status_text"][:5])
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
return info
|
||||
Reference in New Issue
Block a user