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:
@@ -1045,23 +1045,54 @@ def _parse_options_lists(content):
|
||||
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.
|
||||
_VALIDATION_VALUES = ("auto", "yes", "no")
|
||||
|
||||
`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).
|
||||
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 _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(
|
||||
r'(' + re.escape(directive) + r')\s*\{',
|
||||
r'(' + re.escape(directive) + r')\b(?!\-)',
|
||||
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()
|
||||
# Detect indentation by looking at whitespace before the directive
|
||||
line_start = content.rfind('\n', 0, start) + 1
|
||||
@@ -1071,39 +1102,57 @@ def _replace_or_append_option(content, directive, new_block):
|
||||
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
|
||||
|
||||
# Look ahead: is this a brace-delimited directive or value-terminated?
|
||||
i = end_kw
|
||||
# Skip whitespace
|
||||
while i < len(content) and content[i] in ' \t':
|
||||
i += 1
|
||||
if i >= len(content):
|
||||
continue
|
||||
|
||||
if content[i] == '{':
|
||||
# Brace-delimited: walk forward to matching `};`
|
||||
depth = 0
|
||||
for j in range(i, len(content)):
|
||||
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 # 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
|
||||
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:]
|
||||
@@ -1176,6 +1225,7 @@ def config_view():
|
||||
|
||||
# Parse structured fields for the upstream-DNS form
|
||||
forwarders, recursion = _parse_options_lists(options_content)
|
||||
dnssec_value = _parse_options_dnssec(options_content)
|
||||
|
||||
return render_template("config.html",
|
||||
options_content=options_content,
|
||||
@@ -1184,7 +1234,9 @@ def config_view():
|
||||
config_ok=config_ok,
|
||||
config_msg=config_msg,
|
||||
forwarders=forwarders,
|
||||
recursion=recursion)
|
||||
recursion=recursion,
|
||||
dnssec_value=dnssec_value,
|
||||
dnssec_values=_VALIDATION_VALUES)
|
||||
|
||||
|
||||
@app.route("/config/options", methods=["POST"])
|
||||
@@ -1267,6 +1319,9 @@ def config_upstream_save():
|
||||
|
||||
raw_fwd = request.form.get("forwarders", "")
|
||||
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_rc = _parse_list(raw_rc)
|
||||
|
||||
@@ -1287,6 +1342,10 @@ def config_upstream_save():
|
||||
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};",
|
||||
)
|
||||
|
||||
# Validate BEFORE writing to the live file
|
||||
tmp = "/tmp/named_check.tmp"
|
||||
@@ -1309,8 +1368,8 @@ def config_upstream_save():
|
||||
|
||||
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")
|
||||
f"forwarders={new_fwd}; allow-recursion={new_rc}; dnssec={dnssec_in}")
|
||||
flash(f"已保存:forwarders {len(new_fwd)} 条,allow-recursion {len(new_rc)} 条,dnssec-validation {dnssec_in}", "success")
|
||||
return redirect(url_for("config_view"))
|
||||
|
||||
|
||||
|
||||
+12
-1
@@ -48,10 +48,21 @@
|
||||
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-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">
|
||||
<button type="submit" class="btn btn-primary">💾 保存并重载</button>
|
||||
<span class="text-muted text-sm" style="margin-left: 12px;">
|
||||
此处只改 forwarders 和 allow-recursion;其它 options 字段请用下方文本编辑器。
|
||||
此处只改 forwarders / allow-recursion / dnssec-validation;其它 options 字段请用下方文本编辑器。
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user