feat: BIND DNS Web Manager - Web interface for managing BIND9 DNS server

- Flask + SQLite web app with user authentication
- Zone management: create/delete zones, edit raw zone files
- Record management: add/delete A/AAAA/CNAME/MX/TXT/NS/PTR/SRV/CAA records
- Service control: start/stop/restart/reload BIND via systemctl/rndc
- Configuration editor: edit named.conf.options/local with syntax validation
- DNS query testing: online dig tool
- Audit log: all operations logged with user/timestamp
- BIND9 backend, listening on port 53
This commit is contained in:
AI Agent
2026-07-09 17:24:44 +08:00
commit 0a18b94d84
19 changed files with 2587 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
instance/
*.db
*.bak
.env
.venv/
+242
View File
@@ -0,0 +1,242 @@
# DNS Web Manager
基于 BIND9 的 DNS 服务器 Web 管理界面,使用 Flask + SQLite 构建。
## 功能概览
| 功能 | 说明 |
|------|------|
| 仪表盘 | BIND 服务状态、域名/记录统计、服务控制(启动/停止/重启/重载) |
| 域名管理 | 创建/删除 Zone(支持 master/slave)、查看 Zone 详情、编辑原始 zone 文件 |
| 记录管理 | 添加/删除 DNS 记录(A、AAAA、CNAME、MX、TXT、NS、PTR、SRV、CAA |
| DNS 查询 | 在线 dig 测试,支持所有记录类型 |
| 配置管理 | 在线编辑 `named.conf.options``named.conf.local`,带语法校验 |
| 操作日志 | 全部操作的审计日志,支持分页 |
| 用户认证 | 登录/登出/修改密码 |
## 截图
### 仪表盘
- BIND 服务状态卡片(运行状态、开机自启)
- 域名数量、记录总数统计
- 服务控制按钮(启动/停止/重启/重载)
- 域名概览表格
- 最近操作日志
### 域名管理
- 域名列表(域名、类型、Zone 文件、记录数)
- 创建域名表单(域名、类型、NS 服务器、管理员邮箱)
- Zone 详情页:SOA 信息、记录列表、添加记录表单、原始文件编辑
### DNS 查询
- 输入域名 + 记录类型 + DNS 服务器,执行 dig 查询
- 显示完整 dig 输出
## 技术栈
- **DNS 服务器**: BIND 9.18+named
- **Web 框架**: Flask 3.0
- **数据库**: SQLite(用户认证 + 审计日志)
- **前端**: Jinja2 模板 + 原生 CSS(无前端框架依赖)
- **WSGI 服务器**: Gunicorn
## 目录结构
```
dns-service/
├── app.py # Flask 应用主文件(路由、模型、BIND 操作)
├── wsgi.py # Gunicorn 入口
├── init_db.py # 数据库初始化脚本
├── requirements.txt # Python 依赖
├── README.md
├── templates/ # Jinja2 模板
│ ├── base.html # 布局模板(导航栏 + 页脚)
│ ├── login.html # 登录页
│ ├── dashboard.html # 仪表盘
│ ├── zones.html # 域名列表
│ ├── zone_form.html # 创建域名表单
│ ├── zone_detail.html # Zone 详情 + 记录管理
│ ├── zone_raw.html # 原始 Zone 文件编辑
│ ├── query.html # DNS 查询测试
│ ├── config.html # BIND 配置管理
│ ├── logs.html # 操作日志
│ ├── change_password.html
│ └── error.html # 错误页
├── static/
│ └── style.css # 全部样式
└── instance/ # SQLite 数据库(运行时生成)
└── dns_web.db
```
## BIND 配置文件
| 文件 | 用途 | 路径 |
|------|------|------|
| `named.conf` | 主配置 | `/etc/bind/named.conf` |
| `named.conf.options` | 全局选项(监听端口、转发器、查询权限) | `/etc/bind/named.conf.options` |
| `named.conf.local` | Zone 声明(由 Web UI 自动管理) | `/etc/bind/named.conf.local` |
| zone files | DNS 记录文件 | `/etc/bind/zones/db.*` |
## 安装部署
### 1. 安装 BIND9
```bash
apt-get update
apt-get install -y bind9 bind9utils dnsutils
```
### 2. 配置 BIND
```bash
# 创建 zone 文件目录
mkdir -p /etc/bind/zones
chown bind:bind /etc/bind/zones
# named.conf.options(监听 53 端口)
cat > /etc/bind/named.conf.options << 'EOF'
options {
directory "/var/cache/bind";
listen-on port 53 { any; };
listen-on-v6 { none; };
forwarders { 8.8.8.8; 8.8.4.4; };
allow-query { any; };
allow-recursion { 127.0.0.0/8; 10.168.1.0/24; };
dnssec-validation auto;
auth-nxdomain no;
};
EOF
# named.conf(包含 options + local + default-zones
cat > /etc/bind/named.conf << 'EOF'
include "/etc/bind/named.conf.options";
include "/etc/bind/named.conf.local";
include "/etc/bind/named.conf.default-zones";
EOF
# named.conf.local(空,由 Web UI 自动填充)
cat > /etc/bind/named.conf.local << 'EOF'
// Local zone configurations - managed by DNS Web UI
EOF
# 启动 BIND
systemctl enable named
systemctl start named
```
### 3. 安装 Web 应用
```bash
git clone ssh://git@git.cnbugs.com:10022/AI-Agent/dns-service.git
cd dns-service
# 安装 Python 依赖
pip install -r requirements.txt
# 初始化数据库(创建默认 admin/admin 账号)
python3 init_db.py
# 启动(开发模式)
python3 app.py
```
### 4. 生产部署(Gunicorn + Systemd
```bash
# 创建 systemd 服务
cat > /etc/systemd/system/dns-web.service << 'EOF'
[Unit]
Description=DNS Web Manager
After=network.target named.service
[Service]
Type=notify
User=root
WorkingDirectory=/root/dns-service
ExecStart=/usr/local/bin/gunicorn --workers 2 --bind 0.0.0.0:5300 wsgi:app
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable dns-web
systemctl start dns-web
```
## 使用说明
### 登录
- 访问 `http://<服务器IP>:5300`
- 默认账号: `admin` / 密码: `admin`
- 首次登录后请修改密码
### 创建域名
1. 进入「域名管理」→「+ 添加域名」
2. 填写域名(如 `example.com`)、选择类型(master/slave
3. 可选填写 NS 服务器和管理员邮箱(留空则自动生成)
4. 点击「创建域名」
### 添加 DNS 记录
1. 进入域名详情页
2. 点击「+ 添加记录」
3. 填写记录名称、类型、TTL、数据
4. 点击「添加」
**记录类型说明:**
| 类型 | 数据格式 | 示例 |
|------|----------|------|
| A | IPv4 地址 | `10.168.1.100` |
| AAAA | IPv6 地址 | `2001:db8::1` |
| CNAME | 别名(FQDN,以 `.` 结尾) | `www.example.com.` |
| MX | 优先级 + 邮件服务器(FQDN) | `10 mail.example.com.` |
| TXT | 文本内容 | `"v=spf1 ~all"` |
| NS | NS 服务器(FQDN | `ns1.example.com.` |
| PTR | 反向解析目标(FQDN | `host.example.com.` |
| SRV | 优先级 权重 端口 目标 | `10 5 5060 sip.example.com.` |
### DNS 查询测试
1. 进入「DNS 查询」页面
2. 输入域名和记录类型
3. DNS 服务器留空默认查询本地 BIND127.0.0.1:53
4. 点击「查询」查看结果
### 配置管理
1. 进入「配置管理」页面
2. 可在线编辑 `named.conf.options`(监听端口、转发器等)
3. 可在线编辑 `named.conf.local`Zone 声明)
4. 保存时自动执行 `named-checkconf` 校验,通过后自动 `rndc reload`
### 服务控制
在「仪表盘」页面可控制 BIND 服务:
- **启动** - `systemctl start named`
- **停止** - `systemctl stop named`
- **重启** - `systemctl restart named`
- **重载配置** - `rndc reload`(不中断服务,热加载 zone 变更)
## 注意事项
1. **权限要求**: Web 应用需要 root 权限运行(用于操作 BIND 配置文件和 systemctl 命令)
2. **端口冲突**: 如果 53 端口被其他服务占用(如 dnsmasq),需先停掉对应服务
3. **Zone 文件权限**: zone 文件需要 `bind:bind` 所有权,Web UI 自动设置
4. **Serial 自动递增**: 每次添加/删除记录时,SOA Serial 自动递增(YYYYMMDDNN 格式)
5. **配置备份**: 编辑配置文件时自动创建 `.bak` 备份
## API 接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/status` | 获取 BIND 服务状态(需登录) |
## License
MIT
+1075
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env python3
"""Initialize the DNS Web Manager database."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import app, db, seed_admin
with app.app_context():
db.create_all()
seed_admin()
print("Database initialized successfully")
+4
View File
@@ -0,0 +1,4 @@
Flask==3.0.0
Flask-SQLAlchemy==3.1.1
Werkzeug==3.0.1
gunicorn==21.2.0
+540
View File
@@ -0,0 +1,540 @@
/* ═══ DNS Web Manager - Style Sheet ═══ */
:root {
--primary: #2563eb;
--primary-dark: #1d4ed8;
--success: #16a34a;
--warning: #d97706;
--danger: #dc2626;
--info: #0891b2;
--bg: #f8fafc;
--card-bg: #ffffff;
--border: #e2e8f0;
--text: #1e293b;
--text-muted: #64748b;
--text-light: #94a3b8;
--code-bg: #1e293b;
--code-text: #e2e8f0;
--badge-a: #3b82f6;
--badge-cname: #8b5cf6;
--badge-mx: #06b6d4;
--badge-txt: #f59e0b;
--badge-ns: #10b981;
--badge-soa: #ec4899;
--badge-ptr: #f97316;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06);
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
font-size: 14px;
}
/* ─── Navbar ─── */
.navbar {
background: var(--card-bg);
border-bottom: 1px solid var(--border);
padding: 0 24px;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: var(--shadow);
position: sticky;
top: 0;
z-index: 100;
}
.nav-brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 700;
}
.nav-brand a { color: var(--text); text-decoration: none; }
.nav-brand .logo { font-size: 24px; }
.nav-links {
display: flex;
gap: 4px;
}
.nav-links a {
padding: 8px 16px;
border-radius: 6px;
color: var(--text-muted);
text-decoration: none;
font-weight: 500;
transition: all 0.15s;
}
.nav-links a:hover { background: var(--bg); color: var(--text); }
.nav-links a.active { background: var(--primary); color: white; }
.nav-user {
display: flex;
align-items: center;
gap: 8px;
}
.nav-user > span {
color: var(--text-muted);
font-size: 13px;
}
/* ─── Container ─── */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
/* ─── Flash messages ─── */
.flash-messages {
margin-bottom: 16px;
}
.flash {
padding: 10px 16px;
border-radius: var(--radius);
margin-bottom: 8px;
font-weight: 500;
}
.flash-success { background: #dcfce7; color: #166534; border: 1px solid #86efac; }
.flash-error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
.flash-warning { background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; }
/* ─── Buttons ─── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: all 0.15s;
line-height: 1.4;
}
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-success { background: var(--success); color: white; }
.btn-success:hover { background: #15803d; }
.btn-warning { background: var(--warning); color: white; }
.btn-warning:hover { background: #b45309; }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover { background: #b91c1c; }
.btn-info { background: var(--info); color: white; }
.btn-info:hover { background: #0e7490; }
.btn-secondary { background: #e2e8f0; color: var(--text); }
.btn-secondary:hover { background: #cbd5e1; }
.btn-block { width: 100%; justify-content: center; }
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 4px; }
/* ─── Cards ─── */
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 16px;
}
.card-header {
padding: 12px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header h2 {
font-size: 16px;
font-weight: 600;
}
.card-body { padding: 20px; }
/* ─── Tables ─── */
.table {
width: 100%;
border-collapse: collapse;
}
.table th, .table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.table th {
font-weight: 600;
color: var(--text-muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.table tbody tr:hover { background: #f8fafc; }
.table-compact th, .table-compact td {
padding: 6px 12px;
}
/* ─── Badges ─── */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
background: var(--text-light);
color: white;
}
.badge-master { background: var(--success); }
.badge-slave { background: var(--info); }
.badge-A { background: var(--badge-a); }
.badge-AAAA { background: var(--badge-a); }
.badge-CNAME { background: var(--badge-cname); }
.badge-MX { background: var(--badge-mx); }
.badge-TXT { background: var(--badge-txt); }
.badge-NS { background: var(--badge-ns); }
.badge-SOA { background: var(--badge-soa); }
.badge-PTR { background: var(--badge-ptr); }
.badge-SRV { background: var(--badge-ptr); }
.badge-CAA { background: var(--badge-cname); }
.badge-ok { background: var(--success); }
.badge-err { background: var(--danger); }
/* ─── Forms ─── */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: 500;
font-size: 13px;
color: var(--text);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.15s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-hint { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
.form-hints { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
.form-hints p { font-size: 12px; color: var(--text-muted); }
.required { color: var(--danger); }
.form-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.form-row .form-group { flex: 1; margin-bottom: 0; }
.form-actions-vertical { align-self: flex-end; padding-bottom: 0 !important; }
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
/* ─── Dashboard ─── */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: var(--shadow);
}
.stat-icon {
font-size: 32px;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--bg);
}
.stat-ok { color: var(--success); }
.stat-err { color: var(--danger); }
.stat-label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
.stat-value { font-size: 24px; font-weight: 700; }
.stat-value-small { font-size: 13px; font-weight: 600; }
.stat-sub { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.text-ok { color: var(--success); }
.text-err { color: var(--danger); }
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1.5fr;
gap: 16px;
margin-bottom: 16px;
}
.service-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.service-note {
padding: 12px;
background: #eff6ff;
border-radius: 6px;
border: 1px solid #bfdbfe;
}
.service-note p { font-size: 13px; color: #1e40af; margin-bottom: 4px; }
/* ─── Login ─── */
.login-wrap {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - 120px);
}
.login-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
width: 400px;
overflow: hidden;
}
.login-header {
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
color: white;
padding: 32px;
text-align: center;
}
.login-header h1 { font-size: 24px; margin-bottom: 8px; }
.login-header p { opacity: 0.9; font-size: 14px; }
.login-form { padding: 32px; }
.login-hint {
padding: 16px 32px 32px;
text-align: center;
color: var(--text-muted);
font-size: 12px;
}
.login-hint code {
background: var(--bg);
padding: 2px 6px;
border-radius: 4px;
}
/* ─── Code editors ─── */
.code-editor {
font-family: "JetBrains Mono", "Fira Code", "Courier New", monospace;
font-size: 13px;
line-height: 1.5;
background: var(--code-bg);
color: var(--code-text);
border: 1px solid #334155;
border-radius: 6px;
padding: 12px;
resize: vertical;
tab-size: 4;
}
.config-viewer {
font-family: monospace;
font-size: 13px;
background: var(--code-bg);
color: var(--code-text);
padding: 16px;
border-radius: 6px;
overflow-x: auto;
white-space: pre-wrap;
}
.query-output {
font-family: monospace;
font-size: 13px;
background: var(--code-bg);
color: var(--code-text);
padding: 16px;
border-radius: 6px;
overflow-x: auto;
white-space: pre;
max-height: 500px;
overflow-y: auto;
}
.config-status {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-radius: var(--radius);
margin-bottom: 16px;
font-weight: 500;
}
.config-ok { background: #dcfce7; color: #166534; }
.config-err { background: #fee2e2; color: #991b1b; flex-direction: column; align-items: flex-start; }
.config-error {
font-family: monospace;
font-size: 12px;
background: rgba(0,0,0,0.06);
padding: 8px;
border-radius: 4px;
margin-top: 8px;
width: 100%;
}
/* ─── Page header ─── */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.page-header h1 { font-size: 24px; font-weight: 700; }
.zone-meta {
display: flex;
align-items: center;
gap: 12px;
margin-top: 4px;
}
/* ─── Record form ─── */
.record-form-wrap {
background: #f8fafc;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 16px;
}
.record-form .form-row { flex-wrap: wrap; }
/* ─── Query ─── */
.query-meta {
display: flex;
gap: 24px;
margin-bottom: 12px;
font-size: 13px;
}
/* ─── Misc ─── */
.text-muted { color: var(--text-muted); }
.text-mono { font-family: "JetBrains Mono", monospace; }
.text-sm { font-size: 12px; }
.empty-state {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
.empty-state p { margin-bottom: 16px; }
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 16px;
}
.page-info { font-size: 13px; color: var(--text-muted); }
.footer {
text-align: center;
padding: 24px;
color: var(--text-light);
font-size: 12px;
}
.footer a { color: var(--primary); text-decoration: none; }
.error-page {
text-align: center;
padding: 80px 24px;
}
.error-code {
font-size: 72px;
font-weight: 800;
color: var(--danger);
margin-bottom: 8px;
}
.error-msg {
font-size: 18px;
color: var(--text-muted);
margin-bottom: 24px;
}
/* ─── Responsive ─── */
@media (max-width: 768px) {
.navbar { padding: 0 12px; flex-wrap: wrap; height: auto; }
.nav-links { width: 100%; overflow-x: auto; padding: 4px 0; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.dashboard-grid { grid-template-columns: 1fr; }
.form-row { flex-direction: column; }
.page-header { flex-direction: column; gap: 12px; align-items: flex-start; }
}
+48
View File
@@ -0,0 +1,48 @@
<!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 %}DNS 管理系统{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
{% if current_user %}
<nav class="navbar">
<div class="nav-brand">
<span class="logo">🌐</span>
<a href="{{ url_for('dashboard') }}">DNS 管理系统</a>
</div>
<div class="nav-links">
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint == 'dashboard' %}active{% endif %}">仪表盘</a>
<a href="{{ url_for('zone_list') }}" class="{% if request.endpoint in ('zone_list', 'zone_create', 'zone_detail', 'zone_raw') %}active{% endif %}">域名管理</a>
<a href="{{ url_for('query_test') }}" class="{% if request.endpoint == 'query_test' %}active{% endif %}">DNS 查询</a>
<a href="{{ url_for('config_view') }}" class="{% if request.endpoint == 'config_view' %}active{% endif %}">配置管理</a>
<a href="{{ url_for('logs') }}" class="{% if request.endpoint == 'logs' %}active{% endif %}">操作日志</a>
</div>
<div class="nav-user">
<span>{{ current_user.username }}</span>
<a href="{{ url_for('change_password') }}" class="btn-sm">改密</a>
<a href="{{ url_for('logout') }}" class="btn-sm btn-danger">退出</a>
</div>
</nav>
{% endif %}
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="footer">
<p>BIND DNS Web Manager &copy; 2026 | BIND 9.18+ | <a href="https://www.isc.org/bind/" target="_blank">ISC BIND</a></p>
</footer>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}修改密码 - DNS{% endblock %}
{% block content %}
<div class="page-header">
<h1>修改密码</h1>
</div>
<div class="card" style="max-width:500px;margin:0 auto">
<div class="card-body">
<form method="POST" class="form">
<div class="form-group">
<label>旧密码 <span class="required">*</span></label>
<input type="password" name="old_password" required>
</div>
<div class="form-group">
<label>新密码 <span class="required">*</span></label>
<input type="password" name="new_password" required minlength="6">
<p class="form-hint">至少 6 位</p>
</div>
<div class="form-group">
<label>确认新密码 <span class="required">*</span></label>
<input type="password" name="confirm_password" required>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">修改密码</button>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">取消</a>
</div>
</form>
</div>
</div>
{% endblock %}
+90
View File
@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}配置管理 - DNS{% endblock %}
{% block content %}
<div class="page-header">
<h1>BIND 配置管理</h1>
</div>
<div class="config-status {% if config_ok %}config-ok{% else %}config-err{% endif %}">
<span class="config-icon">{% if config_ok %}✅{% else %}❌{% endif %}</span>
<span>配置检查: {% if config_ok %}通过{% else %}有错误{% endif %}</span>
{% if not config_ok %}
<pre class="config-error">{{ config_msg }}</pre>
{% endif %}
</div>
<div class="card">
<div class="card-header">
<h2>named.conf (主配置)</h2>
<span class="text-muted text-sm">只读</span>
</div>
<div class="card-body">
<pre class="config-viewer">{{ main_content }}</pre>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>named.conf.options (选项配置)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('config_options_save') }}">
<div class="form-group">
<textarea name="content" class="code-editor" rows="20" spellcheck="false">{{ options_content }}</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存并重载</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>named.conf.local (本地区域配置)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('config_local_save') }}">
<div class="form-group">
<textarea name="content" class="code-editor" rows="20" spellcheck="false">{{ local_content }}</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存并重载</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><h2>配置说明</h2></div>
<div class="card-body">
<table class="table">
<thead>
<tr><th>配置文件</th><th>用途</th><th>路径</th></tr>
</thead>
<tbody>
<tr>
<td><strong>named.conf</strong></td>
<td>主配置,包含其他配置文件</td>
<td class="text-mono text-sm">/etc/bind/named.conf</td>
</tr>
<tr>
<td><strong>named.conf.options</strong></td>
<td>全局选项: 监听端口/地址、转发器、查询权限等</td>
<td class="text-mono text-sm">/etc/bind/named.conf.options</td>
</tr>
<tr>
<td><strong>named.conf.local</strong></td>
<td>区域(zone)声明 - 由 Web UI 自动管理</td>
<td class="text-mono text-sm">/etc/bind/named.conf.local</td>
</tr>
<tr>
<td><strong>zone files</strong></td>
<td>DNS 记录文件</td>
<td class="text-mono text-sm">/etc/bind/zones/db.*</td>
</tr>
</tbody>
</table>
</div>
</div>
{% endblock %}
+125
View File
@@ -0,0 +1,125 @@
{% extends "base.html" %}
{% block title %}仪表盘 - DNS 管理{% endblock %}
{% block content %}
<div class="dashboard">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon {% if status.active %}stat-ok{% else %}stat-err{% endif %}"></div>
<div class="stat-info">
<div class="stat-label">BIND 服务</div>
<div class="stat-value {% if status.active %}text-ok{% else %}text-err{% endif %}">
{{ status.status | upper }}
</div>
<div class="stat-sub">开机自启: {{ '是' if status.enabled else '否' }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🌍</div>
<div class="stat-info">
<div class="stat-label">域名数量</div>
<div class="stat-value">{{ zone_count }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">📋</div>
<div class="stat-info">
<div class="stat-label">记录总数</div>
<div class="stat-value">{{ total_records }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">⚙️</div>
<div class="stat-info">
<div class="stat-label">BIND 版本</div>
<div class="stat-value-small">{{ version[:40] }}</div>
</div>
</div>
</div>
<div class="dashboard-grid">
<div class="card">
<div class="card-header">
<h2>服务控制</h2>
</div>
<div class="card-body">
<div class="service-controls">
<a href="{{ url_for('service_control', action='start') }}" class="btn btn-success" onclick="return confirm('启动 BIND 服务?')">▶ 启动</a>
<a href="{{ url_for('service_control', action='stop') }}" class="btn btn-warning" onclick="return confirm('停止 BIND 服务?')">⏹ 停止</a>
<a href="{{ url_for('service_control', action='restart') }}" class="btn btn-info" onclick="return confirm('重启 BIND 服务?')">🔄 重启</a>
<a href="{{ url_for('service_control', action='reload') }}" class="btn btn-primary" onclick="return confirm('重载 BIND 配置?')">🔁 重载配置</a>
</div>
<div class="service-note">
<p>📝 <strong>提示:</strong> BIND 监听标准 53 端口,可直接用 <code>dig a.wxj.aa</code> 查询。</p>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>域名概览</h2>
<a href="{{ url_for('zone_create') }}" class="btn btn-primary btn-sm">+ 添加域名</a>
</div>
<div class="card-body">
{% if zones %}
<table class="table">
<thead>
<tr>
<th>域名</th>
<th>类型</th>
<th>记录数</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for z in zones %}
<tr>
<td><a href="{{ url_for('zone_detail', zone_name=z.name) }}"><strong>{{ z.name }}</strong></a></td>
<td><span class="badge">{{ z.type }}</span></td>
<td>{{ z.record_count }}</td>
<td>
<a href="{{ url_for('zone_detail', zone_name=z.name) }}" class="btn-sm btn-primary">管理</a>
<a href="{{ url_for('zone_raw', zone_name=z.name) }}" class="btn-sm btn-info">原始文件</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>还没有任何域名</p>
<a href="{{ url_for('zone_create') }}" class="btn btn-primary">+ 创建第一个域名</a>
</div>
{% endif %}
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2>最近操作</h2>
<a href="{{ url_for('logs') }}" class="btn-sm btn-info">查看全部</a>
</div>
<div class="card-body">
{% if recent_logs %}
<table class="table">
<thead>
<tr><th>时间</th><th>用户</th><th>操作</th><th>详情</th></tr>
</thead>
<tbody>
{% for log in recent_logs %}
<tr>
<td class="text-mono">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>{{ log.user }}</td>
<td>{{ log.action }}</td>
<td class="text-mono text-sm">{{ log.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">暂无操作记录</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}错误 {{ code }}{% endblock %}
{% block content %}
<div class="error-page">
<div class="error-code">{{ code }}</div>
<div class="error-msg">{{ message }}</div>
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">返回首页</a>
</div>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}登录 - DNS 管理{% endblock %}
{% block content %}
<div class="login-wrap">
<div class="login-card">
<div class="login-header">
<h1>🌐 DNS 管理系统</h1>
<p>BIND9 Web 管理界面</p>
</div>
<form method="POST" class="login-form">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" placeholder="admin" autofocus required>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" placeholder="admin" required>
</div>
<button type="submit" class="btn btn-primary btn-block">登 录</button>
</form>
<div class="login-hint">
<p>默认账号: <code>admin</code> / <code>admin</code></p>
<p>首次登录后请修改密码</p>
</div>
</div>
</div>
{% endblock %}
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}操作日志 - DNS{% endblock %}
{% block content %}
<div class="page-header">
<h1>操作日志</h1>
</div>
<div class="card">
<div class="card-body">
{% if logs %}
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>时间</th>
<th>用户</th>
<th>操作</th>
<th>详情</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td class="text-muted">{{ log.id }}</td>
<td class="text-mono text-sm">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>{{ log.user }}</td>
<td>{{ log.action }}</td>
<td class="text-mono text-sm">{{ log.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pagination">
{% if pagination.has_prev %}
<a href="{{ url_for('logs', page=pagination.prev_num) }}" class="btn-sm btn-secondary">← 上一页</a>
{% endif %}
<span class="page-info">第 {{ pagination.page }} / {{ pagination.pages }} 页 (共 {{ pagination.total }} 条)</span>
{% if pagination.has_next %}
<a href="{{ url_for('logs', page=pagination.next_num) }}" class="btn-sm btn-secondary">下一页 →</a>
{% endif %}
</div>
{% else %}
<div class="empty-state"><p>暂无操作记录</p></div>
{% endif %}
</div>
</div>
{% endblock %}
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}DNS 查询测试 - DNS{% endblock %}
{% block content %}
<div class="page-header">
<h1>DNS 查询测试</h1>
</div>
<div class="card">
<div class="card-body">
<form method="POST" class="form">
<div class="form-row">
<div class="form-group" style="flex:1">
<label>域名</label>
<input type="text" name="domain" placeholder="example.com" required
value="{{ result.domain if result else '' }}">
</div>
<div class="form-group">
<label>记录类型</label>
<select name="qtype" style="width:120px">
{% for t in qtypes %}
<option value="{{ t }}" {% if result and result.qtype == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>DNS 服务器</label>
<input type="text" name="server" placeholder="留空=本地:53"
value="{{ result.server if result else '' }}" style="width:200px">
</div>
<div class="form-group form-actions-vertical">
<button type="submit" class="btn btn-primary">🔍 查询</button>
</div>
</div>
<p class="form-hint">留空 DNS 服务器默认查询本地 BIND (127.0.0.1:53)</p>
</form>
</div>
</div>
{% if result %}
<div class="card">
<div class="card-header">
<h2>查询结果</h2>
<span class="badge {% if result.success %}badge-ok{% else %}badge-err{% endif %}">
{% if result.success %}成功{% else %}失败{% endif %}
</span>
</div>
<div class="card-body">
<div class="query-meta">
<span><strong>域名:</strong> {{ result.domain }}</span>
<span><strong>类型:</strong> {{ result.qtype }}</span>
<span><strong>服务器:</strong> {{ result.server }}</span>
</div>
<pre class="query-output">{{ result.output }}</pre>
</div>
</div>
{% endif %}
{% endblock %}
+123
View File
@@ -0,0 +1,123 @@
{% extends "base.html" %}
{% block title %}{{ zone.name }} - DNS{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>{{ zone.name }}</h1>
<div class="zone-meta">
<span class="badge badge-{{ zone.type }}">{{ zone.type }}</span>
<span class="text-mono text-sm">{{ zone.filename }}</span>
<span class="text-muted text-sm">共 {{ records|length }} 条记录</span>
</div>
</div>
<div>
<a href="{{ url_for('zone_raw', zone_name=zone.name) }}" class="btn btn-info btn-sm">📝 原始文件</a>
<form method="POST" action="{{ url_for('zone_delete', zone_name=zone.name) }}" style="display:inline" onsubmit="return confirm('确定删除域名 {{ zone.name }} 及所有记录?')">
<button type="submit" class="btn btn-danger btn-sm">🗑 删除域名</button>
</form>
</div>
</div>
{% if soa %}
<div class="card">
<div class="card-header"><h2>SOA 记录</h2></div>
<div class="card-body">
<table class="table table-compact">
<tr>
<td class="text-muted">主 NS</td><td class="text-mono">{{ soa.raw.split()[0] if soa.raw else '-' }}</td>
<td class="text-muted">管理员</td><td class="text-mono">{{ soa.raw.split()[1] if soa.raw and soa.raw.split()|length > 1 else '-' }}</td>
</tr>
<tr>
<td class="text-muted">Serial</td><td class="text-mono">{{ soa.raw.split()[2] if soa.raw and soa.raw.split()|length > 2 else '-' }}</td>
<td class="text-muted">Refresh</td><td class="text-mono">{{ soa.raw.split()[3] if soa.raw and soa.raw.split()|length > 3 else '-' }}</td>
</tr>
<tr>
<td class="text-muted">Retry</td><td class="text-mono">{{ soa.raw.split()[4] if soa.raw and soa.raw.split()|length > 4 else '-' }}</td>
<td class="text-muted">Expire</td><td class="text-mono">{{ soa.raw.split()[5] if soa.raw and soa.raw.split()|length > 5 else '-' }}</td>
</tr>
<tr>
<td class="text-muted">Min TTL</td><td class="text-mono">{{ soa.raw.split()[6] if soa.raw and soa.raw.split()|length > 6 else '-' }}</td>
<td colspan="2"></td>
</tr>
</table>
</div>
</div>
{% endif %}
<div class="card">
<div class="card-header">
<h2>DNS 记录</h2>
<button type="button" class="btn btn-primary btn-sm" onclick="document.getElementById('addRecordForm').style.display='block'">+ 添加记录</button>
</div>
<div class="card-body">
<div id="addRecordForm" style="display:none" class="record-form-wrap">
<form method="POST" action="{{ url_for('record_add', zone_name=zone.name) }}" class="record-form">
<div class="form-row">
<div class="form-group">
<label>名称</label>
<input type="text" name="name" placeholder="@ 或 www" style="width:150px">
</div>
<div class="form-group">
<label>TTL</label>
<input type="text" name="ttl" placeholder="留空=默认" style="width:100px">
</div>
<div class="form-group">
<label>类型</label>
<select name="type" id="recordType" style="width:100px">
{% for t in record_types %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="flex:1">
<label>数据</label>
<input type="text" name="data" placeholder="如 192.168.1.1 或 target.example.com." style="width:100%">
</div>
<div class="form-group form-actions-vertical">
<button type="submit" class="btn btn-primary btn-sm">添加</button>
</div>
</div>
<div class="form-hints">
<p>A: IPv4地址 | AAAA: IPv6地址 | CNAME: 别名(以.结尾) | MX: 优先级 目标(以.结尾)</p>
<p>TXT: 文本 | NS: NS服务器(以.结尾) | PTR: 反向解析(以.结尾) | SRV: 优先级 权重 端口 目标</p>
</div>
</form>
</div>
{% if records %}
<table class="table">
<thead>
<tr>
<th>#</th>
<th>名称</th>
<th>TTL</th>
<th>类型</th>
<th>数据</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for r in records %}
<tr>
<td class="text-muted">{{ loop.index0 }}</td>
<td class="text-mono">{{ r.name }}</td>
<td class="text-mono text-muted">{{ r.ttl }}</td>
<td><span class="badge badge-{{ r.type }}">{{ r.type }}</span></td>
<td class="text-mono">{{ r.data }}</td>
<td>
<form method="POST" action="{{ url_for('record_delete', zone_name=zone.name, idx=loop.index0) }}" style="display:inline" onsubmit="return confirm('确定删除 {{ r.name }} {{ r.type }} {{ r.data }}?')">
<button type="submit" class="btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>此域名还没有记录</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+45
View File
@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}{% if zone %}编辑域名{% else %}创建域名{% endif %} - DNS{% endblock %}
{% block content %}
<div class="page-header">
<h1>{% if zone %}编辑域名{% else %}创建域名{% endif %}</h1>
</div>
<div class="card">
<div class="card-body">
<form method="POST" class="form">
<div class="form-group">
<label>域名 <span class="required">*</span></label>
<input type="text" name="zone_name" placeholder="example.com" required
value="{{ zone.name if zone else '' }}">
<p class="form-hint">不含末尾的点,例如 example.com</p>
</div>
<div class="form-group">
<label>类型</label>
<select name="zone_type">
<option value="master" selected>master (主域)</option>
<option value="slave">slave (从域)</option>
</select>
</div>
<div class="form-group">
<label>主 NS 服务器</label>
<input type="text" name="ns_server" placeholder="ns1.example.com">
<p class="form-hint">留空则自动使用 ns1.<域名></p>
</div>
<div class="form-group">
<label>管理员邮箱</label>
<input type="text" name="admin_email" placeholder="admin@example.com">
<p class="form-hint">留空则自动使用 admin.<域名>,会自动将 @ 转为 .</p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">创建域名</button>
<a href="{{ url_for('zone_list') }}" class="btn btn-secondary">取消</a>
</div>
</form>
</div>
</div>
{% endblock %}
+45
View File
@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}{{ zone.name }} 原始文件 - DNS{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>原始 Zone 文件: {{ zone.name }}</h1>
<p class="text-muted text-sm">文件: {{ zone.file }}</p>
</div>
<a href="{{ url_for('zone_detail', zone_name=zone.name) }}" class="btn btn-secondary">← 返回</a>
</div>
<div class="card">
<div class="card-body">
<form method="POST">
<div class="form-group">
<textarea name="content" class="code-editor" rows="25" spellcheck="false">{{ content }}</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存并重载</button>
<a href="{{ url_for('zone_detail', zone_name=zone.name) }}" class="btn btn-secondary">取消</a>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><h2>格式说明</h2></div>
<div class="card-body">
<pre class="text-sm text-muted">
$TTL 86400 ; 默认 TTL
@ IN SOA ns1.example.com. admin.example.com. (
2026070901 ; Serial - 每次修改需递增
3600 ; Refresh - 从服务器刷新间隔
900 ; Retry - 刷新失败重试间隔
604800 ; Expire - 过期时间
86400 ; Minimum - 否定缓存TTL
)
@ IN NS ns1.example.com. ; NS 记录
www IN A 192.168.1.100 ; A 记录
mail IN A 192.168.1.200 ; A 记录
@ IN MX 10 mail.example.com. ; MX 记录
</pre>
</div>
</div>
{% endblock %}
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}域名管理 - DNS{% endblock %}
{% block content %}
<div class="page-header">
<h1>域名管理</h1>
<a href="{{ url_for('zone_create') }}" class="btn btn-primary">+ 添加域名</a>
</div>
<div class="card">
<div class="card-body">
{% if zones %}
<table class="table">
<thead>
<tr>
<th>域名</th>
<th>类型</th>
<th>Zone 文件</th>
<th>记录数</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for z in zones %}
<tr>
<td><a href="{{ url_for('zone_detail', zone_name=z.name) }}"><strong>{{ z.name }}</strong></a></td>
<td><span class="badge badge-{{ z.type }}">{{ z.type }}</span></td>
<td class="text-mono text-sm">{{ z.filename }}</td>
<td>{{ z.record_count }}</td>
<td>
<a href="{{ url_for('zone_detail', zone_name=z.name) }}" class="btn-sm btn-primary">管理记录</a>
<a href="{{ url_for('zone_raw', zone_name=z.name) }}" class="btn-sm btn-info">原始文件</a>
<form method="POST" action="{{ url_for('zone_delete', zone_name=z.name) }}" style="display:inline" onsubmit="return confirm('确定删除域名 {{ z.name }} 及所有记录?')">
<button type="submit" class="btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>还没有任何域名</p>
<a href="{{ url_for('zone_create') }}" class="btn btn-primary">+ 创建第一个域名</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
import os
os.makedirs(os.path.join(os.path.dirname(__file__), "instance"), exist_ok=True)
from app import app, db, seed_admin
# Initialize DB - safe for multi-worker gunicorn
with app.app_context():
try:
db.create_all()
seed_admin()
except Exception as e:
print(f"[wsgi] DB init: {e} (likely race condition with another worker, safe to ignore)")