Files
dhcp-service/dhcp_ops.py
T
DHCP Service Bot 90736253b1 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
2026-07-09 17:09:23 +08:00

518 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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