1614e37386
新增配置管理页面 '上游 DNS 转发' 卡片,让管理员通过两个 textarea
分别维护 forwarders 和 allow-recursion,不需要手写 BIND 语法:
- 每行一个 IP / CIDR
- 支持 # 和 // 注释
- 留空 = 清空该字段(forwarders 留空表示走根提示;
allow-recursion 留空表示只有 localhost/localnets 能递归)
实现要点:
1. _parse_options_lists() 从 options 文件文本里抽出 forwarders /
allow-recursion 列表(用大括号平衡匹配,不依赖单行格式)。
2. _replace_or_append_option() 在 options block 里精准替换已有 directive,
自动跟随原缩进;找不到则按 4 空格缩进追加到 'options {' 之后;
options block 本身不存在则新建一个。
3. _ensure_options_file() 自动创建缺失的 options 文件(带合理的最小
默认值),避免 'No such file' 错误。
4. config_upstream_save() 写文件前用 named-checkconf 验证语法,
失败则不写入;成功则备份 .bak + 写文件 + rndc reload。
附带的修复:config_view 进入页面时也调用 _ensure_options_file(),
解决了 'Web 端第一次配置 options 文件不存在' 的问题。
模板 templates/config.html 新增 '上游 DNS 转发' 卡片,放在 named.conf
主配置卡片和 named.conf.options 卡片之间,placeholder 给出常用示例
(223.5.5.5 / 114.114.114.114 / RFC1918 三段)。
回归测试(dev 环境):
- _parse_options_lists: 正确从样例 options 抽出 3 forwarders + 2 recursion
- _replace_or_append_option: 替换后缩进正确,保留其它 directives
- round-trip:表单输入 -> 写文件 -> 再解析 -> 一致
1407 lines
48 KiB
Python
1407 lines
48 KiB
Python
#!/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 ───
|
|
# 优先级:环境变量 > 自动探测 > 内置默认值。
|
|
# 自动探测按"哪个目录/文件存在就用谁"在常见路径里回退,覆盖 Debian/Ubuntu、
|
|
# RHEL/CentOS/Rocky、AlmaLinux、FreeBSD 等发行版默认布局;如有特殊安装路径,
|
|
# 可用环境变量强制指定。
|
|
#
|
|
# 注意 BIND_CONF_LOCAL 的探测顺序:/etc/named.conf.local 优先于
|
|
# /etc/bind/named.conf.local —— 因为 RHEL/CentOS 的 /etc/named.conf 默认
|
|
# include 的是 /etc/named.conf.local,而 Debian/Ubuntu 默认 include 的是
|
|
# /etc/bind/named.conf.local。优先探测 RHEL 路径可以避免"两边文件都存在但
|
|
# named 只读其中一份"的歧义场景。
|
|
_BIND_PATH_CANDIDATES = {
|
|
"BIND_CONF_DIR": ["/etc/bind", "/etc", "/etc/named"],
|
|
"BIND_ZONES_DIR": ["/etc/bind/zones", "/var/named", "/var/named/data"],
|
|
"BIND_CONF_LOCAL": ["/etc/named.conf.local", "/etc/bind/named.conf.local"],
|
|
"BIND_CONF_OPTIONS": ["/etc/named.conf", "/etc/bind/named.conf.options"],
|
|
"BIND_RNDC_KEY": ["/etc/bind/rndc.key", "/etc/rndc.key", "/var/named/key"],
|
|
}
|
|
|
|
def _detect_bind_path(candidates):
|
|
"""Return first existing path in candidates; fall back to first (built-in default)."""
|
|
for p in candidates:
|
|
if os.path.exists(p):
|
|
return p
|
|
return candidates[0]
|
|
|
|
BIND_CONF_DIR = os.environ.get("BIND_CONF_DIR", _detect_bind_path(_BIND_PATH_CANDIDATES["BIND_CONF_DIR"]))
|
|
BIND_ZONES_DIR = os.environ.get("BIND_ZONES_DIR", _detect_bind_path(_BIND_PATH_CANDIDATES["BIND_ZONES_DIR"]))
|
|
BIND_CONF_LOCAL = os.environ.get("BIND_CONF_LOCAL", _detect_bind_path(_BIND_PATH_CANDIDATES["BIND_CONF_LOCAL"]))
|
|
BIND_CONF_OPTIONS = os.environ.get("BIND_CONF_OPTIONS", _detect_bind_path(_BIND_PATH_CANDIDATES["BIND_CONF_OPTIONS"]))
|
|
BIND_RNDC_KEY = os.environ.get("BIND_RNDC_KEY", _detect_bind_path(_BIND_PATH_CANDIDATES["BIND_RNDC_KEY"]))
|
|
BIND_SERVICE = os.environ.get("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
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# python3.7
|
|
#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)
|
|
|
|
# python3.6
|
|
def run_cmd(cmd, timeout=10):
|
|
"""Run shell command, return (returncode, stdout, stderr)."""
|
|
try:
|
|
r = subprocess.run(
|
|
cmd,
|
|
shell=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
universal_newlines=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 parse_all_managed_zones():
|
|
"""Return all zone names that BIND currently has loaded (master/slave/in-view),
|
|
including zones declared in the main named.conf outside BIND_CONF_LOCAL.
|
|
|
|
Uses `named-checkconf -p` which prints the fully-resolved configuration with
|
|
all included files expanded, so we can detect duplicates the user might have
|
|
added manually (e.g. a zone declared in /etc/named.conf that web UI doesn't
|
|
know about).
|
|
|
|
Returns a dict: zone_name -> {"type": str, "file": str, "source": "local"|"main"}.
|
|
"""
|
|
rc, out, err = run_cmd("named-checkconf -p 2>&1", timeout=15)
|
|
if rc != 0:
|
|
# If named-checkconf fails (e.g. broken config), fall back to local-only
|
|
# so the UI doesn't break entirely.
|
|
return {z["name"]: {"type": z["type"], "file": z["file"], "source": "local"}
|
|
for z in parse_named_conf_local()}
|
|
|
|
# Parse the dumped config: zone statements with a non-builtin type (i.e. not
|
|
# "hint" and not under automatic empty-zones). We deliberately include builtin
|
|
# zones too and let callers filter.
|
|
result = {}
|
|
pattern = re.compile(
|
|
r'zone\s+"([^"]+)"\s+(?:IN\s+)?\{\s*'
|
|
r'type\s+(\w+)\s*;\s*'
|
|
r'(?:file\s+"([^"]+)"\s*;\s*)?',
|
|
re.MULTILINE,
|
|
)
|
|
for m in pattern.finditer(out):
|
|
name, ztype, zfile = m.group(1), m.group(2), m.group(3) or ""
|
|
# Skip builtin/hint zones and the "empty" automatic zones (no file).
|
|
if ztype == "hint" or not zfile:
|
|
continue
|
|
result[name] = {"type": ztype, "file": zfile, "source": "unknown"}
|
|
|
|
# Mark which ones came from BIND_CONF_LOCAL for the UI to know whether to
|
|
# let the user edit/delete them.
|
|
local_names = {z["name"] for z in parse_named_conf_local()}
|
|
for name in result:
|
|
if name in local_names:
|
|
result[name]["source"] = "local"
|
|
else:
|
|
result[name]["source"] = "main"
|
|
|
|
return result
|
|
|
|
|
|
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} 2>/dev/null || chown named:named {filepath} 2>/dev/null")
|
|
run_cmd(f"chmod 644 {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} 2>/dev/null || chown named:named {filepath} 2>/dev/null")
|
|
run_cmd(f"chmod 644 {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 — both against zones we manage in BIND_CONF.local AND
|
|
# against zones declared elsewhere in named.conf (e.g. user manually
|
|
# added a zone block in /etc/named.conf). Without the second check,
|
|
# creating a zone with a name already declared in the main config would
|
|
# produce a duplicate zone error on BIND reload.
|
|
existing = parse_named_conf_local()
|
|
all_managed = parse_all_managed_zones()
|
|
if any(z["name"] == zone_name for z in existing):
|
|
flash(f"域名 {zone_name} 已在 Web UI 中存在", "error")
|
|
return render_template("zone_form.html")
|
|
if zone_name in all_managed:
|
|
flash(
|
|
f"域名 {zone_name} 已在 named.conf 中声明(不在 Web UI 管理范围内),"
|
|
f"请先到 named.conf 中注释/删除后再创建",
|
|
"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
|
|
try:
|
|
os.makedirs(BIND_ZONES_DIR, exist_ok=True)
|
|
except PermissionError as e:
|
|
flash(f"无法创建 zone 目录 {BIND_ZONES_DIR}(权限不足):{e}", "error")
|
|
return render_template("zone_form.html")
|
|
with open(zone_file, 'w') as f:
|
|
f.write(zone_content)
|
|
# BIND runs as user 'bind' (Debian/Ubuntu) or 'named' (RHEL/CentOS).
|
|
# Try Debian first; fall back to RHEL.
|
|
run_cmd(f"chown bind:bind {zone_file} 2>/dev/null || chown named:named {zone_file} 2>/dev/null")
|
|
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/<zone_name>")
|
|
@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/<zone_name>/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/<zone_name>/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']} 2>/dev/null || chown named:named {zone['file']} 2>/dev/null")
|
|
run_cmd(f"chmod 644 {zone['file']}")
|
|
|
|
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/<zone_name>/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/<zone_name>/records/<int:idx>/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/<action>")
|
|
@login_required
|
|
def service_control(action):
|
|
u = current_user()
|
|
if action == "start":
|
|
rc, out, err = run_cmd("systemctl start named")
|
|
AuditLog.log(u.username, "启动BIND服务")
|
|
msg = "BIND 服务已启动" if rc == 0 else f"启动失败: {out} {err}"
|
|
elif action == "stop":
|
|
rc, out, err = run_cmd("systemctl stop named")
|
|
AuditLog.log(u.username, "停止BIND服务")
|
|
msg = "BIND 服务已停止" if rc == 0 else f"停止失败: {out} {err}"
|
|
elif action == "restart":
|
|
rc, out, err = run_cmd("systemctl restart named")
|
|
AuditLog.log(u.username, "重启BIND服务")
|
|
msg = "BIND 服务已重启" if rc == 0 else f"重启失败: {out} {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 ───
|
|
|
|
def _parse_options_lists(content):
|
|
"""Extract forwarders and allow-recursion IP/CIDR lists from a named.conf.options-style text.
|
|
|
|
Returns:
|
|
forwarders: list[str] — IPs in order; [] if not present
|
|
recursion: list[str] — CIDRs/acls in order; [] if not present
|
|
"""
|
|
forwarders = []
|
|
recursion = []
|
|
|
|
fw_match = re.search(
|
|
r'forwarders\s*\{([^}]*)\}',
|
|
content,
|
|
re.DOTALL,
|
|
)
|
|
if fw_match:
|
|
body = fw_match.group(1)
|
|
for token in re.findall(r'\d+\.\d+\.\d+\.\d+|[0-9a-fA-F:]+', body):
|
|
# Skip plain numbers (matches like "port 53") — only accept IPv4 dotted quad or IPv6 hex
|
|
if '.' in token and token.count('.') == 3:
|
|
forwarders.append(token)
|
|
elif ':' in token and re.match(r'^[0-9a-fA-F:]+$', token):
|
|
forwarders.append(token)
|
|
|
|
rc_match = re.search(
|
|
r'allow-recursion\s*\{([^}]*)\}',
|
|
content,
|
|
re.DOTALL,
|
|
)
|
|
if rc_match:
|
|
body = rc_match.group(1)
|
|
# Pull out quoted strings and unquoted tokens
|
|
for m in re.finditer(r'"([^"]+)"|([\w./-]+)', body):
|
|
token = m.group(1) or m.group(2)
|
|
if token and token not in ('any', 'none', 'localhost', 'localnets'):
|
|
recursion.append(token)
|
|
|
|
return forwarders, recursion
|
|
|
|
|
|
def _replace_or_append_option(content, directive, new_block):
|
|
"""Replace an existing 'directive { ... };' in an options block with new_block,
|
|
or append it after the opening 'options {' if not present.
|
|
|
|
`new_block` is the full 'directive { ... };' text (without leading/trailing newline).
|
|
|
|
Handles nested braces in directives by counting brace depth (so multi-line
|
|
directives like `forwarders { 1.1.1.1; }` work; nested ones like `key ...`
|
|
are not expected in options block).
|
|
"""
|
|
# Try to find an existing directive with balanced-brace body
|
|
pattern = re.compile(
|
|
r'(' + re.escape(directive) + r')\s*\{',
|
|
re.MULTILINE,
|
|
)
|
|
m = pattern.search(content)
|
|
if m:
|
|
start = m.start()
|
|
# Detect indentation by looking at whitespace before the directive
|
|
line_start = content.rfind('\n', 0, start) + 1
|
|
indent = ''
|
|
for ch in content[line_start:start]:
|
|
if ch in ' \t':
|
|
indent += ch
|
|
else:
|
|
break
|
|
# Walk forward from `{` to find the matching `};`
|
|
depth = 0
|
|
i = m.end() - 1 # points at `{`
|
|
for j in range(i, len(content)):
|
|
c = content[j]
|
|
if c == '{':
|
|
depth += 1
|
|
elif c == '}':
|
|
depth -= 1
|
|
if depth == 0:
|
|
# Expect `;` shortly after; consume any whitespace
|
|
end = j + 1
|
|
while end < len(content) and content[end] in ' \t\n':
|
|
end += 1
|
|
if end < len(content) and content[end] == ';':
|
|
end += 1
|
|
# Indent new_block to match the directive's indentation
|
|
indented = '\n'.join(
|
|
(indent + line) if line else line
|
|
for line in new_block.split('\n')
|
|
)
|
|
return content[:start] + indented + content[end:]
|
|
return content # malformed, give up
|
|
|
|
# Not found — append after the opening "options {" line
|
|
opt_match = re.search(r'options\s*\{', content)
|
|
if opt_match:
|
|
insert_at = opt_match.end()
|
|
# Insert after the newline that follows
|
|
nl = content.find('\n', insert_at)
|
|
if nl >= 0:
|
|
insert_at = nl + 1
|
|
# Indent new_block 4 spaces (typical options-body indent)
|
|
indented = '\n'.join(' ' + line if line else line for line in new_block.split('\n'))
|
|
indented = '\n' + indented + '\n'
|
|
return content[:insert_at] + indented + content[insert_at:]
|
|
# No options block — create one
|
|
return 'options {\n' + new_block + '\n};\n'
|
|
|
|
|
|
def _format_list_block(directive, items, indent=4):
|
|
"""Format a 'directive { item1; item2; ... };' block."""
|
|
if not items:
|
|
return f"{directive} {{ }};"
|
|
pad = ' ' * indent
|
|
lines = [f"{directive} {{"]
|
|
for item in items:
|
|
lines.append(f"{pad}{item};")
|
|
lines.append("};")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _ensure_options_file():
|
|
"""Create BIND_CONF_OPTIONS with a minimal valid options block if missing.
|
|
|
|
Ensures the parent directory exists and the file is owned by root:bind (Debian)
|
|
/ root:named (RHEL), mode 644.
|
|
"""
|
|
if os.path.exists(BIND_CONF_OPTIONS):
|
|
return
|
|
|
|
parent = os.path.dirname(BIND_CONF_OPTIONS)
|
|
if parent and not os.path.exists(parent):
|
|
os.makedirs(parent, exist_ok=True)
|
|
with open(BIND_CONF_OPTIONS, 'w') as f:
|
|
f.write("options {\n"
|
|
" directory \"/var/cache/bind\";\n"
|
|
" listen-on port 53 { any; };\n"
|
|
" listen-on-v6 { none; };\n"
|
|
" allow-query { any; };\n"
|
|
" dnssec-validation auto;\n"
|
|
" auth-nxdomain no;\n"
|
|
"};\n")
|
|
# Ownership: Debian uses bind:named fallback (run_cmd)
|
|
run_cmd(f"chown root:bind {BIND_CONF_OPTIONS} 2>/dev/null || chown root:named {BIND_CONF_OPTIONS} 2>/dev/null")
|
|
run_cmd(f"chmod 644 {BIND_CONF_OPTIONS}")
|
|
|
|
|
|
@app.route("/config")
|
|
@login_required
|
|
def config_view():
|
|
_ensure_options_file()
|
|
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")
|
|
config_ok = rc == 0
|
|
config_msg = out + err
|
|
|
|
# Parse structured fields for the upstream-DNS form
|
|
forwarders, recursion = _parse_options_lists(options_content)
|
|
|
|
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,
|
|
forwarders=forwarders,
|
|
recursion=recursion)
|
|
|
|
|
|
@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")
|
|
run_cmd("rm -f /tmp/named_check.tmp")
|
|
|
|
if rc != 0:
|
|
flash(f"配置验证失败: {out} {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")
|
|
if rc != 0:
|
|
run_cmd(f"cp {backup} {BIND_CONF_LOCAL}")
|
|
flash(f"配置验证失败,已恢复: {out} {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"))
|
|
|
|
|
|
@app.route("/config/upstream", methods=["POST"])
|
|
@login_required
|
|
def config_upstream_save():
|
|
"""Structured edit of forwarders + allow-recursion in named.conf.options.
|
|
|
|
The textarea content for each field is parsed line-by-line; comments (#, //)
|
|
and blank lines are stripped. Whitespace and trailing dots are trimmed.
|
|
This avoids the user accidentally breaking BIND syntax inside a free-form
|
|
text editor.
|
|
"""
|
|
def _parse_list(raw):
|
|
items = []
|
|
for line in (raw or "").splitlines():
|
|
s = line.strip()
|
|
if not s or s.startswith("#") or s.startswith("//"):
|
|
continue
|
|
s = s.rstrip(";").strip()
|
|
if s.startswith("acl ") or s.startswith("key "):
|
|
# Skip acl/key definitions; only accept inline IPs/CIDRs
|
|
continue
|
|
if s:
|
|
items.append(s)
|
|
return items
|
|
|
|
raw_fwd = request.form.get("forwarders", "")
|
|
raw_rc = request.form.get("recursion", "")
|
|
new_fwd = _parse_list(raw_fwd)
|
|
new_rc = _parse_list(raw_rc)
|
|
|
|
_ensure_options_file()
|
|
try:
|
|
with open(BIND_CONF_OPTIONS) as f:
|
|
content = f.read()
|
|
except Exception as e:
|
|
flash(f"读取 {BIND_CONF_OPTIONS} 失败: {e}", "error")
|
|
return redirect(url_for("config_view"))
|
|
|
|
# Build new directives
|
|
new_content = _replace_or_append_option(
|
|
content, "forwarders",
|
|
_format_list_block("forwarders", new_fwd),
|
|
)
|
|
new_content = _replace_or_append_option(
|
|
new_content, "allow-recursion",
|
|
_format_list_block("allow-recursion", new_rc),
|
|
)
|
|
|
|
# Validate BEFORE writing to the live file
|
|
tmp = "/tmp/named_check.tmp"
|
|
with open(tmp, 'w') as f:
|
|
f.write(new_content)
|
|
rc, out, err = run_cmd(f"named-checkconf {tmp}")
|
|
run_cmd(f"rm -f {tmp}")
|
|
if rc != 0:
|
|
flash(f"配置验证失败: {out} {err}", "error")
|
|
return redirect(url_for("config_view"))
|
|
|
|
# Backup + write
|
|
backup = BIND_CONF_OPTIONS + ".bak"
|
|
run_cmd(f"cp {BIND_CONF_OPTIONS} {backup}")
|
|
with open(BIND_CONF_OPTIONS, 'w') as f:
|
|
f.write(new_content)
|
|
run_cmd(f"chown root:bind {BIND_CONF_OPTIONS} 2>/dev/null || chown root:named {BIND_CONF_OPTIONS} 2>/dev/null")
|
|
|
|
bind_reload()
|
|
|
|
u = current_user()
|
|
AuditLog.log(u.username, "修改上游DNS转发配置",
|
|
f"forwarders={new_fwd}; allow-recursion={new_rc}")
|
|
flash(f"已保存:forwarders {len(new_fwd)} 条,allow-recursion {len(new_rc)} 条", "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)
|