diff --git a/app.py b/app.py index c87fc6c..5768d64 100644 --- a/app.py +++ b/app.py @@ -1005,9 +1005,154 @@ 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 + + +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. + + `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). + """ + # Try to find an existing directive with balanced-brace body + pattern = re.compile( + r'(' + re.escape(directive) + r')\s*\{', + re.MULTILINE, + ) + m = pattern.search(content) + if m: + start = m.start() + # Detect indentation by looking at whitespace before the directive + line_start = content.rfind('\n', 0, start) + 1 + indent = '' + for ch in content[line_start:start]: + if ch in ' \t': + 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 + + # 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:] + # No options block — create one + 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 +1174,17 @@ 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) + 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) @app.route("/config/options", methods=["POST"]) @@ -1091,6 +1241,79 @@ 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", "") + new_fwd = _parse_list(raw_fwd) + new_rc = _parse_list(raw_rc) + + _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), + ) + + # 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}") + flash(f"已保存:forwarders {len(new_fwd)} 条,allow-recursion {len(new_rc)} 条", "success") + return redirect(url_for("config_view")) + + # ─── Query test ─── @app.route("/query", methods=["GET", "POST"]) diff --git a/templates/config.html b/templates/config.html index d6018f0..a3f22ea 100644 --- a/templates/config.html +++ b/templates/config.html @@ -23,6 +23,41 @@ +
+
+

上游 DNS 转发

+ 结构化字段 — 不需要手写 BIND 语法 +
+
+
+
+ + +
+
+ + +
+
+ + + 此处只改 forwarders 和 allow-recursion;其它 options 字段请用下方文本编辑器。 + +
+
+
+
+

named.conf.options (选项配置)