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:
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
instance/
|
||||
*.db
|
||||
*.bak
|
||||
.env
|
||||
.venv/
|
||||
@@ -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 服务器留空默认查询本地 BIND(127.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
|
||||
+11
@@ -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")
|
||||
@@ -0,0 +1,4 @@
|
||||
Flask==3.0.0
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Werkzeug==3.0.1
|
||||
gunicorn==21.2.0
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 © 2026 | BIND 9.18+ | <a href="https://www.isc.org/bind/" target="_blank">ISC BIND</a></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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)")
|
||||
Reference in New Issue
Block a user