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:
+16
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Flask>=3.0
|
||||||
|
Flask-SQLAlchemy>=3.1
|
||||||
|
Werkzeug>=3.0
|
||||||
|
gunicorn>=21.0
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user