Files
dhcp-service/app.py
T
DHCP Service Bot 90736253b1 Initial commit: DHCP Web Manager
Flask-based web UI for managing ISC DHCP server (dhcpd).

Features:
- subnet / static host binding CRUD via web
- listen interface configuration (INTERFACESv4)
- active lease query + pool utilization stats
- service status, reload/restart, syntax check
- user auth + operation logs
- automatic backup before each config change
- safe change pipeline: write file → dhcpd -t → systemctl restart + is-active verify
- error messages include journalctl tail for diagnosis
2026-07-09 17:09:23 +08:00

602 lines
22 KiB
Python
Raw Blame History

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