From 0a18b94d84d6b0242ebbbbe54edaf0f14cb6c4f2 Mon Sep 17 00:00:00 2001 From: AI Agent Date: Thu, 9 Jul 2026 17:24:44 +0800 Subject: [PATCH] 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 --- .gitignore | 7 + README.md | 242 +++++++ app.py | 1075 ++++++++++++++++++++++++++++++++ init_db.py | 11 + requirements.txt | 4 + static/style.css | 540 ++++++++++++++++ templates/base.html | 48 ++ templates/change_password.html | 31 + templates/config.html | 90 +++ templates/dashboard.html | 125 ++++ templates/error.html | 9 + templates/login.html | 27 + templates/logs.html | 48 ++ templates/query.html | 57 ++ templates/zone_detail.html | 123 ++++ templates/zone_form.html | 45 ++ templates/zone_raw.html | 45 ++ templates/zones.html | 48 ++ wsgi.py | 12 + 19 files changed, 2587 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 init_db.py create mode 100644 requirements.txt create mode 100644 static/style.css create mode 100644 templates/base.html create mode 100644 templates/change_password.html create mode 100644 templates/config.html create mode 100644 templates/dashboard.html create mode 100644 templates/error.html create mode 100644 templates/login.html create mode 100644 templates/logs.html create mode 100644 templates/query.html create mode 100644 templates/zone_detail.html create mode 100644 templates/zone_form.html create mode 100644 templates/zone_raw.html create mode 100644 templates/zones.html create mode 100644 wsgi.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6fbaab --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +instance/ +*.db +*.bak +.env +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..893d04c --- /dev/null +++ b/README.md @@ -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 diff --git a/app.py b/app.py new file mode 100644 index 0000000..86d628a --- /dev/null +++ b/app.py @@ -0,0 +1,1075 @@ +#!/usr/bin/env python3 +""" +BIND DNS Web Manager - Web interface for managing BIND9 DNS server +Features: zone management, record CRUD, service control, config editing, query testing +""" +import os +import re +import json +import subprocess +import shlex +import ipaddress +from datetime import datetime, timezone +from functools import wraps + +from flask import ( + Flask, render_template, redirect, url_for, request, session, + flash, jsonify, abort, Response +) +from flask_sqlalchemy import SQLAlchemy +from werkzeug.security import generate_password_hash, check_password_hash + +app = Flask(__name__) +app.config["SECRET_KEY"] = os.environ.get("DNS_WEB_SECRET", "dns-web-mgmt-secret-key-2026") +app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{os.path.join(app.instance_path, 'dns_web.db')}" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +db = SQLAlchemy(app) + +# ─── BIND paths ─── +BIND_CONF_DIR = "/etc/bind" +BIND_ZONES_DIR = "/etc/bind/zones" +BIND_CONF_LOCAL = "/etc/bind/named.conf.local" +BIND_CONF_OPTIONS = "/etc/bind/named.conf.options" +BIND_RNDC_KEY = "/etc/bind/rndc.key" +BIND_SERVICE = "named" + +# ─── Record types ─── +RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR", "SRV", "CAA", "SOA"] +RECORD_FIELDS = { + "A": ["ttl", "ip"], + "AAAA": ["ttl", "ip"], + "CNAME": ["ttl", "target"], + "MX": ["ttl", "priority", "target"], + "TXT": ["ttl", "text"], + "NS": ["ttl", "target"], + "PTR": ["ttl", "target"], + "SRV": ["ttl", "priority", "weight", "port", "target"], + "CAA": ["ttl", "flags", "tag", "value"], +} + + +# ════════════════════════════════════════════════════════════════════ +# Models +# ════════════════════════════════════════════════════════════════════ + +class User(db.Model): + __tablename__ = "users" + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password_hash = db.Column(db.String(200), nullable=False) + role = db.Column(db.String(20), default="admin") + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + def set_password(self, pw): + self.password_hash = generate_password_hash(pw) + + def check_password(self, pw): + return check_password_hash(self.password_hash, pw) + + +class AuditLog(db.Model): + __tablename__ = "audit_logs" + id = db.Column(db.Integer, primary_key=True) + user = db.Column(db.String(80)) + action = db.Column(db.String(200), nullable=False) + detail = db.Column(db.Text) + timestamp = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + @staticmethod + def log(user, action, detail=""): + db.session.add(AuditLog(user=user, action=action, detail=detail)) + db.session.commit() + + +# ════════════════════════════════════════════════════════════════════ +# Auth +# ════════════════════════════════════════════════════════════════════ + +def current_user(): + uid = session.get("user_id") + if uid is None: + return None + return db.session.get(User, uid) + + +def login_required(f): + @wraps(f) + def wrapper(*a, **kw): + if session.get("user_id") is None: + return redirect(url_for("login", next=request.path)) + return f(*a, **kw) + return wrapper + + +@app.context_processor +def inject_globals(): + return dict(current_user=current_user()) + + +# ════════════════════════════════════════════════════════════════════ +# BIND helpers +# ════════════════════════════════════════════════════════════════════ + +def run_cmd(cmd, timeout=10): + """Run shell command, return (returncode, stdout, stderr).""" + try: + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout.strip(), r.stderr.strip() + except subprocess.TimeoutExpired: + return -1, "", "Command timed out" + except Exception as e: + return -1, "", str(e) + + +def bind_service_status(): + rc, out, _ = run_cmd("systemctl is-active named 2>&1") + enabled_rc, _, _ = run_cmd("systemctl is-enabled named 2>&1") + return { + "active": out.strip() == "active", + "status": out.strip(), + "enabled": enabled_rc == 0 and "enabled" in run_cmd("systemctl is-enabled named 2>&1")[1], + } + + +def bind_reload(): + """Reload BIND zones via rndc.""" + rc, out, err = run_cmd("rndc reload 2>&1") + return rc == 0, out + err + + +def bind_version(): + rc, out, _ = run_cmd("named -v 2>&1") + return out.strip() + + +def parse_named_conf_local(): + """Parse named.conf.local, return list of zone dicts.""" + zones = [] + try: + with open(BIND_CONF_LOCAL) as f: + content = f.read() + except FileNotFoundError: + return zones + + # Match: zone "example.com" { type master; file "/etc/bind/zones/db.example.com"; }; + pattern = re.compile( + r'zone\s+"([^"]+)"\s*\{\s*' + r'type\s+(\w+)\s*;\s*' + r'file\s+"([^"]+)"\s*;\s*' + r'(?:allow-update\s*\{[^}]*\}\s*;\s*)?' + r'\}\s*;', + re.MULTILINE + ) + for m in pattern.finditer(content): + zone_name = m.group(1) + zone_type = m.group(2) + zone_file = m.group(3) + zones.append({ + "name": zone_name, + "type": zone_type, + "file": zone_file, + "filename": os.path.basename(zone_file), + }) + return zones + + +def write_named_conf_local(zones): + """Rewrite named.conf.local from zone list.""" + lines = ['// Local zone configurations - managed by DNS Web UI\n'] + for z in zones: + lines.append(f'\nzone "{z["name"]}" {{\n') + lines.append(f' type {z["type"]};\n') + lines.append(f' file "{z["file"]}";\n') + lines.append(' allow-update { none; };\n') + lines.append('};\n') + content = ''.join(lines) + with open(BIND_CONF_LOCAL, 'w') as f: + f.write(content) + # Ensure permissions + run_cmd(f"chown root:bind {BIND_CONF_LOCAL}") + run_cmd(f"chmod 644 {BIND_CONF_LOCAL}") + + +def parse_zone_file(filepath): + """Parse a BIND zone file, return SOA + records list. + + Properly handles multi-line SOA with parentheses, inline comments, + $TTL/$ORIGIN directives, and blank-name continuation records. + """ + records = [] + soa = {} + try: + with open(filepath) as f: + raw_lines = f.readlines() + except FileNotFoundError: + return soa, records + + # ── Step 1: Normalise: strip comments, join multi-line SOA ── + # Remove inline comments (everything after unquoted ;) and track lines + clean_lines = [] + in_parens = False + merged = "" + + for line in raw_lines: + # Strip inline comments (simple approach - no quoted ; in our zone files) + if ';' in line: + # Check if ; is inside quotes + in_quote = False + cut_pos = len(line) + for i, ch in enumerate(line): + if ch == '"': + in_quote = not in_quote + elif ch == ';' and not in_quote: + cut_pos = i + break + line = line[:cut_pos] + + stripped = line.strip() + + if in_parens: + merged += " " + stripped + if ')' in stripped: + in_parens = False + clean_lines.append(merged.replace('(', ' ').replace(')', ' ').strip()) + merged = "" + continue + + if '(' in stripped and ')' not in stripped: + in_parens = True + merged = stripped.replace('(', ' ') + continue + + if stripped: + clean_lines.append(stripped) + + # ── Step 2: Parse normalised lines ── + origin = "" + default_ttl = "86400" + prev_name = "" + + for line in clean_lines: + parts = line.split() + + if parts[0] == '$TTL': + default_ttl = parts[1] if len(parts) > 1 else default_ttl + continue + if parts[0] == '$ORIGIN': + origin = parts[1] if len(parts) > 1 else origin + continue + + # SOA record + if 'SOA' in parts: + soa_idx = parts.index('SOA') + soa_name = parts[0] if parts[0] != '@' else (origin.rstrip('.') if origin else '@') + soa["name"] = soa_name + + # SOA data: ns email serial refresh retry expire minimum + soa_data = parts[soa_idx + 1:] + soa["raw"] = ' '.join(soa_data) + + if len(soa_data) >= 7: + soa["ns"] = soa_data[0] + soa["email"] = soa_data[1] + soa["serial"] = soa_data[2] + soa["refresh"] = soa_data[3] + soa["retry"] = soa_data[4] + soa["expire"] = soa_data[5] + soa["minimum"] = soa_data[6] + continue + + # Skip blank-only lines (shouldn't happen after normalisation) + if not parts: + continue + + # Regular record + name = parts[0] + if name == '@': + name = origin.rstrip('.') if origin else '@' + elif name == '': + name = prev_name + else: + prev_name = name + + idx = 1 + ttl = "" + if idx < len(parts) and parts[idx].isdigit(): + ttl = parts[idx] + idx += 1 + + # Class + if idx < len(parts) and parts[idx] in ('IN', 'CH', 'HS'): + idx += 1 + + if idx >= len(parts): + continue + + rtype = parts[idx].upper() + idx += 1 + rdata = ' '.join(parts[idx:]) + + records.append({ + "name": name, + "ttl": ttl, + "type": rtype, + "data": rdata.strip(), + }) + + return soa, records + + +def generate_zone_file(zone_name, records, soa=None): + """Generate zone file content from records list.""" + now = datetime.now().strftime("%Y%m%d01") + if not soa: + soa = { + "ns": f"ns1.{zone_name}.", + "email": f"admin.{zone_name}.", + "serial": now, + "refresh": "3600", + "retry": "900", + "expire": "604800", + "minimum": "86400", + } + + lines = [ + f"$TTL {soa.get('minimum', '86400')}\n", + f'@ IN SOA {soa["ns"]} {soa["email"]} (\n', + f' {soa["serial"]} ; Serial\n', + f' {soa["refresh"]} ; Refresh\n', + f' {soa["retry"]} ; Retry\n', + f' {soa["expire"]} ; Expire\n', + f' {soa["minimum"]} ; Minimum TTL\n', + f' )\n', + ] + + # Add NS records for the zone itself + has_ns = any(r["type"] == "NS" for r in records) + if not has_ns: + lines.append(f'@ IN NS ns1.{zone_name}.\n') + lines.append(f'ns1 IN A 127.0.0.1\n') + + for r in records: + name = r["name"] + ttl = f' {r.get("ttl", "")}' if r.get("ttl") else '' + rtype = r["type"] + data = r["data"] + lines.append(f'{name}{ttl} IN {rtype:<6} {data}\n') + + return ''.join(lines) + + +def append_record_to_file(filepath, name, ttl, rtype, data): + """Append a new record to the zone file and bump the serial. + + Returns (success, message). + """ + try: + with open(filepath) as f: + content = f.read() + except FileNotFoundError: + return False, "Zone file not found" + + # Build the new record line + ttl_str = f'{ttl} ' if ttl else '' + new_line = f'{name} {ttl_str}IN {rtype:<6} {data}\n' + + # Append record at end of file + content = content.rstrip('\n') + '\n' + new_line + + # Update serial + content = bump_serial_in_content(content) + + with open(filepath, 'w') as f: + f.write(content) + run_cmd(f"chown bind:bind {filepath}") + + return True, "OK" + + +def delete_record_from_file(filepath, zone_name, idx): + """Delete the Nth record (0-indexed, excluding SOA) from the zone file. + + Returns (success, deleted_record_str, message). + """ + soa, records = parse_zone_file(filepath) + if idx < 0 or idx >= len(records): + return False, "", "Record index out of range" + + deleted = records[idx] + + # Read raw lines and find the matching record line to remove + with open(filepath) as f: + raw_lines = f.readlines() + + # We need to find the Nth non-SOA, non-directive, non-comment, non-empty line + # and remove it. This is tricky because the record might span lines. + # Strategy: find the line that contains the record's name and type. + + # Build a search pattern for the record + target_name = deleted["name"] + target_type = deleted["type"] + target_data = deleted["data"] + + # Find and remove the matching line + new_lines = [] + record_idx = 0 + removed = False + in_soa_block = False # Track multi-line SOA inside parentheses + for line in raw_lines: + stripped = line.strip() + if not stripped or stripped.startswith(';') or stripped.startswith('$'): + new_lines.append(line) + continue + + # Track SOA multi-line block (lines between ( and )) + if 'SOA' in stripped.split(): + new_lines.append(line) + if '(' in stripped and ')' not in stripped: + in_soa_block = True + continue + + # Skip lines inside SOA block + if in_soa_block: + new_lines.append(line) + if ')' in stripped: + in_soa_block = False + continue + + # This is a record line + if not removed and record_idx == idx: + # Skip this line (don't append) + removed = True + continue + record_idx += 1 + new_lines.append(line) + + if not removed: + return False, "", "Could not find record to delete" + + content = ''.join(new_lines) + content = bump_serial_in_content(content) + + with open(filepath, 'w') as f: + f.write(content) + run_cmd(f"chown bind:bind {filepath}") + + return True, f'{deleted["name"]} {deleted["type"]} {deleted["data"]}', "OK" + + +def bump_serial_in_content(content): + """Find and increment the SOA serial in zone file content.""" + # Match serial number in SOA - it's the number after the email in SOA + # Pattern: SOA ns email serial ... + lines = content.split('\n') + in_soa = False + for i, line in enumerate(lines): + if 'SOA' in line: + in_soa = True + continue + if in_soa: + # Find the serial number (first number in SOA block after ns/email) + stripped = line.strip() + # Remove comments + if ';' in stripped: + stripped = stripped[:stripped.index(';')].strip() + if stripped: + parts = stripped.split() + if parts and parts[0].isdigit(): + new_serial = serial_update(parts[0]) + lines[i] = line.replace(parts[0], new_serial, 1) + break + return '\n'.join(lines) + + +def serial_update(serial_str): + """Increment serial number.""" + try: + s = int(serial_str) + today = int(datetime.now().strftime("%Y%m%d")) + counter = s % 100 + base = s // 100 + if base == today: + s = today * 100 + counter + 1 + else: + s = today * 100 + 1 + return str(s) + except ValueError: + return datetime.now().strftime("%Y%m%d01") + + +def validate_record(rtype, data): + """Validate record data, return (bool, error_msg).""" + parts = data.split() + try: + if rtype in ("A", "AAAA"): + ip = parts[0] + ipaddress.ip_address(ip) + if rtype == "A" and ":" in ip: + return False, "A record must use IPv4" + if rtype == "AAAA" and ":" not in ip: + return False, "AAAA record must use IPv6" + elif rtype == "CNAME": + if not parts[0].endswith('.'): + return False, "CNAME target must be FQDN (end with .)" + elif rtype == "MX": + if len(parts) < 2: + return False, "MX needs priority and target" + int(parts[0]) + if not parts[1].endswith('.'): + return False, "MX target must be FQDN (end with .)" + elif rtype == "NS": + if not parts[0].endswith('.'): + return False, "NS target must be FQDN (end with .)" + elif rtype == "PTR": + if not parts[0].endswith('.'): + return False, "PTR target must be FQDN (end with .)" + elif rtype == "SRV": + if len(parts) < 4: + return False, "SRV needs priority weight port target" + elif rtype == "TXT": + if not data: + return False, "TXT data cannot be empty" + except ValueError as e: + return False, f"Invalid data: {e}" + return True, "" + + +# ════════════════════════════════════════════════════════════════════ +# Routes +# ════════════════════════════════════════════════════════════════════ + +@app.route("/") +@login_required +def dashboard(): + status = bind_service_status() + zones = parse_named_conf_local() + zone_count = len(zones) + total_records = 0 + for z in zones: + try: + _, recs = parse_zone_file(z["file"]) + z["record_count"] = len(recs) + total_records += len(recs) + except Exception: + z["record_count"] = 0 + + recent_logs = AuditLog.query.order_by(AuditLog.timestamp.desc()).limit(10).all() + version = bind_version() + + return render_template("dashboard.html", + status=status, zones=zones, zone_count=zone_count, + total_records=total_records, recent_logs=recent_logs, + version=version) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "") + password = request.form.get("password", "") + user = User.query.filter_by(username=username).first() + if user and user.check_password(password): + session["user_id"] = user.id + AuditLog.log(username, "登录") + nxt = request.args.get("next", "/") + return redirect(nxt) + flash("用户名或密码错误", "error") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + u = current_user() + if u: + AuditLog.log(u.username, "登出") + session.pop("user_id", None) + return redirect(url_for("login")) + + +@app.route("/change-password", methods=["GET", "POST"]) +@login_required +def change_password(): + u = current_user() + if request.method == "POST": + old = request.form.get("old_password", "") + new = request.form.get("new_password", "") + confirm = request.form.get("confirm_password", "") + if not u.check_password(old): + flash("旧密码错误", "error") + elif len(new) < 6: + flash("新密码至少6位", "error") + elif new != confirm: + flash("两次密码不一致", "error") + else: + u.set_password(new) + db.session.commit() + AuditLog.log(u.username, "修改密码") + flash("密码修改成功", "success") + return redirect(url_for("dashboard")) + return render_template("change_password.html") + + +# ─── Zone management ─── + +@app.route("/zones") +@login_required +def zone_list(): + zones = parse_named_conf_local() + for z in zones: + try: + _, recs = parse_zone_file(z["file"]) + z["record_count"] = len(recs) + except Exception: + z["record_count"] = 0 + return render_template("zones.html", zones=zones) + + +@app.route("/zones/create", methods=["GET", "POST"]) +@login_required +def zone_create(): + if request.method == "POST": + zone_name = request.form.get("zone_name", "").strip().rstrip('.') + zone_type = request.form.get("zone_type", "master") + ns_server = request.form.get("ns_server", "").strip() + admin_email = request.form.get("admin_email", "").strip() + + if not zone_name: + flash("请输入域名", "error") + return render_template("zone_form.html") + + # Validate zone name + if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9.-]*$', zone_name): + flash("域名格式无效", "error") + return render_template("zone_form.html") + + zone_file = os.path.join(BIND_ZONES_DIR, f"db.{zone_name}") + + # Check duplicate + existing = parse_named_conf_local() + if any(z["name"] == zone_name for z in existing): + flash(f"域名 {zone_name} 已存在", "error") + return render_template("zone_form.html") + + # Generate initial zone file + now = datetime.now().strftime("%Y%m%d01") + ns = ns_server if ns_server else f"ns1.{zone_name}." + email = admin_email.replace('@', '.') if admin_email else f"admin.{zone_name}." + if not ns.endswith('.'): + ns += '.' + if not email.endswith('.'): + email += '.' + + zone_content = f"""$TTL 86400 +@ IN SOA {ns} {email} ( + {now} ; Serial + 3600 ; Refresh + 900 ; Retry + 604800 ; Expire + 86400 ; Minimum TTL + ) +@ IN NS {ns} +{ns.split('.')[0]} IN A 127.0.0.1 +""" + + # Write zone file + with open(zone_file, 'w') as f: + f.write(zone_content) + run_cmd(f"chown bind:bind {zone_file}") + run_cmd(f"chmod 644 {zone_file}") + + # Validate zone + rc, out, err = run_cmd(f"named-checkzone {zone_name} {zone_file}") + if rc != 0: + os.remove(zone_file) + flash(f"Zone 文件验证失败: {out} {err}", "error") + return render_template("zone_form.html") + + # Add to named.conf.local + existing.append({"name": zone_name, "type": zone_type, "file": zone_file}) + write_named_conf_local(existing) + + # Reload BIND + bind_reload() + + u = current_user() + AuditLog.log(u.username, "创建Zone", f"域名: {zone_name}, 类型: {zone_type}") + flash(f"域名 {zone_name} 创建成功", "success") + return redirect(url_for("zone_detail", zone_name=zone_name)) + + return render_template("zone_form.html") + + +@app.route("/zones/") +@login_required +def zone_detail(zone_name): + zone_name = zone_name.rstrip('.') + zones = parse_named_conf_local() + zone = next((z for z in zones if z["name"] == zone_name), None) + if not zone: + abort(404) + + soa, records = parse_zone_file(zone["file"]) + + # Read raw file + try: + with open(zone["file"]) as f: + raw_content = f.read() + except Exception: + raw_content = "" + + return render_template("zone_detail.html", zone=zone, records=records, + soa=soa, raw_content=raw_content, + record_types=RECORD_TYPES, record_fields=RECORD_FIELDS) + + +@app.route("/zones//delete", methods=["POST"]) +@login_required +def zone_delete(zone_name): + zone_name = zone_name.rstrip('.') + zones = parse_named_conf_local() + zone = next((z for z in zones if z["name"] == zone_name), None) + if not zone: + flash("域名不存在", "error") + return redirect(url_for("zone_list")) + + # Delete zone file + if os.path.exists(zone["file"]): + os.remove(zone["file"]) + + # Remove from named.conf.local + zones = [z for z in zones if z["name"] != zone_name] + write_named_conf_local(zones) + + bind_reload() + + u = current_user() + AuditLog.log(u.username, "删除Zone", f"域名: {zone_name}") + flash(f"域名 {zone_name} 已删除", "success") + return redirect(url_for("zone_list")) + + +@app.route("/zones//raw", methods=["GET", "POST"]) +@login_required +def zone_raw(zone_name): + zone_name = zone_name.rstrip('.') + zones = parse_named_conf_local() + zone = next((z for z in zones if z["name"] == zone_name), None) + if not zone: + abort(404) + + if request.method == "POST": + content = request.form.get("content", "") + # Backup + backup_path = zone["file"] + ".bak" + if os.path.exists(zone["file"]): + run_cmd(f"cp {zone['file']} {backup_path}") + + # Validate + with open("/tmp/zone_check.tmp", 'w') as f: + f.write(content) + rc, out, err = run_cmd(f"named-checkzone {zone_name} /tmp/zone_check.tmp") + if rc != 0: + flash(f"Zone 文件验证失败: {err}", "error") + return render_template("zone_raw.html", zone=zone, content=content) + + # Write + with open(zone["file"], 'w') as f: + f.write(content) + run_cmd(f"chown bind:bind {zone['file']}") + run_cmd(f"rm -f /tmp/zone_check.tmp") + + bind_reload() + + u = current_user() + AuditLog.log(u.username, "编辑Zone原始文件", f"域名: {zone_name}") + flash("Zone 文件已保存并重新加载", "success") + return redirect(url_for("zone_detail", zone_name=zone_name)) + + try: + with open(zone["file"]) as f: + content = f.read() + except Exception: + content = "" + return render_template("zone_raw.html", zone=zone, content=content) + + +# ─── Record management ─── + +@app.route("/zones//records/add", methods=["POST"]) +@login_required +def record_add(zone_name): + zone_name = zone_name.rstrip('.') + zones = parse_named_conf_local() + zone = next((z for z in zones if z["name"] == zone_name), None) + if not zone: + abort(404) + + name = request.form.get("name", "").strip() + rtype = request.form.get("type", "").strip().upper() + ttl = request.form.get("ttl", "").strip() + data = request.form.get("data", "").strip() + + if not rtype or not data: + flash("记录类型和数据不能为空", "error") + return redirect(url_for("zone_detail", zone_name=zone_name)) + + # Validate + ok, err = validate_record(rtype, data) + if not ok: + flash(f"记录验证失败: {err}", "error") + return redirect(url_for("zone_detail", zone_name=zone_name)) + + if not name: + name = "@" + + # Append record directly to zone file and bump serial + ok, msg = append_record_to_file(zone["file"], name, ttl, rtype, data) + if not ok: + flash(f"添加记录失败: {msg}", "error") + return redirect(url_for("zone_detail", zone_name=zone_name)) + + # Validate zone + rc, out, err = run_cmd(f"named-checkzone {zone_name} {zone['file']}") + if rc != 0: + flash(f"Zone 验证失败: {out} {err}", "error") + return redirect(url_for("zone_detail", zone_name=zone_name)) + + bind_reload() + + u = current_user() + AuditLog.log(u.username, "添加记录", f"域名: {zone_name}, {name} {rtype} {data}") + flash(f"记录 {name} {rtype} 添加成功", "success") + return redirect(url_for("zone_detail", zone_name=zone_name)) + + +@app.route("/zones//records//delete", methods=["POST"]) +@login_required +def record_delete(zone_name, idx): + zone_name = zone_name.rstrip('.') + zones = parse_named_conf_local() + zone = next((z for z in zones if z["name"] == zone_name), None) + if not zone: + abort(404) + + # Delete record from file and bump serial + ok, deleted_str, msg = delete_record_from_file(zone["file"], zone_name, idx) + if not ok: + flash(f"删除记录失败: {msg}", "error") + return redirect(url_for("zone_detail", zone_name=zone_name)) + + bind_reload() + + u = current_user() + AuditLog.log(u.username, "删除记录", f"域名: {zone_name}, {deleted_str}") + flash(f"记录 {deleted_str} 已删除", "success") + return redirect(url_for("zone_detail", zone_name=zone_name)) + + +# ─── Service control ─── + +@app.route("/service/") +@login_required +def service_control(action): + u = current_user() + if action == "start": + rc, out, err = run_cmd("systemctl start named 2>&1") + AuditLog.log(u.username, "启动BIND服务") + msg = "BIND 服务已启动" if rc == 0 else f"启动失败: {err}" + elif action == "stop": + rc, out, err = run_cmd("systemctl stop named 2>&1") + AuditLog.log(u.username, "停止BIND服务") + msg = "BIND 服务已停止" if rc == 0 else f"停止失败: {err}" + elif action == "restart": + rc, out, err = run_cmd("systemctl restart named 2>&1") + AuditLog.log(u.username, "重启BIND服务") + msg = "BIND 服务已重启" if rc == 0 else f"重启失败: {err}" + elif action == "reload": + ok, msg = bind_reload() + AuditLog.log(u.username, "重载BIND配置") + msg = "BIND 配置已重载" if ok else f"重载失败: {msg}" + else: + msg = "未知操作" + + flash(msg, "success" if "失败" not in msg else "error") + return redirect(url_for("dashboard")) + + +# ─── Configuration ─── + +@app.route("/config") +@login_required +def config_view(): + try: + with open(BIND_CONF_OPTIONS) as f: + options_content = f.read() + except Exception: + options_content = "" + try: + with open(BIND_CONF_LOCAL) as f: + local_content = f.read() + except Exception: + local_content = "" + try: + with open("/etc/bind/named.conf") as f: + main_content = f.read() + except Exception: + main_content = "" + + # Check config + rc, out, err = run_cmd("named-checkconf 2>&1") + config_ok = rc == 0 + config_msg = out + err + + return render_template("config.html", + options_content=options_content, + local_content=local_content, + main_content=main_content, + config_ok=config_ok, + config_msg=config_msg) + + +@app.route("/config/options", methods=["POST"]) +@login_required +def config_options_save(): + content = request.form.get("content", "") + backup = BIND_CONF_OPTIONS + ".bak" + run_cmd(f"cp {BIND_CONF_OPTIONS} {backup}") + + # Write to temp and validate + with open("/tmp/named_check.tmp", 'w') as f: + f.write(content) + rc, out, err = run_cmd("named-checkconf /tmp/named_check.tmp 2>&1") + run_cmd("rm -f /tmp/named_check.tmp") + + if rc != 0: + flash(f"配置验证失败: {err}", "error") + return redirect(url_for("config_view")) + + with open(BIND_CONF_OPTIONS, 'w') as f: + f.write(content) + run_cmd(f"chown root:bind {BIND_CONF_OPTIONS}") + + bind_reload() + + u = current_user() + AuditLog.log(u.username, "修改BIND options配置") + flash("options 配置已保存", "success") + return redirect(url_for("config_view")) + + +@app.route("/config/local", methods=["POST"]) +@login_required +def config_local_save(): + content = request.form.get("content", "") + backup = BIND_CONF_LOCAL + ".bak" + run_cmd(f"cp {BIND_CONF_LOCAL} {backup}") + + with open(BIND_CONF_LOCAL, 'w') as f: + f.write(content) + run_cmd(f"chown root:bind {BIND_CONF_LOCAL}") + + rc, out, err = run_cmd("named-checkconf 2>&1") + if rc != 0: + run_cmd(f"cp {backup} {BIND_CONF_LOCAL}") + flash(f"配置验证失败,已恢复: {err}", "error") + return redirect(url_for("config_view")) + + bind_reload() + + u = current_user() + AuditLog.log(u.username, "修改BIND local配置") + flash("local 配置已保存", "success") + return redirect(url_for("config_view")) + + +# ─── Query test ─── + +@app.route("/query", methods=["GET", "POST"]) +@login_required +def query_test(): + result = None + if request.method == "POST": + domain = request.form.get("domain", "").strip() + qtype = request.form.get("qtype", "A").strip().upper() + server = request.form.get("server", "").strip() + + if not domain: + flash("请输入域名", "error") + return render_template("query.html", result=None) + + # Build dig command + port = "" + cmd = f"dig {shlex.quote(domain)} {shlex.quote(qtype)} @127.0.0.1" + if server: + cmd = f"dig {shlex.quote(domain)} {shlex.quote(qtype)} {shlex.quote(server)}" + + rc, out, err = run_cmd(cmd, timeout=15) + result = { + "domain": domain, + "qtype": qtype, + "server": server or "127.0.0.1:53", + "output": out if out else err, + "success": rc == 0, + } + + return render_template("query.html", result=result, qtypes=RECORD_TYPES) + + +# ─── Logs ─── + +@app.route("/logs") +@login_required +def logs(): + page = request.args.get("page", 1, type=int) + per_page = 50 + pagination = AuditLog.query.order_by(AuditLog.timestamp.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + return render_template("logs.html", logs=pagination.items, pagination=pagination) + + +# ─── API ─── + +@app.route("/api/status") +@login_required +def api_status(): + return jsonify(bind_service_status()) + + +# ─── Error handlers ─── + +@app.errorhandler(404) +def not_found(e): + return render_template("error.html", code=404, message="页面不存在"), 404 + + +@app.errorhandler(500) +def server_error(e): + return render_template("error.html", code=500, message="服务器错误"), 500 + + +# ════════════════════════════════════════════════════════════════════ +# Init +# ════════════════════════════════════════════════════════════════════ + +def seed_admin(): + from sqlalchemy.exc import IntegrityError + try: + if not User.query.filter_by(username="admin").first(): + a = User(username="admin", role="admin") + a.set_password("admin") + db.session.add(a) + db.session.commit() + print("[init] Default admin user created (admin/admin)") + except IntegrityError: + db.session.rollback() + print("[init] Admin user already exists (race condition handled)") + + +if __name__ == "__main__": + os.makedirs(app.instance_path, exist_ok=True) + with app.app_context(): + db.create_all() + seed_admin() + app.run(host="0.0.0.0", port=5300, debug=True) diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..7a685f5 --- /dev/null +++ b/init_db.py @@ -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") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b517773 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.0.0 +Flask-SQLAlchemy==3.1.1 +Werkzeug==3.0.1 +gunicorn==21.2.0 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..8516073 --- /dev/null +++ b/static/style.css @@ -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; } +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..dc0347c --- /dev/null +++ b/templates/base.html @@ -0,0 +1,48 @@ + + + + + + {% block title %}DNS 管理系统{% endblock %} + + + + {% if current_user %} + + {% endif %} + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ +
+

