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