Initial commit: DHCP Web Manager

Flask-based web UI for managing ISC DHCP server (dhcpd).

Features:
- subnet / static host binding CRUD via web
- listen interface configuration (INTERFACESv4)
- active lease query + pool utilization stats
- service status, reload/restart, syntax check
- user auth + operation logs
- automatic backup before each config change
- safe change pipeline: write file → dhcpd -t → systemctl restart + is-active verify
- error messages include journalctl tail for diagnosis
This commit is contained in:
DHCP Service Bot
2026-07-09 17:09:23 +08:00
commit 90736253b1
18 changed files with 2192 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}DHCP 管理{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
{% if current_user %}
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="{{ url_for('dashboard') }}">📡 DHCP 管理</a>
<nav class="nav">
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint=='dashboard' %}active{% endif %}">总览</a>
<a href="{{ url_for('subnet_list') }}" class="{% if request.endpoint in ['subnet_list','subnet_new','subnet_edit'] %}active{% endif %}">子网</a>
<a href="{{ url_for('host_list') }}" class="{% if request.endpoint in ['host_list','host_new','host_edit'] %}active{% endif %}">静态绑定</a>
<a href="{{ url_for('lease_list') }}" class="{% if request.endpoint=='lease_list' %}active{% endif %}">租约</a>
<a href="{{ url_for('config_view') }}" class="{% if request.endpoint=='config_view' %}active{% endif %}">服务配置</a>
{% if is_admin %}
<a href="{{ url_for('log_list') }}" class="{% if request.endpoint=='log_list' %}active{% endif %}">日志</a>
{% endif %}
</nav>
<div class="userbox">
<span class="username">{{ current_user.username }}{% if is_admin %} <span class="badge">管理员</span>{% endif %}</span>
<a href="{{ url_for('profile') }}" class="link-muted">改密</a>
<a href="{{ url_for('logout') }}" class="link-muted">登出</a>
</div>
</div>
</header>
{% endif %}
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
{% for cat, msg in messages %}
<div class="flash flash-{{ cat }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="footer">
<span>DHCP Web Manager · /etc/dhcp/dhcpd.conf</span>
</footer>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}服务配置{% endblock %}
{% block content %}
<div class="page-head">
<h1>服务配置</h1>
</div>
<div class="card">
<h2>服务状态</h2>
<p>
状态:
{% if svc.active %}
<span class="badge badge-ok">● 运行中</span>
{% else %}
<span class="badge badge-warn">○ 未运行</span>
{% endif %}
</p>
{% if svc.status_text %}
<pre class="code-block small">{{ svc.status_text }}</pre>
{% endif %}
<form method="post" style="display:inline">
<input type="hidden" name="action" value="reload">
<button type="submit" class="btn">reload(不丢连接)</button>
</form>
<form method="post" style="display:inline"
onsubmit="return confirm('重启 dhcpd 会中断当前所有租约续约,确定?');">
<input type="hidden" name="action" value="restart">
<button type="submit" class="btn btn-warn">restart</button>
</form>
</div>
<div class="card">
<h2>语法检查</h2>
{% if syntax_ok %}
<p class="ok-text">✓ 配置语法 OK</p>
{% else %}
<p class="error-text">✗ 配置语法错误</p>
{% endif %}
{% if syntax_msg %}
<pre class="code-block small">{{ syntax_msg }}</pre>
{% endif %}
</div>
<div class="card">
<h2>监听接口</h2>
<p class="muted small">空格分隔多个接口,留空表示监听所有(生产环境强烈建议指定具体接口)</p>
<form method="post">
<input type="hidden" name="action" value="save_interfaces">
<label>INTERFACESv4
<input type="text" name="interfaces" value="{{ ifaces|join(' ') }}"
placeholder="例如: enp0s31f6 或留空">
</label>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存(将重启 dhcpd</button>
</div>
</form>
</div>
<div class="card">
<h2>当前 dhcpd.conf</h2>
<pre class="code-block">{{ conf }}</pre>
</div>
{% endblock %}
+85
View File
@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block title %}总览 · DHCP 管理{% endblock %}
{% block content %}
<div class="stat-grid">
<div class="stat-card">
<div class="stat-num">{{ subnets|length }}</div>
<div class="stat-label">子网数</div>
</div>
<div class="stat-card">
<div class="stat-num">{{ hosts|length }}</div>
<div class="stat-label">静态绑定</div>
</div>
<div class="stat-card">
<div class="stat-num">{{ lease_total }}</div>
<div class="stat-label">活跃租约</div>
</div>
<div class="stat-card {% if svc.active %}stat-ok{% else %}stat-warn{% endif %}">
<div class="stat-num">{% if svc.active %}● 运行{% else %}○ 停止{% endif %}</div>
<div class="stat-label">dhcpd 状态</div>
</div>
</div>
<div class="card">
<h2>接口绑定</h2>
{% if svc.interfaces %}
<p>当前监听: <code>{{ svc.interfaces|join(', ') }}</code></p>
{% else %}
<p class="warn-text">⚠ 当前未配置监听接口(dhcpd 将不会响应任何请求)</p>
{% endif %}
<a href="{{ url_for('config_view') }}" class="btn">前往配置</a>
</div>
<div class="card">
<h2>地址池使用情况</h2>
{% if pool_stats %}
<table class="data-table">
<thead>
<tr><th>子网</th><th>地址池范围</th><th>总数</th><th>已用</th><th>静态</th><th>可用</th><th>利用率</th></tr>
</thead>
<tbody>
{% for p in pool_stats %}
<tr>
<td>{{ p.subnet }}</td>
<td><code>{{ p.range }}</code></td>
<td>{{ p.total }}</td>
<td>{{ p.used }}</td>
<td>{{ p.static }}</td>
<td>{{ p.available }}</td>
<td>
<div class="bar"><div class="bar-fill" style="width: {{ p.util_pct }}%"></div></div>
<span class="small">{{ p.util_pct }}%</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">尚未配置任何子网。 <a href="{{ url_for('subnet_new') }}">新增子网</a></p>
{% endif %}
</div>
<div class="card">
<h2>最近活跃租约</h2>
{% if leases %}
<table class="data-table">
<thead>
<tr><th>IP</th><th>MAC</th><th>主机名</th><th>剩余</th></tr>
</thead>
<tbody>
{% for l in leases %}
<tr>
<td>{{ l.ip }}</td>
<td><code>{{ l.mac or '—' }}</code></td>
<td>{{ l.client_hostname or '—' }}</td>
<td>{{ l.remaining_seconds()|format_duration }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><a href="{{ url_for('lease_list') }}">查看全部租约 →</a></p>
{% else %}
<p class="muted">暂无活跃租约</p>
{% endif %}
</div>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}{{ code }} 错误{% endblock %}
{% block content %}
<div class="card error-page">
<h1>{{ code }}</h1>
<p>{{ message }}</p>
<a href="{{ url_for('dashboard') }}" class="btn">返回首页</a>
</div>
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}{{ host.name|default('新增') }} · 静态绑定{% endblock %}
{% block content %}
<div class="page-head">
<h1>{% if host.name %}编辑 host {{ host.name }}{% else %}新增静态绑定{% endif %}</h1>
<a href="{{ url_for('host_list') }}" class="btn btn-muted">← 返回</a>
</div>
<form method="post" class="card form">
<div class="grid-2">
<label>主机名
<input type="text" name="name" value="{{ form.name or host.name }}"
placeholder="printer-hall" required
pattern="[A-Za-z0-9_\-\.]{1,63}">
</label>
<label>MAC 地址
<input type="text" name="mac" value="{{ form.mac or host.mac }}"
placeholder="aa:bb:cc:dd:ee:ff" required
pattern="([0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}">
</label>
<label>固定 IP
<input type="text" name="ip" value="{{ form.ip or host.ip }}"
placeholder="192.168.1.50" required
pattern="\d{1,3}(\.\d{1,3}){3}">
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存并应用</button>
<a href="{{ url_for('host_list') }}" class="btn btn-muted">取消</a>
</div>
<p class="muted small">保存后会自动 reload dhcpd。客户端重启网络或重新获取 IP 后生效。</p>
</form>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}静态绑定{% endblock %}
{% block content %}
<div class="page-head">
<h1>静态绑定(host 声明)</h1>
{% if is_admin %}<a href="{{ url_for('host_new') }}" class="btn btn-primary">+ 新增绑定</a>{% endif %}
</div>
{% if hosts %}
<table class="data-table">
<thead>
<tr>
<th>主机名</th>
<th>MAC</th>
<th>固定 IP</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for h in hosts %}
<tr>
<td><strong>{{ h.name }}</strong></td>
<td><code>{{ h.mac }}</code></td>
<td><code>{{ h.ip }}</code></td>
<td class="actions">
{% if is_admin %}
<a href="{{ url_for('host_edit', name=h.name) }}" class="btn btn-sm">编辑</a>
<form method="post" action="{{ url_for('host_delete', name=h.name) }}"
onsubmit="return confirm('确认删除 host {{ h.name }}');" style="display:inline">
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
{% else %}<span class="muted"></span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card empty">
<p>暂无静态绑定</p>
{% if is_admin %}<a href="{{ url_for('host_new') }}" class="btn btn-primary">+ 新增第一个</a>{% endif %}
</div>
{% endif %}
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}租约{% endblock %}
{% block content %}
<div class="page-head">
<h1>租约查询</h1>
<form method="get" class="search-box">
<input type="text" name="q" value="{{ q }}" placeholder="按 IP / MAC / 主机名搜索…">
<button type="submit" class="btn">搜索</button>
</form>
</div>
{% if leases %}
<table class="data-table">
<thead>
<tr>
<th>IP</th>
<th>MAC</th>
<th>主机名</th>
<th>状态</th>
<th>开始</th>
<th>结束</th>
<th>剩余</th>
</tr>
</thead>
<tbody>
{% for l in leases %}
<tr>
<td><code>{{ l.ip }}</code></td>
<td><code>{{ l.mac or '—' }}</code></td>
<td>{{ l.client_hostname or '—' }}</td>
<td>
{% if l.binding_state == 'active' %}
<span class="badge badge-ok">active</span>
{% elif l.binding_state == 'free' %}
<span class="badge">free</span>
{% else %}
<span class="badge badge-warn">{{ l.binding_state }}</span>
{% endif %}
</td>
<td><span class="small">{{ l.starts or '—' }}</span></td>
<td><span class="small">{{ l.ends or '—' }}</span></td>
<td>
{% set rem = l.remaining_seconds() %}
{% if rem is none %}—
{% elif rem < 0 %}已过期
{% else %}{{ rem|format_duration }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted small">共 {{ leases|length }} 条租约</p>
{% else %}
<div class="card empty">
<p>暂无租约记录{% if q %}(搜索 "<code>{{ q }}</code>" 无结果){% endif %}</p>
</div>
{% endif %}
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}登录 · DHCP 管理{% endblock %}
{% block content %}
<div class="login-card">
<h1>📡 DHCP 管理</h1>
<p class="muted">请登录以管理 dhcpd 配置</p>
<form method="post" class="login-form">
<label>用户名 <input type="text" name="username" required autofocus></label>
<label>密码 <input type="password" name="password" required></label>
<button type="submit" class="btn btn-primary btn-block">登录</button>
</form>
<p class="muted small">默认账号:admin / admin(首次登录后请到"改密"修改)</p>
</div>
{% endblock %}
+47
View File
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}操作日志{% endblock %}
{% block content %}
<div class="page-head">
<h1>操作日志</h1>
<span class="muted">共 {{ total }} 条</span>
</div>
<table class="data-table">
<thead>
<tr>
<th>时间</th>
<th>用户</th>
<th>操作</th>
<th>对象</th>
<th>结果</th>
<th>消息</th>
</tr>
</thead>
<tbody>
{% for l in logs %}
<tr>
<td><span class="small">{{ l.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</span></td>
<td>{{ l.user }}</td>
<td><code>{{ l.action }}</code></td>
<td><code>{{ l.target }}</code></td>
<td>
{% if l.result == 'ok' %}
<span class="badge badge-ok">{{ l.result }}</span>
{% else %}
<span class="badge badge-warn">{{ l.result }}</span>
{% endif %}
</td>
<td><span class="small">{{ l.message }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% if pages > 1 %}
<div class="pager">
{% if page > 1 %}<a href="?page={{ page-1 }}" class="btn">← 上一页</a>{% endif %}
<span class="muted">第 {{ page }} / {{ pages }} 页</span>
{% if page < pages %}<a href="?page={{ page+1 }}" class="btn">下一页 →</a>{% endif %}
</div>
{% endif %}
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}个人资料{% endblock %}
{% block content %}
<div class="page-head">
<h1>修改密码</h1>
</div>
<form method="post" class="card form" style="max-width:420px">
<label>用户名 <input type="text" value="{{ user.username }}" disabled></label>
<label>当前密码 <input type="password" name="old_password" required></label>
<label>新密码 <input type="password" name="new_password" required minlength="6"></label>
<label>再次输入新密码 <input type="password" name="confirm" required minlength="6></label>
<div class="form-actions">
<button type="submit" class="btn btn-primary">修改</button>
</div>
</form>
{% endblock %}
+60
View File
@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}{{ subnet.name|default('新增') }} · 子网{% endblock %}
{% block content %}
<div class="page-head">
<h1>{% if subnet.network %}编辑子网 {{ subnet.network }}/{{ subnet.netmask }}{% else %}新增子网{% endif %}</h1>
<a href="{{ url_for('subnet_list') }}" class="btn btn-muted">← 返回</a>
</div>
<form method="post" class="card form">
<div class="grid-2">
<label>网络号
<input type="text" name="network" value="{{ form.network or subnet.network }}"
placeholder="192.168.1.0" required pattern="\d{1,3}(\.\d{1,3}){3}">
</label>
<label>子网掩码
<input type="text" name="netmask" value="{{ form.netmask or subnet.netmask }}"
placeholder="255.255.255.0" required pattern="(\d{1,3}(\.\d{1,3}){3})|(\d{1,2})">
<small class="muted">支持 255.255.255.0 或 /24 写法</small>
</label>
<label>默认网关
<input type="text" name="routers" value="{{ form.routers or subnet.routers }}"
placeholder="192.168.1.1" required>
</label>
<label>DNS 服务器
<input type="text" name="dns" value="{{ form.dns or subnet.dns }}"
placeholder="8.8.8.8, 8.8.4.4" required>
<small class="muted">多个用逗号分隔</small>
</label>
<label>地址池起始
<input type="text" name="range_start" value="{{ form.range_start or subnet.range_start }}"
placeholder="192.168.1.100" required>
</label>
<label>地址池结束
<input type="text" name="range_end" value="{{ form.range_end or subnet.range_end }}"
placeholder="192.168.1.200" required>
</label>
<label>默认租期(秒)
<input type="number" name="default_lease" min="60" value="{{ form.default_lease or subnet.default_lease or 600 }}" required>
</label>
<label>最大租期(秒)
<input type="number" name="max_lease" min="60" value="{{ form.max_lease or subnet.max_lease or 7200 }}" required>
</label>
<label>域名(可选)
<input type="text" name="domain_name" value="{{ form.domain_name or subnet.domain_name }}"
placeholder="lan.local">
</label>
<label class="checkbox-row">
<input type="checkbox" name="authoritative"
{% if form.authoritative is defined and form.authoritative %}checked{% endif %}
{% if not form and subnet.authoritative %}checked{% endif %}>
authoritative(本网络唯一 DHCP 服务器)
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存并应用</button>
<a href="{{ url_for('subnet_list') }}" class="btn btn-muted">取消</a>
</div>
<p class="muted small">保存将自动校验语法并 reload 服务。</p>
</form>
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}子网配置{% endblock %}
{% block content %}
<div class="page-head">
<h1>子网配置</h1>
{% if is_admin %}<a href="{{ url_for('subnet_new') }}" class="btn btn-primary">+ 新增子网</a>{% endif %}
</div>
{% if subnets %}
<table class="data-table">
<thead>
<tr>
<th>网段</th>
<th>掩码</th>
<th>网关</th>
<th>DNS</th>
<th>地址池</th>
<th>租期(默认/最大)</th>
<th>authoritative</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for s in subnets %}
<tr>
<td><code>{{ s.network }}</code></td>
<td><code>{{ s.netmask }}</code></td>
<td><code>{{ s.routers }}</code></td>
<td><code>{{ s.dns }}</code></td>
<td><code>{{ s.range_start }}<br>~ {{ s.range_end }}</code></td>
<td>{{ s.default_lease }}s / {{ s.max_lease }}s</td>
<td>{% if s.authoritative %}<span class="badge badge-ok">yes</span>{% else %}<span class="muted">no</span>{% endif %}</td>
<td class="actions">
{% if is_admin %}
<a href="{{ url_for('subnet_edit', network=s.network, netmask=s.netmask) }}" class="btn btn-sm">编辑</a>
<form method="post" action="{{ url_for('subnet_delete', network=s.network, netmask=s.netmask) }}"
onsubmit="return confirm('确认删除子网 {{ s.network }}/{{ s.netmask }}');" style="display:inline">
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
{% else %}
<span class="muted"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card empty">
<p>暂无子网配置</p>
{% if is_admin %}<a href="{{ url_for('subnet_new') }}" class="btn btn-primary">+ 新增第一个子网</a>{% endif %}
</div>
{% endif %}
{% endblock %}