BIND DNS Web Manager © 2026 | BIND 9.18+ | ISC BIND

+
+ + diff --git a/templates/change_password.html b/templates/change_password.html new file mode 100644 index 0000000..1a210f7 --- /dev/null +++ b/templates/change_password.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}修改密码 - DNS{% endblock %} +{% block content %} + + +
+
+
+
+ + +
+
+ + +

至少 6 位

+
+
+ + +
+
+ + 取消 +
+
+
+
+{% endblock %} diff --git a/templates/config.html b/templates/config.html new file mode 100644 index 0000000..d6018f0 --- /dev/null +++ b/templates/config.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% block title %}配置管理 - DNS{% endblock %} +{% block content %} + + +
+ {% if config_ok %}✅{% else %}❌{% endif %} + 配置检查: {% if config_ok %}通过{% else %}有错误{% endif %} + {% if not config_ok %} +
{{ config_msg }}
+ {% endif %} +
+ +
+
+

named.conf (主配置)

+ 只读 +
+
+
{{ main_content }}
+
+
+ +
+
+

named.conf.options (选项配置)

+
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+

named.conf.local (本地区域配置)

+
+
+
+
+ +
+
+ +
+
+
+
+ +
+

配置说明

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
配置文件用途路径
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 filesDNS 记录文件/etc/bind/zones/db.*
+
+
+{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..0ff5cef --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,125 @@ +{% extends "base.html" %} +{% block title %}仪表盘 - DNS 管理{% endblock %} +{% block content %} +
+
+
+
+
+
BIND 服务
+
+ {{ status.status | upper }} +
+
开机自启: {{ '是' if status.enabled else '否' }}
+
+
+
+
🌍
+
+
域名数量
+
{{ zone_count }}
+
+
+
+
📋
+
+
记录总数
+
{{ total_records }}
+
+
+
+
⚙️
+
+
BIND 版本
+
{{ version[:40] }}
+
+
+
+ +
+
+
+

服务控制

+
+
+ +
+

📝 提示: BIND 监听标准 53 端口,可直接用 dig a.wxj.aa 查询。

+
+
+
+ +
+
+

域名概览

+ + 添加域名 +
+
+ {% if zones %} + + + + + + + + + + + {% for z in zones %} + + + + + + + {% endfor %} + +
域名类型记录数操作
{{ z.name }}{{ z.type }}{{ z.record_count }} + 管理 + 原始文件 +
+ {% else %} +
+

还没有任何域名

+ + 创建第一个域名 +
+ {% endif %} +
+
+
+ +
+
+

最近操作

+ 查看全部 +
+
+ {% if recent_logs %} + + + + + + {% for log in recent_logs %} + + + + + + + {% endfor %} + +
时间用户操作详情
{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{{ log.user }}{{ log.action }}{{ log.detail }}
+ {% else %} +

暂无操作记录

+ {% endif %} +
+
+
+{% endblock %} diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..2fb790c --- /dev/null +++ b/templates/error.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}错误 {{ code }}{% endblock %} +{% block content %} +
+
{{ code }}
+
{{ message }}
+ 返回首页 +
+{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..2fe524f --- /dev/null +++ b/templates/login.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}登录 - DNS 管理{% endblock %} +{% block content %} + +{% endblock %} diff --git a/templates/logs.html b/templates/logs.html new file mode 100644 index 0000000..17c025d --- /dev/null +++ b/templates/logs.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}操作日志 - DNS{% endblock %} +{% block content %} + + +
+
+ {% if logs %} + + + + + + + + + + + + {% for log in logs %} + + + + + + + + {% endfor %} + +
ID时间用户操作详情
{{ log.id }}{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{{ log.user }}{{ log.action }}{{ log.detail }}
+ + + {% else %} +

暂无操作记录

+ {% endif %} +
+
+{% endblock %} diff --git a/templates/query.html b/templates/query.html new file mode 100644 index 0000000..00547ef --- /dev/null +++ b/templates/query.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}DNS 查询测试 - DNS{% endblock %} +{% block content %} + + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+

