commit 90736253b130ac50b0541eec7edb4cfbaeaa3455 Author: DHCP Service Bot Date: Thu Jul 9 17:09:23 2026 +0800 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44b0fb0 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a20bdd8 --- /dev/null +++ b/README.md @@ -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..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 <: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 +``` \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..afb97c0 --- /dev/null +++ b/app.py @@ -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///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///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//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//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) \ No newline at end of file diff --git a/dhcp_ops.py b/dhcp_ops.py new file mode 100644 index 0000000..f56a5fa --- /dev/null +++ b/dhcp_ops.py @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e016f3f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask>=3.0 +Flask-SQLAlchemy>=3.1 +Werkzeug>=3.0 +gunicorn>=21.0 \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..f49b65f --- /dev/null +++ b/static/style.css @@ -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; +} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..9b82050 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,51 @@ + + + + + +{% block title %}DHCP 管理{% endblock %} + + + +{% if current_user %} +
+
+ 📡 DHCP 管理 + +
+ {{ current_user.username }}{% if is_admin %} 管理员{% endif %} + 改密 + 登出 +
+
+
+{% endif %} + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ +
+ DHCP Web Manager · /etc/dhcp/dhcpd.conf +
+ + \ No newline at end of file diff --git a/templates/config.html b/templates/config.html new file mode 100644 index 0000000..16d5ffd --- /dev/null +++ b/templates/config.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}服务配置{% endblock %} +{% block content %} +
+

服务配置

+
+ +
+

服务状态

+

+ 状态: + {% if svc.active %} + ● 运行中 + {% else %} + ○ 未运行 + {% endif %} +

+ {% if svc.status_text %} +
{{ svc.status_text }}
+ {% endif %} +
+ + +
+
+ + +
+
+ +
+

语法检查

+ {% if syntax_ok %} +

✓ 配置语法 OK

+ {% else %} +

✗ 配置语法错误

+ {% endif %} + {% if syntax_msg %} +
{{ syntax_msg }}
+ {% endif %} +
+ +
+

监听接口

+

空格分隔多个接口,留空表示监听所有(生产环境强烈建议指定具体接口)

+
+ + +
+ +
+
+
+ +
+

当前 dhcpd.conf

+
{{ conf }}
+
+{% endblock %} \ No newline at end of file diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..5d0a7be --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% block title %}总览 · DHCP 管理{% endblock %} +{% block content %} +
+
+
{{ subnets|length }}
+
子网数
+
+
+
{{ hosts|length }}
+
静态绑定
+
+
+
{{ lease_total }}
+
活跃租约
+
+
+
{% if svc.active %}● 运行{% else %}○ 停止{% endif %}
+
dhcpd 状态
+
+
+ +
+

接口绑定

+ {% if svc.interfaces %} +

当前监听: {{ svc.interfaces|join(', ') }}

+ {% else %} +

⚠ 当前未配置监听接口(dhcpd 将不会响应任何请求)

+ {% endif %} + 前往配置 +
+ +
+

地址池使用情况

+ {% if pool_stats %} + + + + + + {% for p in pool_stats %} + + + + + + + + + + {% endfor %} + +
子网地址池范围总数已用静态可用利用率
{{ p.subnet }}{{ p.range }}{{ p.total }}{{ p.used }}{{ p.static }}{{ p.available }} +
+ {{ p.util_pct }}% +
+ {% else %} +

尚未配置任何子网。 新增子网

+ {% endif %} +
+ +
+

最近活跃租约

+ {% if leases %} + + + + + + {% for l in leases %} + + + + + + + {% endfor %} + +
IPMAC主机名剩余
{{ l.ip }}{{ l.mac or '—' }}{{ l.client_hostname or '—' }}{{ l.remaining_seconds()|format_duration }}
+

查看全部租约 →

+ {% else %} +

暂无活跃租约

+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..f8dc38c --- /dev/null +++ b/templates/error.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}{{ code }} 错误{% endblock %} +{% block content %} +
+

{{ code }}

+

{{ message }}

+ 返回首页 +
+{% endblock %} \ No newline at end of file diff --git a/templates/host_form.html b/templates/host_form.html new file mode 100644 index 0000000..838e96a --- /dev/null +++ b/templates/host_form.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}{{ host.name|default('新增') }} · 静态绑定{% endblock %} +{% block content %} +
+

{% if host.name %}编辑 host {{ host.name }}{% else %}新增静态绑定{% endif %}

+ ← 返回 +
+ +
+
+ + + +
+
+ + 取消 +
+

保存后会自动 reload dhcpd。客户端重启网络或重新获取 IP 后生效。

+
+{% endblock %} \ No newline at end of file diff --git a/templates/hosts.html b/templates/hosts.html new file mode 100644 index 0000000..d4171ee --- /dev/null +++ b/templates/hosts.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}静态绑定{% endblock %} +{% block content %} +
+

静态绑定(host 声明)

+ {% if is_admin %}+ 新增绑定{% endif %} +
+ +{% if hosts %} + + + + + + + + + + + {% for h in hosts %} + + + + + + + {% endfor %} + +
主机名MAC固定 IP操作
{{ h.name }}{{ h.mac }}{{ h.ip }} + {% if is_admin %} + 编辑 +
+ +
+ {% else %}{% endif %} +
+{% else %} +
+

暂无静态绑定

+ {% if is_admin %}+ 新增第一个{% endif %} +
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/leases.html b/templates/leases.html new file mode 100644 index 0000000..96b67a3 --- /dev/null +++ b/templates/leases.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}租约{% endblock %} +{% block content %} +
+

租约查询

+ +
+ +{% if leases %} + + + + + + + + + + + + + + {% for l in leases %} + + + + + + + + + + {% endfor %} + +
IPMAC主机名状态开始结束剩余
{{ l.ip }}{{ l.mac or '—' }}{{ l.client_hostname or '—' }} + {% if l.binding_state == 'active' %} + active + {% elif l.binding_state == 'free' %} + free + {% else %} + {{ l.binding_state }} + {% endif %} + {{ l.starts or '—' }}{{ l.ends or '—' }} + {% set rem = l.remaining_seconds() %} + {% if rem is none %}— + {% elif rem < 0 %}已过期 + {% else %}{{ rem|format_duration }} + {% endif %} +
+

共 {{ leases|length }} 条租约

+{% else %} +
+

暂无租约记录{% if q %}(搜索 "{{ q }}" 无结果){% endif %}

+
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..4636387 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}登录 · DHCP 管理{% endblock %} +{% block content %} + +{% endblock %} \ No newline at end of file diff --git a/templates/logs.html b/templates/logs.html new file mode 100644 index 0000000..f731413 --- /dev/null +++ b/templates/logs.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}操作日志{% endblock %} +{% block content %} +
+

操作日志

+ 共 {{ total }} 条 +
+ + + + + + + + + + + + + + {% for l in logs %} + + + + + + + + + {% endfor %} + +
时间用户操作对象结果消息
{{ l.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{{ l.user }}{{ l.action }}{{ l.target }} + {% if l.result == 'ok' %} + {{ l.result }} + {% else %} + {{ l.result }} + {% endif %} + {{ l.message }}
+ +{% if pages > 1 %} +
+ {% if page > 1 %}← 上一页{% endif %} + 第 {{ page }} / {{ pages }} 页 + {% if page < pages %}下一页 →{% endif %} +
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..55b39cf --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}个人资料{% endblock %} +{% block content %} +
+

修改密码

+
+
+ + + +