feat: structured upstream DNS / allow-recursion editor in Web UI
新增配置管理页面 '上游 DNS 转发' 卡片,让管理员通过两个 textarea
分别维护 forwarders 和 allow-recursion,不需要手写 BIND 语法:
- 每行一个 IP / CIDR
- 支持 # 和 // 注释
- 留空 = 清空该字段(forwarders 留空表示走根提示;
allow-recursion 留空表示只有 localhost/localnets 能递归)
实现要点:
1. _parse_options_lists() 从 options 文件文本里抽出 forwarders /
allow-recursion 列表(用大括号平衡匹配,不依赖单行格式)。
2. _replace_or_append_option() 在 options block 里精准替换已有 directive,
自动跟随原缩进;找不到则按 4 空格缩进追加到 'options {' 之后;
options block 本身不存在则新建一个。
3. _ensure_options_file() 自动创建缺失的 options 文件(带合理的最小
默认值),避免 'No such file' 错误。
4. config_upstream_save() 写文件前用 named-checkconf 验证语法,
失败则不写入;成功则备份 .bak + 写文件 + rndc reload。
附带的修复:config_view 进入页面时也调用 _ensure_options_file(),
解决了 'Web 端第一次配置 options 文件不存在' 的问题。
模板 templates/config.html 新增 '上游 DNS 转发' 卡片,放在 named.conf
主配置卡片和 named.conf.options 卡片之间,placeholder 给出常用示例
(223.5.5.5 / 114.114.114.114 / RFC1918 三段)。
回归测试(dev 环境):
- _parse_options_lists: 正确从样例 options 抽出 3 forwarders + 2 recursion
- _replace_or_append_option: 替换后缩进正确,保留其它 directives
- round-trip:表单输入 -> 写文件 -> 再解析 -> 一致
This commit is contained in:
@@ -1005,9 +1005,154 @@ def service_control(action):
|
|||||||
|
|
||||||
# ─── Configuration ───
|
# ─── Configuration ───
|
||||||
|
|
||||||
|
def _parse_options_lists(content):
|
||||||
|
"""Extract forwarders and allow-recursion IP/CIDR lists from a named.conf.options-style text.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
forwarders: list[str] — IPs in order; [] if not present
|
||||||
|
recursion: list[str] — CIDRs/acls in order; [] if not present
|
||||||
|
"""
|
||||||
|
forwarders = []
|
||||||
|
recursion = []
|
||||||
|
|
||||||
|
fw_match = re.search(
|
||||||
|
r'forwarders\s*\{([^}]*)\}',
|
||||||
|
content,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
if fw_match:
|
||||||
|
body = fw_match.group(1)
|
||||||
|
for token in re.findall(r'\d+\.\d+\.\d+\.\d+|[0-9a-fA-F:]+', body):
|
||||||
|
# Skip plain numbers (matches like "port 53") — only accept IPv4 dotted quad or IPv6 hex
|
||||||
|
if '.' in token and token.count('.') == 3:
|
||||||
|
forwarders.append(token)
|
||||||
|
elif ':' in token and re.match(r'^[0-9a-fA-F:]+$', token):
|
||||||
|
forwarders.append(token)
|
||||||
|
|
||||||
|
rc_match = re.search(
|
||||||
|
r'allow-recursion\s*\{([^}]*)\}',
|
||||||
|
content,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
if rc_match:
|
||||||
|
body = rc_match.group(1)
|
||||||
|
# Pull out quoted strings and unquoted tokens
|
||||||
|
for m in re.finditer(r'"([^"]+)"|([\w./-]+)', body):
|
||||||
|
token = m.group(1) or m.group(2)
|
||||||
|
if token and token not in ('any', 'none', 'localhost', 'localnets'):
|
||||||
|
recursion.append(token)
|
||||||
|
|
||||||
|
return forwarders, recursion
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_or_append_option(content, directive, new_block):
|
||||||
|
"""Replace an existing 'directive { ... };' in an options block with new_block,
|
||||||
|
or append it after the opening 'options {' if not present.
|
||||||
|
|
||||||
|
`new_block` is the full 'directive { ... };' text (without leading/trailing newline).
|
||||||
|
|
||||||
|
Handles nested braces in directives by counting brace depth (so multi-line
|
||||||
|
directives like `forwarders { 1.1.1.1; }` work; nested ones like `key ...`
|
||||||
|
are not expected in options block).
|
||||||
|
"""
|
||||||
|
# Try to find an existing directive with balanced-brace body
|
||||||
|
pattern = re.compile(
|
||||||
|
r'(' + re.escape(directive) + r')\s*\{',
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
m = pattern.search(content)
|
||||||
|
if m:
|
||||||
|
start = m.start()
|
||||||
|
# Detect indentation by looking at whitespace before the directive
|
||||||
|
line_start = content.rfind('\n', 0, start) + 1
|
||||||
|
indent = ''
|
||||||
|
for ch in content[line_start:start]:
|
||||||
|
if ch in ' \t':
|
||||||
|
indent += ch
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
# Walk forward from `{` to find the matching `};`
|
||||||
|
depth = 0
|
||||||
|
i = m.end() - 1 # points at `{`
|
||||||
|
for j in range(i, len(content)):
|
||||||
|
c = content[j]
|
||||||
|
if c == '{':
|
||||||
|
depth += 1
|
||||||
|
elif c == '}':
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
# Expect `;` shortly after; consume any whitespace
|
||||||
|
end = j + 1
|
||||||
|
while end < len(content) and content[end] in ' \t\n':
|
||||||
|
end += 1
|
||||||
|
if end < len(content) and content[end] == ';':
|
||||||
|
end += 1
|
||||||
|
# Indent new_block to match the directive's indentation
|
||||||
|
indented = '\n'.join(
|
||||||
|
(indent + line) if line else line
|
||||||
|
for line in new_block.split('\n')
|
||||||
|
)
|
||||||
|
return content[:start] + indented + content[end:]
|
||||||
|
return content # malformed, give up
|
||||||
|
|
||||||
|
# Not found — append after the opening "options {" line
|
||||||
|
opt_match = re.search(r'options\s*\{', content)
|
||||||
|
if opt_match:
|
||||||
|
insert_at = opt_match.end()
|
||||||
|
# Insert after the newline that follows
|
||||||
|
nl = content.find('\n', insert_at)
|
||||||
|
if nl >= 0:
|
||||||
|
insert_at = nl + 1
|
||||||
|
# Indent new_block 4 spaces (typical options-body indent)
|
||||||
|
indented = '\n'.join(' ' + line if line else line for line in new_block.split('\n'))
|
||||||
|
indented = '\n' + indented + '\n'
|
||||||
|
return content[:insert_at] + indented + content[insert_at:]
|
||||||
|
# No options block — create one
|
||||||
|
return 'options {\n' + new_block + '\n};\n'
|
||||||
|
|
||||||
|
|
||||||
|
def _format_list_block(directive, items, indent=4):
|
||||||
|
"""Format a 'directive { item1; item2; ... };' block."""
|
||||||
|
if not items:
|
||||||
|
return f"{directive} {{ }};"
|
||||||
|
pad = ' ' * indent
|
||||||
|
lines = [f"{directive} {{"]
|
||||||
|
for item in items:
|
||||||
|
lines.append(f"{pad}{item};")
|
||||||
|
lines.append("};")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_options_file():
|
||||||
|
"""Create BIND_CONF_OPTIONS with a minimal valid options block if missing.
|
||||||
|
|
||||||
|
Ensures the parent directory exists and the file is owned by root:bind (Debian)
|
||||||
|
/ root:named (RHEL), mode 644.
|
||||||
|
"""
|
||||||
|
if os.path.exists(BIND_CONF_OPTIONS):
|
||||||
|
return
|
||||||
|
|
||||||
|
parent = os.path.dirname(BIND_CONF_OPTIONS)
|
||||||
|
if parent and not os.path.exists(parent):
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
with open(BIND_CONF_OPTIONS, 'w') as f:
|
||||||
|
f.write("options {\n"
|
||||||
|
" directory \"/var/cache/bind\";\n"
|
||||||
|
" listen-on port 53 { any; };\n"
|
||||||
|
" listen-on-v6 { none; };\n"
|
||||||
|
" allow-query { any; };\n"
|
||||||
|
" dnssec-validation auto;\n"
|
||||||
|
" auth-nxdomain no;\n"
|
||||||
|
"};\n")
|
||||||
|
# Ownership: Debian uses bind:named fallback (run_cmd)
|
||||||
|
run_cmd(f"chown root:bind {BIND_CONF_OPTIONS} 2>/dev/null || chown root:named {BIND_CONF_OPTIONS} 2>/dev/null")
|
||||||
|
run_cmd(f"chmod 644 {BIND_CONF_OPTIONS}")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/config")
|
@app.route("/config")
|
||||||
@login_required
|
@login_required
|
||||||
def config_view():
|
def config_view():
|
||||||
|
_ensure_options_file()
|
||||||
try:
|
try:
|
||||||
with open(BIND_CONF_OPTIONS) as f:
|
with open(BIND_CONF_OPTIONS) as f:
|
||||||
options_content = f.read()
|
options_content = f.read()
|
||||||
@@ -1029,12 +1174,17 @@ def config_view():
|
|||||||
config_ok = rc == 0
|
config_ok = rc == 0
|
||||||
config_msg = out + err
|
config_msg = out + err
|
||||||
|
|
||||||
|
# Parse structured fields for the upstream-DNS form
|
||||||
|
forwarders, recursion = _parse_options_lists(options_content)
|
||||||
|
|
||||||
return render_template("config.html",
|
return render_template("config.html",
|
||||||
options_content=options_content,
|
options_content=options_content,
|
||||||
local_content=local_content,
|
local_content=local_content,
|
||||||
main_content=main_content,
|
main_content=main_content,
|
||||||
config_ok=config_ok,
|
config_ok=config_ok,
|
||||||
config_msg=config_msg)
|
config_msg=config_msg,
|
||||||
|
forwarders=forwarders,
|
||||||
|
recursion=recursion)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/config/options", methods=["POST"])
|
@app.route("/config/options", methods=["POST"])
|
||||||
@@ -1091,6 +1241,79 @@ def config_local_save():
|
|||||||
return redirect(url_for("config_view"))
|
return redirect(url_for("config_view"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/config/upstream", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def config_upstream_save():
|
||||||
|
"""Structured edit of forwarders + allow-recursion in named.conf.options.
|
||||||
|
|
||||||
|
The textarea content for each field is parsed line-by-line; comments (#, //)
|
||||||
|
and blank lines are stripped. Whitespace and trailing dots are trimmed.
|
||||||
|
This avoids the user accidentally breaking BIND syntax inside a free-form
|
||||||
|
text editor.
|
||||||
|
"""
|
||||||
|
def _parse_list(raw):
|
||||||
|
items = []
|
||||||
|
for line in (raw or "").splitlines():
|
||||||
|
s = line.strip()
|
||||||
|
if not s or s.startswith("#") or s.startswith("//"):
|
||||||
|
continue
|
||||||
|
s = s.rstrip(";").strip()
|
||||||
|
if s.startswith("acl ") or s.startswith("key "):
|
||||||
|
# Skip acl/key definitions; only accept inline IPs/CIDRs
|
||||||
|
continue
|
||||||
|
if s:
|
||||||
|
items.append(s)
|
||||||
|
return items
|
||||||
|
|
||||||
|
raw_fwd = request.form.get("forwarders", "")
|
||||||
|
raw_rc = request.form.get("recursion", "")
|
||||||
|
new_fwd = _parse_list(raw_fwd)
|
||||||
|
new_rc = _parse_list(raw_rc)
|
||||||
|
|
||||||
|
_ensure_options_file()
|
||||||
|
try:
|
||||||
|
with open(BIND_CONF_OPTIONS) as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"读取 {BIND_CONF_OPTIONS} 失败: {e}", "error")
|
||||||
|
return redirect(url_for("config_view"))
|
||||||
|
|
||||||
|
# Build new directives
|
||||||
|
new_content = _replace_or_append_option(
|
||||||
|
content, "forwarders",
|
||||||
|
_format_list_block("forwarders", new_fwd),
|
||||||
|
)
|
||||||
|
new_content = _replace_or_append_option(
|
||||||
|
new_content, "allow-recursion",
|
||||||
|
_format_list_block("allow-recursion", new_rc),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate BEFORE writing to the live file
|
||||||
|
tmp = "/tmp/named_check.tmp"
|
||||||
|
with open(tmp, 'w') as f:
|
||||||
|
f.write(new_content)
|
||||||
|
rc, out, err = run_cmd(f"named-checkconf {tmp}")
|
||||||
|
run_cmd(f"rm -f {tmp}")
|
||||||
|
if rc != 0:
|
||||||
|
flash(f"配置验证失败: {out} {err}", "error")
|
||||||
|
return redirect(url_for("config_view"))
|
||||||
|
|
||||||
|
# Backup + write
|
||||||
|
backup = BIND_CONF_OPTIONS + ".bak"
|
||||||
|
run_cmd(f"cp {BIND_CONF_OPTIONS} {backup}")
|
||||||
|
with open(BIND_CONF_OPTIONS, 'w') as f:
|
||||||
|
f.write(new_content)
|
||||||
|
run_cmd(f"chown root:bind {BIND_CONF_OPTIONS} 2>/dev/null || chown root:named {BIND_CONF_OPTIONS} 2>/dev/null")
|
||||||
|
|
||||||
|
bind_reload()
|
||||||
|
|
||||||
|
u = current_user()
|
||||||
|
AuditLog.log(u.username, "修改上游DNS转发配置",
|
||||||
|
f"forwarders={new_fwd}; allow-recursion={new_rc}")
|
||||||
|
flash(f"已保存:forwarders {len(new_fwd)} 条,allow-recursion {len(new_rc)} 条", "success")
|
||||||
|
return redirect(url_for("config_view"))
|
||||||
|
|
||||||
|
|
||||||
# ─── Query test ───
|
# ─── Query test ───
|
||||||
|
|
||||||
@app.route("/query", methods=["GET", "POST"])
|
@app.route("/query", methods=["GET", "POST"])
|
||||||
|
|||||||
@@ -23,6 +23,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>上游 DNS 转发</h2>
|
||||||
|
<span class="text-muted text-sm">结构化字段 — 不需要手写 BIND 语法</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('config_upstream_save') }}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="forwarders">
|
||||||
|
<strong>Forwarders</strong>
|
||||||
|
<span class="text-muted text-sm">每行一个上游 DNS IP(v4 / v6 都可)。留空表示使用根提示。</span>
|
||||||
|
</label>
|
||||||
|
<textarea name="forwarders" class="code-editor" rows="5" spellcheck="false"
|
||||||
|
placeholder="223.5.5.5 114.114.114.114 8.8.8.8">{% for ip in forwarders %}{{ ip }}
|
||||||
|
{% endfor %}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="recursion">
|
||||||
|
<strong>Allow-recursion 网段</strong>
|
||||||
|
<span class="text-muted text-sm">允许递归查询外网的客户端 CIDR。留空表示只允许本机。</span>
|
||||||
|
</label>
|
||||||
|
<textarea name="recursion" class="code-editor" rows="5" spellcheck="false"
|
||||||
|
placeholder="127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16">{% for net in recursion %}{{ net }}
|
||||||
|
{% endfor %}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">💾 保存并重载</button>
|
||||||
|
<span class="text-muted text-sm" style="margin-left: 12px;">
|
||||||
|
此处只改 forwarders 和 allow-recursion;其它 options 字段请用下方文本编辑器。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>named.conf.options (选项配置)</h2>
|
<h2>named.conf.options (选项配置)</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user