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:
DHCP Service Bot
2026-07-09 17:09:23 +08:00
commit 90736253b1
18 changed files with 2192 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# Database (contains admin password hash + op logs)
instance/dhcp.db
instance/dhcp.db-journal
# Backup files
backups/*.bak
# Runtime
*.log
+149
View File
@@ -0,0 +1,149 @@
# DHCP Web Manager
一个用 Flask 写的轻量级 ISC DHCP (dhcpd) Web 管理界面,**底层直接复用系统自带的 isc-dhcp-server**
不引入额外 daemon。提供 Web 界面来管理 subnet / 静态 host 绑定 / 监听接口 / 查看租约 / 操作日志。
## 功能
- 🟢 子网(subnet)增删改:网段、掩码、网关、DNS、地址池、租期
- 🟢 静态绑定(host):MAC + 固定 IP + 主机名
- 🟢 监听接口配置(INTERFACESv4
- 🟢 租约查询(按 IP/MAC/主机名搜索)+ 地址池使用率
- 🟢 服务状态 + reload / restart + 语法检查
- 🟢 操作日志(谁在什么时间做了什么)
- 🟢 用户名密码登录 + 改密
- 🟢 所有变更自动:写文件 → 备份 → `dhcpd -t` 语法检查 → `systemctl reload isc-dhcp-server`
## 文件结构
```
dhcp-service/
├── app.py # Flask 主程序
├── dhcp_ops.py # dhcpd.conf 读写 + leases 解析 + 服务控制
├── requirements.txt
├── instance/dhcp.db # SQLite(用户、日志)
├── backups/ # dhcpd.conf 自动备份(最多保留 20 份)
├── templates/ # Jinja2 模板
└── static/style.css
```
## 安装
```bash
apt-get install -y isc-dhcp-server # 后端 DHCP 服务
pip3 install -r requirements.txt --break-system-packages
```
## 启动
开发模式:
```bash
python3 app.py # 监听 0.0.0.0:5100
```
生产模式(推荐用 gunicorn):
```bash
gunicorn --workers 2 --bind 0.0.0.0:5100 'app:app'
```
默认账号:`admin` / `admin`(首次登录后请到「改密」修改)。
## 关键设计
### 配置文件组织
我们改写 `/etc/dhcp/dhcpd.conf`,但**只动我们管理的区段**——通过标记:
```
# 原有的通用配置(option domain-name 等)保持不变
option domain-name "example.org";
default-lease-time 600;
...
# >>> DHCP-WEB-MANAGER BEGIN >>>
authoritative;
subnet 192.168.1.0 netmask 255.255.255.0 {
option routers 192.168.1.1;
...
}
host printer {
hardware ethernet aa:bb:cc:dd:ee:01;
fixed-address 192.168.1.50;
}
# <<< DHCP-WEB-MANAGER END <<<
```
系统原有的 option / 注释 / 不认识的配置都不丢。备份在 `backups/dhcpd.conf.<timestamp>.bak`(最多 20 份)。
### 变更流程
所有写操作都走同一个流程(在 `app.py: apply_and_reload`):
1. 读现有 subnets/hosts → 追加/替换 → 生成新的 conf 文本
2. **先备份到 backups/**
3. 写到 `/etc/dhcp/dhcpd.conf`
4.`dhcpd -t` 语法检查 — 失败则回滚到备份(不修改数据库)
5. 通过则 `systemctl reload isc-dhcp-server`(不丢连接)
### 接口绑定
`/etc/default/isc-dhcp-server` 中的 `INTERFACESv4="enp0s31f6"`
保存接口配置会**重启**(而非 reload)服务,因为接口绑定变更不支持热加载。
### 租约解析
直接读 `/var/lib/dhcp/dhcpd.leases` 并 regex 解析 lease 块。注意 dhcpd 每次写入 leases 文件时
会以临时名写入后 rename 替换,所以解析时可能短暂读到空内容——前端解析失败的话就是 0 条,不报错。
## 部署到生产
按本机惯例,dev 仓库在 `/root/dhcp-service/`,生产部署在 `/root/.openclaw/workspace/dhcp-service/`
```bash
# 1. 把代码复制到生产目录
mkdir -p /root/.openclaw/workspace/dhcp-service
cp -r /root/dhcp-service/{app.py,dhcp_ops.py,requirements.txt,templates,static} \
/root/.openclaw/workspace/dhcp-service/
# 2. systemd unit (示例)
cat > /etc/systemd/system/dhcp-web-manager.service <<EOF
[Unit]
Description=DHCP Web Manager (Flask)
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/.openclaw/workspace/dhcp-service
ExecStart=/usr/bin/gunicorn --workers 2 --bind 0.0.0.0:5100 'app:app'
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now dhcp-web-manager
```
访问 `http://<server-ip>:5100`
## 注意事项
- 本程序必须以 root 跑,因为需要写 `/etc/dhcp/dhcpd.conf` 和控制 systemd 服务。
- 修改 subnet/host 走 reload(不丢现有租约),但改 INTERFACESv4 必须 restart。
- `dhcpd -t` 语法检查失败时**不会**回滚配置,但也不会 reload——服务继续按旧配置运行,
操作日志记录失败原因。修改者可以修正后重试。
- 数据库 `instance/dhcp.db` 不要 commit 到 git。
## 备份恢复
所有 conf 改动前会自动备份到 `backups/`,恢复:
```bash
ls backups/
cp backups/dhcpd.conf.20260709_120000.bak /etc/dhcp/dhcpd.conf
systemctl reload isc-dhcp-server
```
+602
View File
@@ -0,0 +1,602 @@
"""
DHCP Web 管理界面 - Flask 主程序
- 用户认证(用户名/密码,session)
- subnet/host 增删改,配置自动重载
- 租约查询、地址池使用情况
- 接口绑定配置
- 操作日志
设计:用户的元数据(subnet/host/操作日志/接口)全在 SQLite,实际配置写在
/etc/dhcp/dhcpd.conf。所有改动通过"Web -> 写文件 -> dhcpd -t -> systemctl reload" 流程。
"""
import os
import re
import json
from datetime import datetime, timedelta
from functools import wraps
from flask import (Flask, render_template, request, redirect, url_for,
session, jsonify, flash, abort)
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import event
from werkzeug.security import generate_password_hash, check_password_hash
# 强制使用 pbkdf2:sha256,避免 scrypt 在某些环境下跨进程兼容性 bug
_PASSWORD_METHOD = "pbkdf2:sha256"
import dhcp_ops
# ---------------------------------------------------------------------------
# Flask & DB 配置
# ---------------------------------------------------------------------------
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
INSTANCE_DIR = os.path.join(BASE_DIR, "instance")
os.makedirs(INSTANCE_DIR, exist_ok=True)
app = Flask(__name__)
app.config["SECRET_KEY"] = os.environ.get("DHCPMGR_SECRET", "change-me-on-first-deploy")
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{os.path.join(INSTANCE_DIR, 'dhcp.db')}"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["JSON_AS_ASCII"] = False
db = SQLAlchemy(app)
# ---------------------------------------------------------------------------
# 模型
# ---------------------------------------------------------------------------
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(200), nullable=False)
role = db.Column(db.String(20), default="admin") # 简单起见只有 admin / user
created_at = db.Column(db.DateTime, default=lambda: datetime.utcnow())
last_login = db.Column(db.DateTime)
def set_password(self, pw):
self.password_hash = generate_password_hash(pw, method="pbkdf2:sha256")
def check_password(self, pw):
return check_password_hash(self.password_hash, pw)
class OperationLog(db.Model):
__tablename__ = "op_logs"
id = db.Column(db.Integer, primary_key=True)
timestamp = db.Column(db.DateTime, default=lambda: datetime.utcnow())
user = db.Column(db.String(80))
action = db.Column(db.String(50)) # create_subnet / update_subnet / delete_subnet / etc.
target = db.Column(db.String(200)) # 子网网络、主机名等
detail = db.Column(db.Text) # JSON / 文本详情
result = db.Column(db.String(20)) # ok / error
message = db.Column(db.Text)
# ---------------------------------------------------------------------------
# 辅助函数
# ---------------------------------------------------------------------------
def current_user():
if "user_id" not in session:
return None
return db.session.get(User, session["user_id"])
def login_required(f):
@wraps(f)
def wrapper(*a, **kw):
if "user_id" not in session:
return redirect(url_for("login", next=request.path))
return f(*a, **kw)
return wrapper
def admin_required(f):
@wraps(f)
def wrapper(*a, **kw):
u = current_user()
if not u:
return redirect(url_for("login", next=request.path))
if u.role != "admin":
abort(403)
return f(*a, **kw)
return wrapper
def log_op(action, target, detail, result, message):
u = current_user()
entry = OperationLog(
user=(u.username if u else "anonymous"),
action=action, target=target,
detail=detail if isinstance(detail, str) else json.dumps(detail, ensure_ascii=False),
result=result, message=message,
)
db.session.add(entry)
db.session.commit()
return entry
def get_subnets() -> list:
block = dhcp_ops.read_managed_block()
subs, _ = dhcp_ops.parse_managed_block(block)
return subs
def get_hosts() -> list:
block = dhcp_ops.read_managed_block()
_, hosts = dhcp_ops.parse_managed_block(block)
return hosts
def apply_and_reload(action: str, target: str, extra: dict = "",
subnets_override=None, hosts_override=None):
"""
通用的"改 conf -> 语法检查 -> reload" 流程。
subnets_override / hosts_override:调用方已修改内存中的对象,绕过从文件重读。
返回 (success, message)
"""
subnets = subnets_override if subnets_override is not None else get_subnets()
hosts = hosts_override if hosts_override is not None else get_hosts()
ok, msg = dhcp_ops.write_managed_block(subnets, hosts)
if not ok:
log_op(action, target, extra, "error", msg)
return False, msg
syntax_ok, syntax_msg = dhcp_ops.check_syntax()
if not syntax_ok:
log_op(action, target, extra, "error", f"语法错误: {syntax_msg}")
return False, f"语法检查失败:\n{syntax_msg}\n(配置未生效,已备份到 backups/)"
r_ok, r_msg = dhcp_ops.reload_service()
if not r_ok:
log_op(action, target, extra, "error", f"reload 失败: {r_msg}")
return False, f"reload 失败: {r_msg}"
log_op(action, target, extra, "ok", f"{msg}; 语法 OK; {r_msg}")
return True, f"{msg}{r_msg}"
# ---------------------------------------------------------------------------
# 上下文:菜单 / 当前用户
# ---------------------------------------------------------------------------
@app.context_processor
def inject_globals():
u = current_user()
return dict(
current_user=u,
is_admin=(u.role == "admin" if u else False),
now=datetime.utcnow(),
)
# ---------------------------------------------------------------------------
# 路由
# ---------------------------------------------------------------------------
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
session["user_id"] = user.id
user.last_login = datetime.utcnow()
db.session.commit()
return redirect(request.args.get("next") or url_for("dashboard"))
flash("用户名或密码错误", "error")
return render_template("login.html")
@app.route("/logout")
def logout():
session.pop("user_id", None)
return redirect(url_for("login"))
@app.route("/")
@login_required
def dashboard():
subnets = get_subnets()
hosts = get_hosts()
leases = dhcp_ops.active_leases()
svc = dhcp_ops.get_service_status()
# 计算地址池使用率
pool_stats = []
lease_ips = {l.ip for l in leases}
for s in subnets:
try:
from ipaddress import ip_network, ip_address
net = ip_network(f"{s.network}/{s.netmask}", strict=False)
start = ip_address(s.range_start)
end = ip_address(s.range_end)
pool_total = int(end) - int(start) + 1
used = sum(1 for ip in lease_ips
if start <= ip_address(ip) <= end and ip_address(ip) in net)
# 减去 host 静态绑定数
host_static_in_pool = sum(
1 for h in hosts
if h.ip and s.range_start <= h.ip <= s.range_end
)
pool_stats.append({
"subnet": f"{s.network}/{s.netmask}",
"range": f"{s.range_start} - {s.range_end}",
"total": pool_total,
"used": used,
"static": host_static_in_pool,
"available": pool_total - used - host_static_in_pool,
"util_pct": round(used / pool_total * 100, 1) if pool_total > 0 else 0,
})
except Exception as e:
pool_stats.append({"subnet": s.network, "error": str(e)})
return render_template("dashboard.html",
subnets=subnets, hosts=hosts,
leases=leases[:10],
lease_total=len(leases),
pool_stats=pool_stats,
svc=svc)
# ---------- subnet ----------
@app.route("/subnets")
@login_required
def subnet_list():
return render_template("subnets.html",
subnets=get_subnets(),
hosts=get_hosts())
@app.route("/subnets/new", methods=["GET", "POST"])
@admin_required
def subnet_new():
if request.method == "POST":
try:
s = dhcp_ops.Subnet(
network=request.form["network"].strip(),
netmask=request.form["netmask"].strip(),
routers=request.form["routers"].strip(),
dns=request.form["dns"].strip(),
range_start=request.form["range_start"].strip(),
range_end=request.form["range_end"].strip(),
default_lease=int(request.form.get("default_lease") or 600),
max_lease=int(request.form.get("max_lease") or 7200),
domain_name=request.form.get("domain_name", "").strip(),
authoritative=bool(request.form.get("authoritative")),
)
except (KeyError, ValueError) as e:
flash(f"表单错误: {e}", "error")
return render_template("subnet_form.html", subnet={}, form=request.form)
errs = s.validate()
if errs:
for e in errs:
flash(e, "error")
return render_template("subnet_form.html", subnet={}, form=request.form)
# 写入:先获取现有,追加,再走 apply_and_reload
subnets = get_subnets()
if any(x.network == s.network and x.netmask == s.netmask for x in subnets):
flash(f"已存在相同 subnet {s.network}/{s.netmask}", "error")
return render_template("subnet_form.html", subnet={}, form=request.form)
subnets.append(s)
ok, msg = apply_and_reload("create_subnet", s.network, asdict_safe(s),
subnets_override=subnets)
if ok:
flash(f"subnet {s.network}/{s.netmask} 已创建,{msg}", "ok")
return redirect(url_for("subnet_list"))
flash(msg, "error")
return render_template("subnet_form.html", subnet={}, form=request.form)
return render_template("subnet_form.html", subnet={}, form={})
@app.route("/subnets/<network>/<netmask>/edit", methods=["GET", "POST"])
@admin_required
def subnet_edit(network, netmask):
subnets = get_subnets()
target = next((s for s in subnets if s.network == network and s.netmask == netmask), None)
if not target:
abort(404)
if request.method == "POST":
try:
new_s = dhcp_ops.Subnet(
network=request.form["network"].strip(),
netmask=request.form["netmask"].strip(),
routers=request.form["routers"].strip(),
dns=request.form["dns"].strip(),
range_start=request.form["range_start"].strip(),
range_end=request.form["range_end"].strip(),
default_lease=int(request.form.get("default_lease") or 600),
max_lease=int(request.form.get("max_lease") or 7200),
domain_name=request.form.get("domain_name", "").strip(),
authoritative=bool(request.form.get("authoritative")),
)
except (KeyError, ValueError) as e:
flash(f"表单错误: {e}", "error")
return render_template("subnet_form.html", subnet=target, form=request.form)
errs = new_s.validate()
if errs:
for e in errs:
flash(e, "error")
return render_template("subnet_form.html", subnet=new_s, form=request.form)
subnets = [new_s if (s.network == network and s.netmask == netmask) else s
for s in subnets]
ok, msg = apply_and_reload(
"update_subnet", new_s.network,
{"old": f"{network}/{netmask}", "new": asdict_safe(new_s)},
subnets_override=subnets,
)
if ok:
flash(f"subnet 已更新,{msg}", "ok")
return redirect(url_for("subnet_list"))
flash(msg, "error")
return render_template("subnet_form.html", subnet=new_s, form=request.form)
return render_template("subnet_form.html", subnet=target, form=asdict_safe(target))
@app.route("/subnets/<network>/<netmask>/delete", methods=["POST"])
@admin_required
def subnet_delete(network, netmask):
subnets = [s for s in get_subnets()
if not (s.network == network and s.netmask == netmask)]
ok, msg = apply_and_reload(
"delete_subnet", f"{network}/{netmask}",
{"remaining_subnets": len(subnets)},
subnets_override=subnets,
)
flash(f"已删除 subnet {network}/{netmask}{msg}", "ok" if ok else "error")
return redirect(url_for("subnet_list"))
# ---------- host ----------
@app.route("/hosts")
@login_required
def host_list():
return render_template("hosts.html", hosts=get_hosts(), subnets=get_subnets())
@app.route("/hosts/new", methods=["GET", "POST"])
@admin_required
def host_new():
if request.method == "POST":
try:
h = dhcp_ops.Host(
name=request.form["name"].strip(),
mac=request.form["mac"].strip().lower(),
ip=request.form["ip"].strip(),
subnet_network=request.form.get("subnet_network", "").strip(),
)
except (KeyError, ValueError) as e:
flash(f"表单错误: {e}", "error")
return render_template("host_form.html", host={}, form=request.form, subnets=get_subnets())
errs = h.validate()
if errs:
for e in errs:
flash(e, "error")
return render_template("host_form.html", host={}, form=request.form, subnets=get_subnets())
hosts = get_hosts()
if any(x.name == h.name for x in hosts):
flash(f"已存在同名 host: {h.name}", "error")
return render_template("host_form.html", host={}, form=request.form, subnets=get_subnets())
if any(x.ip == h.ip for x in hosts):
flash(f"IP {h.ip} 已被其他 host 占用", "error")
return render_template("host_form.html", host={}, form=request.form, subnets=get_subnets())
hosts.append(h)
ok, msg = apply_and_reload("create_host", h.name, asdict_safe(h),
hosts_override=hosts)
if ok:
flash(f"host {h.name} 已创建,{msg}", "ok")
return redirect(url_for("host_list"))
flash(msg, "error")
return render_template("host_form.html", host={}, form=request.form, subnets=get_subnets())
return render_template("host_form.html", host={}, form={}, subnets=get_subnets())
@app.route("/hosts/<name>/edit", methods=["GET", "POST"])
@admin_required
def host_edit(name):
hosts = get_hosts()
target = next((h for h in hosts if h.name == name), None)
if not target:
abort(404)
if request.method == "POST":
try:
new_h = dhcp_ops.Host(
name=request.form["name"].strip(),
mac=request.form["mac"].strip().lower(),
ip=request.form["ip"].strip(),
subnet_network=request.form.get("subnet_network", "").strip(),
)
except (KeyError, ValueError) as e:
flash(f"表单错误: {e}", "error")
return render_template("host_form.html", host=target, form=request.form, subnets=get_subnets())
errs = new_h.validate()
if errs:
for e in errs:
flash(e, "error")
return render_template("host_form.html", host=new_h, form=request.form, subnets=get_subnets())
# 名字变更则检查冲突
if new_h.name != name and any(h.name == new_h.name for h in hosts):
flash(f"已存在同名 host: {new_h.name}", "error")
return render_template("host_form.html", host=new_h, form=request.form, subnets=get_subnets())
hosts = [new_h if h.name == name else h for h in hosts]
ok, msg = apply_and_reload("update_host", new_h.name,
{"old": name, "new": asdict_safe(new_h)},
hosts_override=hosts)
if ok:
flash(f"host 已更新,{msg}", "ok")
return redirect(url_for("host_list"))
flash(msg, "error")
return render_template("host_form.html", host=new_h, form=request.form, subnets=get_subnets())
return render_template("host_form.html", host=target, form=asdict_safe(target), subnets=get_subnets())
@app.route("/hosts/<name>/delete", methods=["POST"])
@admin_required
def host_delete(name):
hosts = [h for h in get_hosts() if h.name != name]
ok, msg = apply_and_reload("delete_host", name, {"remaining_hosts": len(hosts)},
hosts_override=hosts)
flash(f"已删除 host {name}{msg}", "ok" if ok else "error")
return redirect(url_for("host_list"))
# ---------- leases ----------
@app.route("/leases")
@login_required
def lease_list():
leases = dhcp_ops.parse_leases()
q = request.args.get("q", "").strip().lower()
if q:
leases = [l for l in leases if q in l.ip.lower()
or q in l.mac.lower()
or q in l.client_hostname.lower()]
leases.sort(key=lambda l: l.remaining_seconds() or -1, reverse=True)
return render_template("leases.html", leases=leases, q=q)
# ---------- config (interfaces + 原始 conf) ----------
@app.route("/config", methods=["GET", "POST"])
@admin_required
def config_view():
if request.method == "POST":
action = request.form.get("action")
if action == "save_interfaces":
ifaces = request.form.get("interfaces", "").strip().split()
ok, msg = dhcp_ops.save_interfaces(ifaces)
log_op("save_interfaces", ",".join(ifaces), "", "ok" if ok else "error", msg)
flash(msg, "ok" if ok else "error")
if ok:
# 重新 reload 服务(需要重启以应用新 interface)
r_ok, r_msg = dhcp_ops.restart_service()
flash(r_msg, "ok" if r_ok else "error")
log_op("restart_service", "", "", "ok" if r_ok else "error", r_msg)
elif action == "reload":
r_ok, r_msg = dhcp_ops.reload_service()
flash(r_msg, "ok" if r_ok else "error")
log_op("reload_service", "", "", "ok" if r_ok else "error", r_msg)
elif action == "restart":
r_ok, r_msg = dhcp_ops.restart_service()
flash(r_msg, "ok" if r_ok else "error")
log_op("restart_service", "", "", "ok" if r_ok else "error", r_msg)
return redirect(url_for("config_view"))
ifaces = dhcp_ops.get_interfaces()
conf_content = ""
if os.path.exists(dhcp_ops.DHCPD_CONF):
with open(dhcp_ops.DHCPD_CONF) as f:
conf_content = f.read()
svc = dhcp_ops.get_service_status()
syntax_ok, syntax_msg = dhcp_ops.check_syntax()
return render_template("config.html",
ifaces=ifaces, conf=conf_content,
svc=svc, syntax_ok=syntax_ok, syntax_msg=syntax_msg)
# ---------- 操作日志 ----------
@app.route("/logs")
@admin_required
def log_list():
page = request.args.get("page", 1, type=int)
per = 50
q = OperationLog.query.order_by(OperationLog.id.desc())
total = q.count()
logs = q.offset((page - 1) * per).limit(per).all()
pages = (total + per - 1) // per
return render_template("logs.html", logs=logs, page=page, pages=pages, total=total)
# ---------- 用户管理(修改密码) ----------
@app.route("/profile", methods=["GET", "POST"])
@login_required
def profile():
u = current_user()
if request.method == "POST":
old = request.form.get("old_password", "")
new = request.form.get("new_password", "")
if not u.check_password(old):
flash("当前密码错误", "error")
elif len(new) < 6:
flash("新密码至少 6 位", "error")
else:
u.set_password(new)
db.session.commit()
flash("密码已更新", "ok")
return redirect(url_for("profile"))
return render_template("profile.html", user=u)
# ---------- API(轻量查询) ----------
@app.route("/api/syntax_check")
@admin_required
def api_syntax():
ok, msg = dhcp_ops.check_syntax()
return jsonify(ok=ok, msg=msg)
# ---------------------------------------------------------------------------
# 辅助:把 dataclass 转 dict 而非崩溃
# ---------------------------------------------------------------------------
def asdict_safe(obj):
try:
from dataclasses import asdict
return asdict(obj)
except Exception:
return {}
def _format_duration(seconds):
"""把秒数格式化为 d/h/m/s 友好显示"""
if seconds is None:
return ""
seconds = int(seconds)
if seconds < 0:
return "已过期"
if seconds < 60:
return f"{seconds}s"
if seconds < 3600:
return f"{seconds // 60}m {seconds % 60}s"
if seconds < 86400:
h, rem = divmod(seconds, 3600)
return f"{h}h {rem // 60}m"
d, rem = divmod(seconds, 86400)
h = rem // 3600
return f"{d}d {h}h"
app.jinja_env.filters["format_duration"] = _format_duration
# ---------------------------------------------------------------------------
# 初始化
# ---------------------------------------------------------------------------
def seed_admin():
"""首次启动时创建默认管理员 admin/admin"""
if User.query.count() == 0:
admin = User(username="admin", role="admin")
admin.set_password("admin")
db.session.add(admin)
db.session.commit()
print("[init] created default user: admin / admin(请尽快修改)")
def init_app():
with app.app_context():
db.create_all()
seed_admin()
if __name__ == "__main__":
init_app()
port = int(os.environ.get("PORT", "5100"))
app.run(host="0.0.0.0", port=port, debug=False)
+518
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
Flask>=3.0
Flask-SQLAlchemy>=3.1
Werkzeug>=3.0
gunicorn>=21.0
+368
View File
@@ -0,0 +1,368 @@
/* DHCP Web Manager - 简洁管理面板风格 */
:root {
--bg: #f5f7fa;
--card-bg: #ffffff;
--border: #e1e4e8;
--text: #24292e;
--text-muted: #6a737d;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--ok: #16a34a;
--warn: #d97706;
--danger: #dc2626;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--radius: 6px;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
}
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
code { font-family: "SF Mono", Consolas, "Liberation Mono", monospace;
font-size: 0.92em; background: #f1f3f5; padding: 1px 5px;
border-radius: 3px; }
/* ---- topbar ---- */
.topbar {
background: #1f2937;
color: #f3f4f6;
box-shadow: var(--shadow);
position: sticky; top: 0; z-index: 100;
}
.topbar-inner {
max-width: 1280px;
margin: 0 auto;
display: flex; align-items: center;
padding: 0 20px;
height: 52px;
gap: 24px;
}
.brand {
color: #f3f4f6;
font-weight: 600;
font-size: 16px;
text-decoration: none;
}
.nav {
display: flex; gap: 4px;
flex: 1;
}
.nav a {
color: #d1d5db;
padding: 8px 14px;
border-radius: var(--radius);
text-decoration: none;
font-size: 14px;
transition: background 0.15s, color 0.15s;
}
.nav a:hover { background: #374151; color: #fff; text-decoration: none; }
.nav a.active { background: var(--primary); color: #fff; }
.userbox {
display: flex; align-items: center; gap: 14px;
font-size: 13px;
}
.username { color: #f3f4f6; }
.link-muted {
color: #9ca3af;
font-size: 13px;
}
.link-muted:hover { color: #fff; text-decoration: none; }
/* ---- layout ---- */
.container {
max-width: 1280px;
margin: 24px auto;
padding: 0 20px;
}
.footer {
text-align: center;
padding: 24px;
color: var(--text-muted);
font-size: 12px;
}
/* ---- 卡片 ---- */
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 24px;
margin-bottom: 18px;
box-shadow: var(--shadow);
}
.card h1, .card h2 {
margin-top: 0;
font-size: 17px;
border-bottom: 1px solid var(--border);
padding-bottom: 10px;
margin-bottom: 16px;
}
.card.empty {
text-align: center;
padding: 48px 24px;
color: var(--text-muted);
}
/* ---- 统计卡片 ---- */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 18px;
}
.stat-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-left: 4px solid var(--primary);
border-radius: var(--radius);
padding: 18px 20px;
box-shadow: var(--shadow);
}
.stat-card.stat-ok { border-left-color: var(--ok); }
.stat-card.stat-warn { border-left-color: var(--warn); }
.stat-num {
font-size: 26px;
font-weight: 600;
color: var(--text);
}
.stat-label {
color: var(--text-muted);
font-size: 13px;
margin-top: 4px;
}
/* ---- 表格 ---- */
.data-table {
width: 100%;
border-collapse: collapse;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.data-table th, .data-table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
background: #f6f8fa;
font-weight: 600;
color: var(--text-muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.data-table tr:last-child td { border-bottom: 0; }
.data-table tr:hover { background: #f9fafb; }
.data-table .actions { white-space: nowrap; }
/* ---- 按钮 ---- */
.btn {
display: inline-block;
padding: 6px 14px;
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
text-decoration: none;
font-size: 13px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.btn:hover {
background: #f3f4f6;
text-decoration: none;
}
.btn-primary {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); color: #fff; }
.btn-danger {
background: var(--danger);
border-color: var(--danger);
color: #fff;
}
.btn-danger:hover { background: #b91c1c; border-color: #b91c1c; color: #fff; }
.btn-warn { background: var(--warn); border-color: var(--warn); color: #fff; }
.btn-warn:hover { background: #b45309; border-color: #b45309; color: #fff; }
.btn-muted { background: #f3f4f6; color: var(--text-muted); }
.btn-sm { padding: 3px 10px; font-size: 12px; }
.btn-block { display: block; width: 100%; }
/* ---- 徽章 ---- */
.badge {
display: inline-block;
padding: 1px 8px;
background: #e5e7eb;
color: #374151;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
}
.badge-ok { background: #dcfce7; color: var(--ok); }
.badge-warn { background: #fef3c7; color: var(--warn); }
/* ---- 表单 ---- */
.form .grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
}
.form label {
display: flex;
flex-direction: column;
font-weight: 500;
font-size: 13px;
color: var(--text-muted);
gap: 4px;
}
.form label.checkbox-row {
flex-direction: row;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text);
}
.form input[type="text"],
.form input[type="password"],
.form input[type="number"] {
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 14px;
font-family: inherit;
background: #fff;
color: var(--text);
}
.form input[type="text"]:focus,
.form input[type="password"]:focus,
.form input[type="number"]:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.form-actions {
margin-top: 18px;
display: flex; gap: 8px;
}
/* ---- flash ---- */
.flashes { margin-bottom: 16px; }
.flash {
padding: 10px 14px;
border-radius: var(--radius);
margin-bottom: 8px;
border: 1px solid;
font-size: 14px;
}
.flash-ok { background: #dcfce7; border-color: #bbf7d0; color: #166534; }
.flash-error { background: #fee2e2; border-color: #fecaca; color: #991b1b; }
.warn-text { color: var(--warn); }
.error-text { color: var(--danger); }
.ok-text { color: var(--ok); }
/* ---- 通用 ---- */
.muted { color: var(--text-muted); }
.small { font-size: 12px; }
.muted.small { color: var(--text-muted); font-size: 12px; }
.page-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px;
}
.page-head h1 { font-size: 22px; margin: 0; }
.search-box { display: flex; gap: 8px; }
.search-box input {
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 13px;
min-width: 240px;
}
/* ---- 进度条 ---- */
.bar {
background: #e5e7eb;
border-radius: 4px;
height: 6px;
width: 100px;
overflow: hidden;
display: inline-block;
vertical-align: middle;
margin-right: 6px;
}
.bar-fill {
background: var(--primary);
height: 100%;
transition: width 0.3s;
}
/* ---- 代码块 ---- */
.code-block {
background: #1f2937;
color: #e5e7eb;
padding: 14px 16px;
border-radius: var(--radius);
font-family: "SF Mono", Consolas, monospace;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
white-space: pre;
max-height: 480px;
}
/* ---- 登录页 ---- */
.login-card {
max-width: 360px;
margin: 80px auto;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px;
box-shadow: var(--shadow);
text-align: center;
}
.login-card h1 { margin-top: 0; }
.login-form {
text-align: left;
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 20px;
}
.login-form label {
display: flex; flex-direction: column; gap: 4px;
font-size: 13px; color: var(--text-muted); font-weight: 500;
}
.login-form input {
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 14px;
}
/* ---- 分页 ---- */
.pager {
display: flex; gap: 12px; align-items: center; justify-content: center;
margin-top: 20px;
}
/* ---- 错误页 ---- */
.error-page { text-align: center; padding: 60px 24px; }
.error-page h1 {
font-size: 64px;
margin: 0;
border: 0;
}
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}DHCP 管理{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
{% if current_user %}
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="{{ url_for('dashboard') }}">📡 DHCP 管理</a>
<nav class="nav">
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint=='dashboard' %}active{% endif %}">总览</a>
<a href="{{ url_for('subnet_list') }}" class="{% if request.endpoint in ['subnet_list','subnet_new','subnet_edit'] %}active{% endif %}">子网</a>
<a href="{{ url_for('host_list') }}" class="{% if request.endpoint in ['host_list','host_new','host_edit'] %}active{% endif %}">静态绑定</a>
<a href="{{ url_for('lease_list') }}" class="{% if request.endpoint=='lease_list' %}active{% endif %}">租约</a>
<a href="{{ url_for('config_view') }}" class="{% if request.endpoint=='config_view' %}active{% endif %}">服务配置</a>
{% if is_admin %}
<a href="{{ url_for('log_list') }}" class="{% if request.endpoint=='log_list' %}active{% endif %}">日志</a>
{% endif %}
</nav>
<div class="userbox">
<span class="username">{{ current_user.username }}{% if is_admin %} <span class="badge">管理员</span>{% endif %}</span>
<a href="{{ url_for('profile') }}" class="link-muted">改密</a>
<a href="{{ url_for('logout') }}" class="link-muted">登出</a>
</div>
</div>
</header>
{% endif %}
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
{% for cat, msg in messages %}
<div class="flash flash-{{ cat }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="footer">
<span>DHCP Web Manager · /etc/dhcp/dhcpd.conf</span>
</footer>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}服务配置{% endblock %}
{% block content %}
<div class="page-head">
<h1>服务配置</h1>
</div>
<div class="card">
<h2>服务状态</h2>
<p>
状态:
{% if svc.active %}
<span class="badge badge-ok">● 运行中</span>
{% else %}
<span class="badge badge-warn">○ 未运行</span>
{% endif %}
</p>
{% if svc.status_text %}
<pre class="code-block small">{{ svc.status_text }}</pre>
{% endif %}
<form method="post" style="display:inline">
<input type="hidden" name="action" value="reload">
<button type="submit" class="btn">reload(不丢连接)</button>
</form>
<form method="post" style="display:inline"
onsubmit="return confirm('重启 dhcpd 会中断当前所有租约续约,确定?');">
<input type="hidden" name="action" value="restart">
<button type="submit" class="btn btn-warn">restart</button>
</form>
</div>
<div class="card">
<h2>语法检查</h2>
{% if syntax_ok %}
<p class="ok-text">✓ 配置语法 OK</p>
{% else %}
<p class="error-text">✗ 配置语法错误</p>
{% endif %}
{% if syntax_msg %}
<pre class="code-block small">{{ syntax_msg }}</pre>
{% endif %}
</div>
<div class="card">
<h2>监听接口</h2>
<p class="muted small">空格分隔多个接口,留空表示监听所有(生产环境强烈建议指定具体接口)</p>
<form method="post">
<input type="hidden" name="action" value="save_interfaces">
<label>INTERFACESv4
<input type="text" name="interfaces" value="{{ ifaces|join(' ') }}"
placeholder="例如: enp0s31f6 或留空">
</label>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存(将重启 dhcpd</button>
</div>
</form>
</div>
<div class="card">
<h2>当前 dhcpd.conf</h2>
<pre class="code-block">{{ conf }}</pre>
</div>
{% endblock %}
+85
View File
@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block title %}总览 · DHCP 管理{% endblock %}
{% block content %}
<div class="stat-grid">
<div class="stat-card">
<div class="stat-num">{{ subnets|length }}</div>
<div class="stat-label">子网数</div>
</div>
<div class="stat-card">
<div class="stat-num">{{ hosts|length }}</div>
<div class="stat-label">静态绑定</div>
</div>
<div class="stat-card">
<div class="stat-num">{{ lease_total }}</div>
<div class="stat-label">活跃租约</div>
</div>
<div class="stat-card {% if svc.active %}stat-ok{% else %}stat-warn{% endif %}">
<div class="stat-num">{% if svc.active %}● 运行{% else %}○ 停止{% endif %}</div>
<div class="stat-label">dhcpd 状态</div>
</div>
</div>
<div class="card">
<h2>接口绑定</h2>
{% if svc.interfaces %}
<p>当前监听: <code>{{ svc.interfaces|join(', ') }}</code></p>
{% else %}
<p class="warn-text">⚠ 当前未配置监听接口(dhcpd 将不会响应任何请求)</p>
{% endif %}
<a href="{{ url_for('config_view') }}" class="btn">前往配置</a>
</div>
<div class="card">
<h2>地址池使用情况</h2>
{% if pool_stats %}
<table class="data-table">
<thead>
<tr><th>子网</th><th>地址池范围</th><th>总数</th><th>已用</th><th>静态</th><th>可用</th><th>利用率</th></tr>
</thead>
<tbody>
{% for p in pool_stats %}
<tr>
<td>{{ p.subnet }}</td>
<td><code>{{ p.range }}</code></td>
<td>{{ p.total }}</td>
<td>{{ p.used }}</td>
<td>{{ p.static }}</td>
<td>{{ p.available }}</td>
<td>
<div class="bar"><div class="bar-fill" style="width: {{ p.util_pct }}%"></div></div>
<span class="small">{{ p.util_pct }}%</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">尚未配置任何子网。 <a href="{{ url_for('subnet_new') }}">新增子网</a></p>
{% endif %}
</div>
<div class="card">
<h2>最近活跃租约</h2>
{% if leases %}
<table class="data-table">
<thead>
<tr><th>IP</th><th>MAC</th><th>主机名</th><th>剩余</th></tr>
</thead>
<tbody>
{% for l in leases %}
<tr>
<td>{{ l.ip }}</td>
<td><code>{{ l.mac or '—' }}</code></td>
<td>{{ l.client_hostname or '—' }}</td>
<td>{{ l.remaining_seconds()|format_duration }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><a href="{{ url_for('lease_list') }}">查看全部租约 →</a></p>
{% else %}
<p class="muted">暂无活跃租约</p>
{% endif %}
</div>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}{{ code }} 错误{% endblock %}
{% block content %}
<div class="card error-page">
<h1>{{ code }}</h1>
<p>{{ message }}</p>
<a href="{{ url_for('dashboard') }}" class="btn">返回首页</a>
</div>
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}{{ host.name|default('新增') }} · 静态绑定{% endblock %}
{% block content %}
<div class="page-head">
<h1>{% if host.name %}编辑 host {{ host.name }}{% else %}新增静态绑定{% endif %}</h1>
<a href="{{ url_for('host_list') }}" class="btn btn-muted">← 返回</a>
</div>
<form method="post" class="card form">
<div class="grid-2">
<label>主机名
<input type="text" name="name" value="{{ form.name or host.name }}"
placeholder="printer-hall" required
pattern="[A-Za-z0-9_\-\.]{1,63}">
</label>
<label>MAC 地址
<input type="text" name="mac" value="{{ form.mac or host.mac }}"
placeholder="aa:bb:cc:dd:ee:ff" required
pattern="([0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}">
</label>
<label>固定 IP
<input type="text" name="ip" value="{{ form.ip or host.ip }}"
placeholder="192.168.1.50" required
pattern="\d{1,3}(\.\d{1,3}){3}">
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存并应用</button>
<a href="{{ url_for('host_list') }}" class="btn btn-muted">取消</a>
</div>
<p class="muted small">保存后会自动 reload dhcpd。客户端重启网络或重新获取 IP 后生效。</p>
</form>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}静态绑定{% endblock %}
{% block content %}
<div class="page-head">
<h1>静态绑定(host 声明)</h1>
{% if is_admin %}<a href="{{ url_for('host_new') }}" class="btn btn-primary">+ 新增绑定</a>{% endif %}
</div>
{% if hosts %}
<table class="data-table">
<thead>
<tr>
<th>主机名</th>
<th>MAC</th>
<th>固定 IP</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for h in hosts %}
<tr>
<td><strong>{{ h.name }}</strong></td>
<td><code>{{ h.mac }}</code></td>
<td><code>{{ h.ip }}</code></td>
<td class="actions">
{% if is_admin %}
<a href="{{ url_for('host_edit', name=h.name) }}" class="btn btn-sm">编辑</a>
<form method="post" action="{{ url_for('host_delete', name=h.name) }}"
onsubmit="return confirm('确认删除 host {{ h.name }}');" style="display:inline">
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
{% else %}<span class="muted"></span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card empty">
<p>暂无静态绑定</p>
{% if is_admin %}<a href="{{ url_for('host_new') }}" class="btn btn-primary">+ 新增第一个</a>{% endif %}
</div>
{% endif %}
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}租约{% endblock %}
{% block content %}
<div class="page-head">
<h1>租约查询</h1>
<form method="get" class="search-box">
<input type="text" name="q" value="{{ q }}" placeholder="按 IP / MAC / 主机名搜索…">
<button type="submit" class="btn">搜索</button>
</form>
</div>
{% if leases %}
<table class="data-table">
<thead>
<tr>
<th>IP</th>
<th>MAC</th>
<th>主机名</th>
<th>状态</th>
<th>开始</th>
<th>结束</th>
<th>剩余</th>
</tr>
</thead>
<tbody>
{% for l in leases %}
<tr>
<td><code>{{ l.ip }}</code></td>
<td><code>{{ l.mac or '—' }}</code></td>
<td>{{ l.client_hostname or '—' }}</td>
<td>
{% if l.binding_state == 'active' %}
<span class="badge badge-ok">active</span>
{% elif l.binding_state == 'free' %}
<span class="badge">free</span>
{% else %}
<span class="badge badge-warn">{{ l.binding_state }}</span>
{% endif %}
</td>
<td><span class="small">{{ l.starts or '—' }}</span></td>
<td><span class="small">{{ l.ends or '—' }}</span></td>
<td>
{% set rem = l.remaining_seconds() %}
{% if rem is none %}—
{% elif rem < 0 %}已过期
{% else %}{{ rem|format_duration }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted small">共 {{ leases|length }} 条租约</p>
{% else %}
<div class="card empty">
<p>暂无租约记录{% if q %}(搜索 "<code>{{ q }}</code>" 无结果){% endif %}</p>
</div>
{% endif %}
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}登录 · DHCP 管理{% endblock %}
{% block content %}
<div class="login-card">
<h1>📡 DHCP 管理</h1>
<p class="muted">请登录以管理 dhcpd 配置</p>
<form method="post" class="login-form">
<label>用户名 <input type="text" name="username" required autofocus></label>
<label>密码 <input type="password" name="password" required></label>
<button type="submit" class="btn btn-primary btn-block">登录</button>
</form>
<p class="muted small">默认账号:admin / admin(首次登录后请到"改密"修改)</p>
</div>
{% endblock %}
+47
View File
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}操作日志{% endblock %}
{% block content %}
<div class="page-head">
<h1>操作日志</h1>
<span class="muted">共 {{ total }} 条</span>
</div>
<table class="data-table">
<thead>
<tr>
<th>时间</th>
<th>用户</th>
<th>操作</th>
<th>对象</th>
<th>结果</th>
<th>消息</th>
</tr>
</thead>
<tbody>
{% for l in logs %}
<tr>
<td><span class="small">{{ l.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</span></td>
<td>{{ l.user }}</td>
<td><code>{{ l.action }}</code></td>
<td><code>{{ l.target }}</code></td>
<td>
{% if l.result == 'ok' %}
<span class="badge badge-ok">{{ l.result }}</span>
{% else %}
<span class="badge badge-warn">{{ l.result }}</span>
{% endif %}
</td>
<td><span class="small">{{ l.message }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% if pages > 1 %}
<div class="pager">
{% if page > 1 %}<a href="?page={{ page-1 }}" class="btn">← 上一页</a>{% endif %}
<span class="muted">第 {{ page }} / {{ pages }} 页</span>
{% if page < pages %}<a href="?page={{ page+1 }}" class="btn">下一页 →</a>{% endif %}
</div>
{% endif %}
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}个人资料{% endblock %}
{% block content %}
<div class="page-head">
<h1>修改密码</h1>
</div>
<form method="post" class="card form" style="max-width:420px">
<label>用户名 <input type="text" value="{{ user.username }}" disabled></label>
<label>当前密码 <input type="password" name="old_password" required></label>
<label>新密码 <input type="password" name="new_password" required minlength="6"></label>
<label>再次输入新密码 <input type="password" name="confirm" required minlength="6></label>
<div class="form-actions">
<button type="submit" class="btn btn-primary">修改</button>
</div>
</form>
{% endblock %}
+60
View File
@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}{{ subnet.name|default('新增') }} · 子网{% endblock %}
{% block content %}
<div class="page-head">
<h1>{% if subnet.network %}编辑子网 {{ subnet.network }}/{{ subnet.netmask }}{% else %}新增子网{% endif %}</h1>
<a href="{{ url_for('subnet_list') }}" class="btn btn-muted">← 返回</a>
</div>
<form method="post" class="card form">
<div class="grid-2">
<label>网络号
<input type="text" name="network" value="{{ form.network or subnet.network }}"
placeholder="192.168.1.0" required pattern="\d{1,3}(\.\d{1,3}){3}">
</label>
<label>子网掩码
<input type="text" name="netmask" value="{{ form.netmask or subnet.netmask }}"
placeholder="255.255.255.0" required pattern="(\d{1,3}(\.\d{1,3}){3})|(\d{1,2})">
<small class="muted">支持 255.255.255.0 或 /24 写法</small>
</label>
<label>默认网关
<input type="text" name="routers" value="{{ form.routers or subnet.routers }}"
placeholder="192.168.1.1" required>
</label>
<label>DNS 服务器
<input type="text" name="dns" value="{{ form.dns or subnet.dns }}"
placeholder="8.8.8.8, 8.8.4.4" required>
<small class="muted">多个用逗号分隔</small>
</label>
<label>地址池起始
<input type="text" name="range_start" value="{{ form.range_start or subnet.range_start }}"
placeholder="192.168.1.100" required>
</label>
<label>地址池结束
<input type="text" name="range_end" value="{{ form.range_end or subnet.range_end }}"
placeholder="192.168.1.200" required>
</label>
<label>默认租期(秒)
<input type="number" name="default_lease" min="60" value="{{ form.default_lease or subnet.default_lease or 600 }}" required>
</label>
<label>最大租期(秒)
<input type="number" name="max_lease" min="60" value="{{ form.max_lease or subnet.max_lease or 7200 }}" required>
</label>
<label>域名(可选)
<input type="text" name="domain_name" value="{{ form.domain_name or subnet.domain_name }}"
placeholder="lan.local">
</label>
<label class="checkbox-row">
<input type="checkbox" name="authoritative"
{% if form.authoritative is defined and form.authoritative %}checked{% endif %}
{% if not form and subnet.authoritative %}checked{% endif %}>
authoritative(本网络唯一 DHCP 服务器)
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存并应用</button>
<a href="{{ url_for('subnet_list') }}" class="btn btn-muted">取消</a>
</div>
<p class="muted small">保存将自动校验语法并 reload 服务。</p>
</form>
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}子网配置{% endblock %}
{% block content %}
<div class="page-head">
<h1>子网配置</h1>
{% if is_admin %}<a href="{{ url_for('subnet_new') }}" class="btn btn-primary">+ 新增子网</a>{% endif %}
</div>
{% if subnets %}
<table class="data-table">
<thead>
<tr>
<th>网段</th>
<th>掩码</th>
<th>网关</th>
<th>DNS</th>
<th>地址池</th>
<th>租期(默认/最大)</th>
<th>authoritative</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for s in subnets %}
<tr>
<td><code>{{ s.network }}</code></td>
<td><code>{{ s.netmask }}</code></td>
<td><code>{{ s.routers }}</code></td>
<td><code>{{ s.dns }}</code></td>
<td><code>{{ s.range_start }}<br>~ {{ s.range_end }}</code></td>
<td>{{ s.default_lease }}s / {{ s.max_lease }}s</td>
<td>{% if s.authoritative %}<span class="badge badge-ok">yes</span>{% else %}<span class="muted">no</span>{% endif %}</td>
<td class="actions">
{% if is_admin %}
<a href="{{ url_for('subnet_edit', network=s.network, netmask=s.netmask) }}" class="btn btn-sm">编辑</a>
<form method="post" action="{{ url_for('subnet_delete', network=s.network, netmask=s.netmask) }}"
onsubmit="return confirm('确认删除子网 {{ s.network }}/{{ s.netmask }}');" style="display:inline">
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
{% else %}
<span class="muted"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card empty">
<p>暂无子网配置</p>
{% if is_admin %}<a href="{{ url_for('subnet_new') }}" class="btn btn-primary">+ 新增第一个子网</a>{% endif %}
</div>
{% endif %}
{% endblock %}