feat: dnssec-validation toggle in upstream DNS form

Web 端 '上游 DNS 转发' 卡片新增第三个字段:DNSSEC 验证(auto/yes/no),
解决 SERVFAIL 经典坑:内网 DNS 用 auto 时会被根服务器坏 DS 记录卡住,
返回 SERVFAIL 而非答案。

实现:
- _parse_options_dnssec() 抽出当前 dnssec-validation 值
- _replace_or_append_option() 扩展支持 value-terminated directives
  ('dnssec-validation auto;' 而非 '{...}'),用负向断言避免误匹配
  'listen-on' 时把 'listen-on-v6' 也吃掉
- config_view 多传 dnssec_value/dnssec_values 给模板
- config_upstream_save 多处理 dnssec 表单字段
- 模板加 <select> 下拉,默认选中当前文件里的值

测试覆盖:brace-delimited 替换、value-terminated 替换、hyphen
lookbehind、append-when-missing、parse 各种值、完整 round-trip。
This commit is contained in:
Hermes
2026-07-24 18:08:41 +08:00
parent 1614e37386
commit 9b1679bbd0
2 changed files with 110 additions and 40 deletions
+98 -39
View File
@@ -1045,23 +1045,54 @@ def _parse_options_lists(content):
return forwarders, recursion return forwarders, recursion
def _replace_or_append_option(content, directive, new_block): _VALIDATION_VALUES = ("auto", "yes", "no")
"""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 def _parse_options_dnssec(content):
directives like `forwarders { 1.1.1.1; }` work; nested ones like `key ...` """Return current dnssec-validation value as one of 'auto'/'yes'/'no'.
are not expected in options block). 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 _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.
""" """
# Try to find an existing directive with balanced-brace body
pattern = re.compile( pattern = re.compile(
r'(' + re.escape(directive) + r')\s*\{', r'(' + re.escape(directive) + r')\b(?!\-)',
re.MULTILINE, re.MULTILINE,
) )
m = pattern.search(content)
if m: for m in pattern.finditer(content):
# Skip lines where the directive is part of a longer hyphenated name,
# e.g. don't match 'listen-on' when looking for 'listen-on-v6'.
# The negative lookbehind `(?<!\-)` in the regex above handles most
# cases; also check that the next char isn't '-' (handles 'foo-bar'
# matching 'foo').
end_kw = m.end()
if end_kw < len(content) and content[end_kw] == '-':
continue
start = m.start() start = m.start()
# Detect indentation by looking at whitespace before the directive # Detect indentation by looking at whitespace before the directive
line_start = content.rfind('\n', 0, start) + 1 line_start = content.rfind('\n', 0, start) + 1
@@ -1071,39 +1102,57 @@ def _replace_or_append_option(content, directive, new_block):
indent += ch indent += ch
else: else:
break break
# Walk forward from `{` to find the matching `};`
depth = 0 # Look ahead: is this a brace-delimited directive or value-terminated?
i = m.end() - 1 # points at `{` i = end_kw
for j in range(i, len(content)): # Skip whitespace
c = content[j] while i < len(content) and content[i] in ' \t':
if c == '{': i += 1
depth += 1 if i >= len(content):
elif c == '}': continue
depth -= 1
if depth == 0: if content[i] == '{':
# Expect `;` shortly after; consume any whitespace # Brace-delimited: walk forward to matching `};`
end = j + 1 depth = 0
while end < len(content) and content[end] in ' \t\n': for j in range(i, len(content)):
end += 1 c = content[j]
if end < len(content) and content[end] == ';': if c == '{':
end += 1 depth += 1
# Indent new_block to match the directive's indentation elif c == '}':
indented = '\n'.join( depth -= 1
(indent + line) if line else line if depth == 0:
for line in new_block.split('\n') end = j + 1
) while end < len(content) and content[end] in ' \t\n':
return content[:start] + indented + content[end:] end += 1
return content # malformed, give up 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 # malformed, try next match
else:
# Value-terminated: read tokens up to `;`
end = i
while end < len(content) and content[end] != ';':
end += 1
if end < len(content):
end += 1 # consume the `;`
indented = '\n'.join(
(indent + line) if line else line
for line in new_block.split('\n')
)
return content[:start] + indented + content[end:]
# else: malformed, try next match
# Not found — append after the opening "options {" line # Not found — append after the opening "options {" line
opt_match = re.search(r'options\s*\{', content) opt_match = re.search(r'options\s*\{', content)
if opt_match: if opt_match:
insert_at = opt_match.end() insert_at = opt_match.end()
# Insert after the newline that follows
nl = content.find('\n', insert_at) nl = content.find('\n', insert_at)
if nl >= 0: if nl >= 0:
insert_at = nl + 1 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'.join(' ' + line if line else line for line in new_block.split('\n'))
indented = '\n' + indented + '\n' indented = '\n' + indented + '\n'
return content[:insert_at] + indented + content[insert_at:] return content[:insert_at] + indented + content[insert_at:]
@@ -1176,6 +1225,7 @@ def config_view():
# Parse structured fields for the upstream-DNS form # Parse structured fields for the upstream-DNS form
forwarders, recursion = _parse_options_lists(options_content) forwarders, recursion = _parse_options_lists(options_content)
dnssec_value = _parse_options_dnssec(options_content)
return render_template("config.html", return render_template("config.html",
options_content=options_content, options_content=options_content,
@@ -1184,7 +1234,9 @@ def config_view():
config_ok=config_ok, config_ok=config_ok,
config_msg=config_msg, config_msg=config_msg,
forwarders=forwarders, forwarders=forwarders,
recursion=recursion) recursion=recursion,
dnssec_value=dnssec_value,
dnssec_values=_VALIDATION_VALUES)
@app.route("/config/options", methods=["POST"]) @app.route("/config/options", methods=["POST"])
@@ -1267,6 +1319,9 @@ def config_upstream_save():
raw_fwd = request.form.get("forwarders", "") raw_fwd = request.form.get("forwarders", "")
raw_rc = request.form.get("recursion", "") raw_rc = request.form.get("recursion", "")
dnssec_in = (request.form.get("dnssec") or "auto").strip().lower()
if dnssec_in not in _VALIDATION_VALUES:
dnssec_in = "auto"
new_fwd = _parse_list(raw_fwd) new_fwd = _parse_list(raw_fwd)
new_rc = _parse_list(raw_rc) new_rc = _parse_list(raw_rc)
@@ -1287,6 +1342,10 @@ def config_upstream_save():
new_content, "allow-recursion", new_content, "allow-recursion",
_format_list_block("allow-recursion", new_rc), _format_list_block("allow-recursion", new_rc),
) )
new_content = _replace_or_append_option(
new_content, "dnssec-validation",
f"dnssec-validation {dnssec_in};",
)
# Validate BEFORE writing to the live file # Validate BEFORE writing to the live file
tmp = "/tmp/named_check.tmp" tmp = "/tmp/named_check.tmp"
@@ -1309,8 +1368,8 @@ def config_upstream_save():
u = current_user() u = current_user()
AuditLog.log(u.username, "修改上游DNS转发配置", AuditLog.log(u.username, "修改上游DNS转发配置",
f"forwarders={new_fwd}; allow-recursion={new_rc}") f"forwarders={new_fwd}; allow-recursion={new_rc}; dnssec={dnssec_in}")
flash(f"已保存:forwarders {len(new_fwd)} 条,allow-recursion {len(new_rc)}", "success") flash(f"已保存:forwarders {len(new_fwd)} 条,allow-recursion {len(new_rc)}dnssec-validation {dnssec_in}", "success")
return redirect(url_for("config_view")) return redirect(url_for("config_view"))
+12 -1
View File
@@ -48,10 +48,21 @@
placeholder="127.0.0.0/8&#10;10.0.0.0/8&#10;172.16.0.0/12&#10;192.168.0.0/16">{% for net in recursion %}{{ net }} placeholder="127.0.0.0/8&#10;10.0.0.0/8&#10;172.16.0.0/12&#10;192.168.0.0/16">{% for net in recursion %}{{ net }}
{% endfor %}</textarea> {% endfor %}</textarea>
</div> </div>
<div class="form-group">
<label for="dnssec">
<strong>DNSSEC 验证</strong>
<span class="text-muted text-sm">内网 DNS 通常选 <code>no</code>,避免根服务器坏 DS 记录导致 SERVFAIL。</span>
</label>
<select name="dnssec" id="dnssec" class="form-control">
{% for v in dnssec_values %}
<option value="{{ v }}" {% if v == dnssec_value %}selected{% endif %}>{{ v }}{% if v == 'auto' %}(默认,验证根 key{% elif v == 'yes' %}(强制验证){% elif v == 'no' %}(关闭验证){% endif %}</option>
{% endfor %}
</select>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存并重载</button> <button type="submit" class="btn btn-primary">💾 保存并重载</button>
<span class="text-muted text-sm" style="margin-left: 12px;"> <span class="text-muted text-sm" style="margin-left: 12px;">
此处只改 forwarders allow-recursion;其它 options 字段请用下方文本编辑器。 此处只改 forwarders / allow-recursion / dnssec-validation;其它 options 字段请用下方文本编辑器。
</span> </span>
</div> </div>
</form> </form>