feat: listen-on and recursion toggles in upstream DNS form

Web 端 '上游 DNS 转发' 卡片新增两个字段:

1. Listen-on (IPv4) 53 端口 — textarea,每行一个 CIDR/IP/ACL 名。
   留空 = any(任意来源)。对应 BIND 指令 listen-on port 53 { ... };。
2. Recursion — yes/no 下拉,控制本 DNS 是否代客户端去外网递归。
   内网递归服务器选 yes;纯权威服务器选 no。

修了一个 regex bug:之前 _replace_or_append_option 用 \\b(?!\\-) 做
lookahead 但没用 lookbehind,导致在内容含 allow-recursion 时匹配
recursion 时也会匹配到 allow- 后面的 recursion,把 allow-recursion
破坏。改为 (?<!\\w)(?<!\\-)keyword\\b(?!\\w)(?!\\-) 双向拒绝,
确保 keyword 必须是独立单词,不能是其它带连字符 directive 的子串。

测试覆盖:
- parse listen-on (IPv4 list)
- parse recursion (yes/no/default)
- 完整 round-trip:5 个字段同时更新,allow-recursion / listen-on-v6
  等未触及的 directives 正确保留
This commit is contained in:
Hermes
2026-07-24 18:13:00 +08:00
parent 9b1679bbd0
commit e0bb10bfab
2 changed files with 78 additions and 9 deletions
+58 -8
View File
@@ -1067,6 +1067,31 @@ def _listen_on_v4_set(content):
return bool(re.search(r'(?<!-)listen-on\b', content))
def _parse_listen_on_v4(content):
"""Return list of CIDR/IP/ACL names from `listen-on [port 53] { ... };` block.
Returns [] if directive is absent.
"""
m = re.search(r'(?<!-)listen-on(?:\s+port\s+\d+)?\s*\{([^}]*)\}', content, re.DOTALL)
if not m:
return []
body = m.group(1)
items = []
for tm in re.finditer(r'"([^"]+)"|([\w./-]+)', body):
token = tm.group(1) or tm.group(2)
if token and token not in ('any', 'none'):
items.append(token)
return items
def _parse_recursion(content):
"""Return 'yes' (default), 'no', or specific value from 'recursion <v>;'."""
m = re.search(r'recursion\s+(\w+)\s*;', content)
if not m:
return "yes"
val = m.group(1).lower()
return val if val in ("yes", "no") else "yes"
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
@@ -1079,16 +1104,15 @@ def _replace_or_append_option(content, directive, new_block):
Nested braces are matched by counting depth.
"""
pattern = re.compile(
r'(' + re.escape(directive) + r')\b(?!\-)',
r'(?<!\w)(?<!\-)(' + re.escape(directive) + r')\b(?!\w)(?!\-)',
re.MULTILINE,
)
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').
# e.g. don't match 'recursion' when looking for it inside 'allow-recursion'.
# The negative lookbehind `(?<!\-)` and lookahead `(?!\-)` together cover
# both directions. Belt-and-suspenders: also check next char isn't '-'.
end_kw = m.end()
if end_kw < len(content) and content[end_kw] == '-':
continue
@@ -1226,6 +1250,8 @@ 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)
listen_on_v4 = _parse_listen_on_v4(options_content)
recursion_value = _parse_recursion(options_content)
return render_template("config.html",
options_content=options_content,
@@ -1236,7 +1262,9 @@ def config_view():
forwarders=forwarders,
recursion=recursion,
dnssec_value=dnssec_value,
dnssec_values=_VALIDATION_VALUES)
dnssec_values=_VALIDATION_VALUES,
listen_on_v4=listen_on_v4,
recursion_value=recursion_value)
@app.route("/config/options", methods=["POST"])
@@ -1319,11 +1347,16 @@ def config_upstream_save():
raw_fwd = request.form.get("forwarders", "")
raw_rc = request.form.get("recursion", "")
raw_listen = request.form.get("listen_on_v4", "")
dnssec_in = (request.form.get("dnssec") or "auto").strip().lower()
if dnssec_in not in _VALIDATION_VALUES:
dnssec_in = "auto"
recursion_in = (request.form.get("recursion_toggle") or "yes").strip().lower()
if recursion_in not in ("yes", "no"):
recursion_in = "yes"
new_fwd = _parse_list(raw_fwd)
new_rc = _parse_list(raw_rc)
new_listen = _parse_list(raw_listen)
_ensure_options_file()
try:
@@ -1346,6 +1379,17 @@ def config_upstream_save():
new_content, "dnssec-validation",
f"dnssec-validation {dnssec_in};",
)
# listen-on: empty list -> listen-on port 53 { any; }; (default safe)
if not new_listen:
new_listen = ["any"]
new_content = _replace_or_append_option(
new_content, "listen-on",
_format_list_block("listen-on port 53", new_listen),
)
new_content = _replace_or_append_option(
new_content, "recursion",
f"recursion {recursion_in};",
)
# Validate BEFORE writing to the live file
tmp = "/tmp/named_check.tmp"
@@ -1368,8 +1412,14 @@ def config_upstream_save():
u = current_user()
AuditLog.log(u.username, "修改上游DNS转发配置",
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")
f"forwarders={new_fwd}; allow-recursion={new_rc}; "
f"dnssec={dnssec_in}; listen-on={new_listen}; recursion={recursion_in}")
flash(
f"已保存:forwarders {len(new_fwd)} 条,allow-recursion {len(new_rc)} 条,"
f"dnssec-validation {dnssec_in}listen-on {len(new_listen)} 项,"
f"recursion {recursion_in}",
"success",
)
return redirect(url_for("config_view"))