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
|
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
@@ -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 }}
|
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>
|
{% 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user