Files
dns-service/app.py
T
Hermes fb3f13c3b2 fix: skip directive matches inside BIND comments
修复 _replace_or_append_option 之前没考虑注释行:
- BIND 配置文件里大量 '// ... forwarders ...' 这样的注释
- 原 regex 'forwarders\\b(?!\\-)' 在注释文本里也能匹配到 forwarders
- 导致保存时把 forwarders 块插入到了注释行的中间,破坏文件结构
- BIND parser 后续报 'unknown option 223.5.5.5'(行号指向 forwarders 块
  里的 IP,因为 forwarders 关键字所在行已被注释污染)

修法:先扫一遍文件建立 in_comment[] 布尔掩码(覆盖 // 行注释、
# 行注释、/* */ 块注释),regex 匹配后检查 match 位置是否在
注释内,若是则 skip。

测试用例包含 '// ... use them as forwarders.' 这种典型注释文本,
验证修改后的 forwarders 块正确插入到 options block 内、不破坏注释。
2026-07-24 18:17:08 +08:00

1560 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
_VALIDATION_VALUES = ("auto", "yes", "no")
def _parse_options_dnssec(content):
"""Return current dnssec-validation value as one of 'auto'/'yes'/'no'.
Returns 'auto' if the directive is absent (BIND default).
"""
m = re.search(r'dnssec-validation\s+(\w+)\s*;', content)
if not m:
return "auto"
val = m.group(1).lower()
if val in _VALIDATION_VALUES:
return val
return "auto"
def _listen_on_v4_set(content):
"""True if the options block contains any IPv4 'listen-on' directive."""
# Match listen-on (port N)? { ... } but NOT listen-on-v6
return bool(re.search(r'(?<!-)listen-on\b', content))
def _parse_listen_on_v4(content):
"""Return list of CIDR/IP/ACL names from `listen-on [port 53] { ... };` block.
Returns [] if directive is absent.
"""
m = re.search(r'(?<!-)listen-on(?:\s+port\s+\d+)?\s*\{([^}]*)\}', content, re.DOTALL)
if not m:
return []
body = m.group(1)
items = []
for tm in re.finditer(r'"([^"]+)"|([\w./-]+)', body):
token = tm.group(1) or tm.group(2)
if token and token not in ('any', 'none'):
items.append(token)
return items
def _parse_recursion(content):
"""Return 'yes' (default), 'no', or specific value from 'recursion <v>;'."""
m = re.search(r'recursion\s+(\w+)\s*;', content)
if not m:
return "yes"
val = m.group(1).lower()
return val if val in ("yes", "no") else "yes"
def _replace_or_append_option(content, directive, new_block):
"""Replace an existing 'directive { ... };' or 'directive <value>;' 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:
- brace-delimited: 'forwarders { ... };' / 'allow-recursion { ... };'
- value-terminated: 'dnssec-validation auto;' / 'recursion yes;'
Nested braces are matched by counting depth. Comments ('//', '#', '/*...*/')
are skipped so the directive name doesn't match inside comment text.
"""
# Build a mask of which character ranges are inside comments. We treat the
# whole file linearly:
# - '//' to end-of-line: line comment (BIND and shell style)
# - '#' to end-of-line: line comment (BIND 9.18+ accepts # too)
# - '/* ... */': block comment
n = len(content)
in_comment = [False] * n # True at position i = i is inside a comment
i = 0
while i < n:
c = content[i]
# End of block comment
if in_comment[i] is False and i + 1 < n and c == '/' and content[i + 1] == '*':
j = i + 2
depth = 1
while j < n and depth > 0:
if j + 1 < n and content[j] == '*' and content[j + 1] == '/':
depth -= 1
j += 2
else:
j += 1
for k in range(i, j):
in_comment[k] = True
i = j
continue
# Line comment // ... \n
if in_comment[i] is False and i + 1 < n and c == '/' and content[i + 1] == '/':
j = i
while j < n and content[j] != '\n':
in_comment[j] = True
j += 1
i = j
continue
# Line comment # ... \n (skip only if at start of token; be conservative
# and treat any '#' preceded by whitespace or start-of-line as a comment)
if in_comment[i] is False and c == '#':
# Only treat as comment if preceded by whitespace or start-of-line
prev_ok = (i == 0) or content[i - 1] in ' \t'
if prev_ok:
j = i
while j < n and content[j] != '\n':
in_comment[j] = True
j += 1
i = j
continue
i += 1
pattern = re.compile(
r'(?<!\w)(?<!-)(' + re.escape(directive) + r')\b(?!-)(?!\w)',
)
for m in pattern.finditer(content):
start = m.start()
# Skip if this match is inside a comment
if in_comment[start]:
continue
end_kw = m.end()
if end_kw < len(content) and content[end_kw] == '-':
continue
# Detect indentation
line_start = content.rfind('\n', 0, start) + 1
indent = ''
for ch in content[line_start:start]:
if ch in ' \t':
indent += ch
else:
break
# Look ahead: brace-delimited or value-terminated?
i = end_kw
while i < len(content) and content[i] in ' \t':
i += 1
if i >= len(content):
continue
if content[i] == '{':
depth = 0
for j in range(i, len(content)):
# Skip over comments inside the brace body
if in_comment[j]:
continue
c = content[j]
if c == '{':
depth += 1
elif c == '}':
depth -= 1
if depth == 0:
end = j + 1
while end < len(content) and content[end] in ' \t\n':
end += 1
if end < len(content) and content[end] == ';':
end += 1
indented = '\n'.join(
(indent + line) if line else line
for line in new_block.split('\n')
)
return content[:start] + indented + content[end:]
continue
else:
end = i
while end < len(content) and content[end] != ';':
end += 1
if end < len(content):
end += 1
indented = '\n'.join(
(indent + line) if line else line
for line in new_block.split('\n')
)
return content[:start] + indented + content[end:]
# Not found — append after the opening "options {" line
opt_match = re.search(r'options\s*\{', content)
if opt_match:
insert_at = opt_match.end()
nl = content.find('\n', insert_at)
if nl >= 0:
insert_at = nl + 1
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:]
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)
dnssec_value = _parse_options_dnssec(options_content)
listen_on_v4 = _parse_listen_on_v4(options_content)
recursion_value = _parse_recursion(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,
dnssec_value=dnssec_value,
dnssec_values=_VALIDATION_VALUES,
listen_on_v4=listen_on_v4,
recursion_value=recursion_value)
@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", "")
raw_listen = request.form.get("listen_on_v4", "")
dnssec_in = (request.form.get("dnssec") or "auto").strip().lower()
if dnssec_in not in _VALIDATION_VALUES:
dnssec_in = "auto"
recursion_in = (request.form.get("recursion_toggle") or "yes").strip().lower()
if recursion_in not in ("yes", "no"):
recursion_in = "yes"
new_fwd = _parse_list(raw_fwd)
new_rc = _parse_list(raw_rc)
new_listen = _parse_list(raw_listen)
_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),
)
new_content = _replace_or_append_option(
new_content, "dnssec-validation",
f"dnssec-validation {dnssec_in};",
)
# listen-on: empty list -> listen-on port 53 { any; }; (default safe)
if not new_listen:
new_listen = ["any"]
new_content = _replace_or_append_option(
new_content, "listen-on",
_format_list_block("listen-on port 53", new_listen),
)
new_content = _replace_or_append_option(
new_content, "recursion",
f"recursion {recursion_in};",
)
# 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}; "
f"dnssec={dnssec_in}; listen-on={new_listen}; recursion={recursion_in}")
flash(
f"已保存:forwarders {len(new_fwd)} 条,allow-recursion {len(new_rc)} 条,"
f"dnssec-validation {dnssec_in}listen-on {len(new_listen)} 项,"
f"recursion {recursion_in}",
"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)