fb3f13c3b2
修复 _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 内、不破坏注释。
1560 lines
54 KiB
Python
1560 lines
54 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
|
||
|
||
|
||
_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)
|