留空 DNS 服务器默认查询本地 BIND (127.0.0.1:53)

+
+
+
+ +{% if result %} +
+
+

查询结果

+ + {% if result.success %}成功{% else %}失败{% endif %} + +
+
+
+ 域名: {{ result.domain }} + 类型: {{ result.qtype }} + 服务器: {{ result.server }} +
+
{{ result.output }}
+
+
+{% endif %} +{% endblock %} diff --git a/templates/zone_detail.html b/templates/zone_detail.html new file mode 100644 index 0000000..4b3806e --- /dev/null +++ b/templates/zone_detail.html @@ -0,0 +1,123 @@ +{% extends "base.html" %} +{% block title %}{{ zone.name }} - DNS{% endblock %} +{% block content %} + + +{% if soa %} +
+

SOA 记录

+
+ + + + + + + + + + + + + + + + + +
主 NS{{ soa.raw.split()[0] if soa.raw else '-' }}管理员{{ soa.raw.split()[1] if soa.raw and soa.raw.split()|length > 1 else '-' }}
Serial{{ soa.raw.split()[2] if soa.raw and soa.raw.split()|length > 2 else '-' }}Refresh{{ soa.raw.split()[3] if soa.raw and soa.raw.split()|length > 3 else '-' }}
Retry{{ soa.raw.split()[4] if soa.raw and soa.raw.split()|length > 4 else '-' }}Expire{{ soa.raw.split()[5] if soa.raw and soa.raw.split()|length > 5 else '-' }}
Min TTL{{ soa.raw.split()[6] if soa.raw and soa.raw.split()|length > 6 else '-' }}
+
+
+{% endif %} + +
+
+

