Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb3f13c3b2 | |||
| e0bb10bfab | |||
| 9b1679bbd0 | |||
| 1614e37386 |
@@ -1005,9 +1005,271 @@ def service_control(action):
|
||||
|
||||
# ─── 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
|
||||
|
||||
|
||||
_VALIDATION_VALUES = ("auto", "yes", "no")
|
||||
|
||||
|
||||
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 _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
|
||||
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. Comments ('//', '#', '/*...*/')
|
||||
are skipped so the directive name doesn't match inside comment text.
|
||||
"""
|
||||
# Build a mask of which character ranges are inside comments. We treat the
|
||||
# whole file linearly:
|
||||
# - '//' to end-of-line: line comment (BIND and shell style)
|
||||
# - '#' to end-of-line: line comment (BIND 9.18+ accepts # too)
|
||||
# - '/* ... */': block comment
|
||||
n = len(content)
|
||||
in_comment = [False] * n # True at position i = i is inside a comment
|
||||
|
||||
i = 0
|
||||
while i < n:
|
||||
c = content[i]
|
||||
# End of block comment
|
||||
if in_comment[i] is False and i + 1 < n and c == '/' and content[i + 1] == '*':
|
||||
j = i + 2
|
||||
depth = 1
|
||||
while j < n and depth > 0:
|
||||
if j + 1 < n and content[j] == '*' and content[j + 1] == '/':
|
||||
depth -= 1
|
||||
j += 2
|
||||
else:
|
||||
j += 1
|
||||
for k in range(i, j):
|
||||
in_comment[k] = True
|
||||
i = j
|
||||
continue
|
||||
# Line comment // ... \n
|
||||
if in_comment[i] is False and i + 1 < n and c == '/' and content[i + 1] == '/':
|
||||
j = i
|
||||
while j < n and content[j] != '\n':
|
||||
in_comment[j] = True
|
||||
j += 1
|
||||
i = j
|
||||
continue
|
||||
# Line comment # ... \n (skip only if at start of token; be conservative
|
||||
# and treat any '#' preceded by whitespace or start-of-line as a comment)
|
||||
if in_comment[i] is False and c == '#':
|
||||
# Only treat as comment if preceded by whitespace or start-of-line
|
||||
prev_ok = (i == 0) or content[i - 1] in ' \t'
|
||||
if prev_ok:
|
||||
j = i
|
||||
while j < n and content[j] != '\n':
|
||||
in_comment[j] = True
|
||||
j += 1
|
||||
i = j
|
||||
continue
|
||||
i += 1
|
||||
|
||||
pattern = re.compile(
|
||||
r'(?<!\w)(?<!-)(' + re.escape(directive) + r')\b(?!-)(?!\w)',
|
||||
)
|
||||
|
||||
for m in pattern.finditer(content):
|
||||
start = m.start()
|
||||
# Skip if this match is inside a comment
|
||||
if in_comment[start]:
|
||||
continue
|
||||
end_kw = m.end()
|
||||
if end_kw < len(content) and content[end_kw] == '-':
|
||||
continue
|
||||
|
||||
# Detect indentation
|
||||
line_start = content.rfind('\n', 0, start) + 1
|
||||
indent = ''
|
||||
for ch in content[line_start:start]:
|
||||
if ch in ' \t':
|
||||
indent += ch
|
||||
else:
|
||||
break
|
||||
|
||||
# Look ahead: brace-delimited or value-terminated?
|
||||
i = end_kw
|
||||
while i < len(content) and content[i] in ' \t':
|
||||
i += 1
|
||||
if i >= len(content):
|
||||
continue
|
||||
|
||||
if content[i] == '{':
|
||||
depth = 0
|
||||
for j in range(i, len(content)):
|
||||
# Skip over comments inside the brace body
|
||||
if in_comment[j]:
|
||||
continue
|
||||
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
|
||||
else:
|
||||
end = i
|
||||
while end < len(content) and content[end] != ';':
|
||||
end += 1
|
||||
if end < len(content):
|
||||
end += 1
|
||||
indented = '\n'.join(
|
||||
(indent + line) if line else line
|
||||
for line in new_block.split('\n')
|
||||
)
|
||||
return content[:start] + indented + content[end:]
|
||||
|
||||
# Not found — append after the opening "options {" line
|
||||
opt_match = re.search(r'options\s*\{', content)
|
||||
if opt_match:
|
||||
insert_at = opt_match.end()
|
||||
nl = content.find('\n', insert_at)
|
||||
if nl >= 0:
|
||||
insert_at = nl + 1
|
||||
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:]
|
||||
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")
|
||||
@login_required
|
||||
def config_view():
|
||||
_ensure_options_file()
|
||||
try:
|
||||
with open(BIND_CONF_OPTIONS) as f:
|
||||
options_content = f.read()
|
||||
@@ -1029,12 +1291,24 @@ def config_view():
|
||||
config_ok = rc == 0
|
||||
config_msg = out + err
|
||||
|
||||
# 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,
|
||||
local_content=local_content,
|
||||
main_content=main_content,
|
||||
config_ok=config_ok,
|
||||
config_msg=config_msg)
|
||||
config_msg=config_msg,
|
||||
forwarders=forwarders,
|
||||
recursion=recursion,
|
||||
dnssec_value=dnssec_value,
|
||||
dnssec_values=_VALIDATION_VALUES,
|
||||
listen_on_v4=listen_on_v4,
|
||||
recursion_value=recursion_value)
|
||||
|
||||
|
||||
@app.route("/config/options", methods=["POST"])
|
||||
@@ -1091,6 +1365,108 @@ def config_local_save():
|
||||
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", "")
|
||||
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:
|
||||
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),
|
||||
)
|
||||
new_content = _replace_or_append_option(
|
||||
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"
|
||||
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}; "
|
||||
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"))
|
||||
|
||||
|
||||
# ─── Query test ───
|
||||
|
||||
@app.route("/query", methods=["GET", "POST"])
|
||||
|
||||
@@ -23,6 +23,71 @@
|
||||
</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-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-group">
|
||||
<label for="listen_on_v4">
|
||||
<strong>Listen-on (IPv4) 53 端口</strong>
|
||||
<span class="text-muted text-sm">允许哪些客户端查询本 DNS。每行一个 CIDR / IP / ACL 名。留空 = <code>any</code>(任意来源)。</span>
|
||||
</label>
|
||||
<textarea name="listen_on_v4" id="listen_on_v4" class="code-editor" rows="4" spellcheck="false"
|
||||
placeholder="any 或具体网段,如: 192.168.1.0/24 10.0.0.0/8">{% for net in listen_on_v4 %}{{ net }}
|
||||
{% endfor %}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="recursion_toggle">
|
||||
<strong>Recursion (递归查询)</strong>
|
||||
<span class="text-muted text-sm">是否允许本 DNS 代客户端去外网查询。内网递归服务器选 <code>yes</code>;纯权威服务器选 <code>no</code>。</span>
|
||||
</label>
|
||||
<select name="recursion_toggle" id="recursion_toggle" class="form-control">
|
||||
<option value="yes" {% if recursion_value == 'yes' %}selected{% endif %}>yes(允许递归)</option>
|
||||
<option value="no" {% if recursion_value == 'no' %}selected{% endif %}>no(仅权威应答,不递归)</option>
|
||||
</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 / dnssec-validation / listen-on / recursion;其它字段请用下方文本编辑器。
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>named.conf.options (选项配置)</h2>
|
||||
|
||||
Reference in New Issue
Block a user