DNS 记录

+ +
+
+ + + {% if records %} + + + + + + + + + + + + + {% for r in records %} + + + + + + + + + {% endfor %} + +
#名称TTL类型数据操作
{{ loop.index0 }}{{ r.name }}{{ r.ttl }}{{ r.type }}{{ r.data }} +
+ +
+
+ {% else %} +
+

此域名还没有记录

+
+ {% endif %} +
+
+{% endblock %} diff --git a/templates/zone_form.html b/templates/zone_form.html new file mode 100644 index 0000000..a93a3ed --- /dev/null +++ b/templates/zone_form.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}{% if zone %}编辑域名{% else %}创建域名{% endif %} - DNS{% endblock %} +{% block content %} + + +
+
+
+
+ + +

不含末尾的点,例如 example.com

+
+ +
+ + +
+ +
+ + +

留空则自动使用 ns1.<域名>

+
+ +
+ + +

留空则自动使用 admin.<域名>,会自动将 @ 转为 .

+
+ +
+ + 取消 +
+
+
+
+{% endblock %} diff --git a/templates/zone_raw.html b/templates/zone_raw.html new file mode 100644 index 0000000..b94de5f --- /dev/null +++ b/templates/zone_raw.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}{{ zone.name }} 原始文件 - DNS{% endblock %} +{% block content %} + + +
+
+
+
+ +
+
+ + 取消 +
+
+
+
+ +
+

格式说明

+
+
+$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 记录
+        
+
+
+{% endblock %} diff --git a/templates/zones.html b/templates/zones.html new file mode 100644 index 0000000..7821579 --- /dev/null +++ b/templates/zones.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}域名管理 - DNS{% endblock %} +{% block content %} + + +
+
+ {% if zones %} + + + + + + + + + + + + {% for z in zones %} + + + + + + + + {% endfor %} + +
域名类型Zone 文件记录数操作
{{ z.name }}{{ z.type }}{{ z.filename }}{{ z.record_count }} + 管理记录 + 原始文件 +
+ +
+
+ {% else %} +
+

还没有任何域名

+ + 创建第一个域名 +
+ {% endif %} +
+
+{% endblock %} diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..dfc9e31 --- /dev/null +++ b/wsgi.py @@ -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)")