Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a025d627b7 | |||
| 450ffb89ae | |||
| 948d4cff9a | |||
| e20e235a1e | |||
| fb3f13c3b2 | |||
| e0bb10bfab | |||
| 9b1679bbd0 | |||
| 1614e37386 |
@@ -5,3 +5,4 @@ instance/
|
|||||||
*.bak
|
*.bak
|
||||||
.env
|
.env
|
||||||
.venv/
|
.venv/
|
||||||
|
venv/
|
||||||
|
|||||||
@@ -81,7 +81,51 @@ dns-service/
|
|||||||
|
|
||||||
## 安装部署
|
## 安装部署
|
||||||
|
|
||||||
### 1. 安装 BIND9
|
### 方式一:一键部署(推荐)
|
||||||
|
|
||||||
|
`scripts/deploy.sh` 从干净系统一路到运行中的服务,自动完成:
|
||||||
|
|
||||||
|
1. 安装系统包(bind9 / bind-utils / python3 / pip / venv / git,自动识别 apt/yum/dnf)
|
||||||
|
2. 创建 Python 虚拟环境 `venv/`,安装 `requirements.txt`(用 venv 的 pip,避开 Debian 12+ PEP 668)
|
||||||
|
3. 初始化 SQLite 数据库(默认 `admin/admin`)
|
||||||
|
4. 调用 `scripts/setup-bind.sh` 配置 BIND(zone 目录、named.conf.local include、权限)
|
||||||
|
5. 安装并启用 systemd 服务 `dns-web`(gunicorn,端口 5300,2 workers)
|
||||||
|
6. 启动服务并验证端口 + HTTP 响应
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://git@git.cnbugs.com:10022/AI-Agent/dns-service.git
|
||||||
|
cd dns-service
|
||||||
|
|
||||||
|
# 先预览会做什么(推荐):
|
||||||
|
sudo bash scripts/deploy.sh --dry-run
|
||||||
|
|
||||||
|
# 确认后真跑:
|
||||||
|
sudo bash scripts/deploy.sh
|
||||||
|
|
||||||
|
# 如果 BIND 已经配好了只想更新应用:
|
||||||
|
sudo bash scripts/deploy.sh --skip-bind
|
||||||
|
|
||||||
|
# 换端口:
|
||||||
|
sudo bash scripts/deploy.sh --port 5301
|
||||||
|
# 或用环境变量:
|
||||||
|
sudo DNS_WEB_PORT=5301 DNS_WEB_WORKERS=4 bash scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本幂等,可重复执行。升级代码后重跑一次即可(venv 和 DB 保留,只补缺失部分)。
|
||||||
|
|
||||||
|
**可调环境变量:**
|
||||||
|
|
||||||
|
| 变量 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `DNS_WEB_PORT` | `5300` | 监听端口 |
|
||||||
|
| `DNS_WEB_USER` | `root` | 服务运行用户(需能操作 BIND 配置 + systemctl) |
|
||||||
|
| `DNS_WEB_WORKERS` | `2` | gunicorn worker 数 |
|
||||||
|
|
||||||
|
> **关于 root 用户**:Web 应用需要 root 权限来操作 `/etc/bind/*` 配置文件和执行 `systemctl`/`rndc` 命令。如果你的环境不允许 root 跑服务,用 `DNS_WEB_USER=<user>` 覆盖,并给该用户配 sudo 规则。systemd unit 用 `Type=exec`(gunicorn 21.x 不带 sd_notify,用 `Type=notify` 会卡 90 秒超时然后失败)。
|
||||||
|
|
||||||
|
### 方式二:手动部署
|
||||||
|
|
||||||
|
适合需要完全控制每一步的场景。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
apt-get update
|
apt-get update
|
||||||
|
|||||||
@@ -1005,9 +1005,246 @@ def service_control(action):
|
|||||||
|
|
||||||
# ─── Configuration ───
|
# ─── 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_listen_on_v6(content):
|
||||||
|
"""Return 'any'/'none'/'localhost' — the IPv6 listen mode.
|
||||||
|
Returns 'none' if directive is absent (disabled by default).
|
||||||
|
"""
|
||||||
|
m = re.search(r'listen-on-v6(?:\s+port\s+\d+)?\s*\{([^}]*)\}', content, re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
return "none"
|
||||||
|
body = m.group(1)
|
||||||
|
# Look for the main token: { any; } or { none; } or { localhost; }
|
||||||
|
for tm in re.finditer(r'"([^"]+)"|([\w./:-]+)', body):
|
||||||
|
token = tm.group(1) or tm.group(2)
|
||||||
|
if token in ("any", "none", "localhost"):
|
||||||
|
return token
|
||||||
|
# Fallback: if the block is not empty, assume "custom" mode
|
||||||
|
body_stripped = body.strip().strip(';').strip()
|
||||||
|
return "any" if body_stripped else "none"
|
||||||
|
|
||||||
|
|
||||||
|
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 append it inside the options block.
|
||||||
|
|
||||||
|
Simple and reliable version:
|
||||||
|
1. First try to match brace-delimited directives: 'directive { ... };'
|
||||||
|
2. Then try to match value-terminated directives: 'directive <value>;'
|
||||||
|
3. If not found, append after 'options {' opening line
|
||||||
|
"""
|
||||||
|
# Pattern 1: brace-delimited (handles nested braces, ignores 'port X' prefixes)
|
||||||
|
# Match: directive [optional port N] { ... };
|
||||||
|
# The [\w\s.-]* matches things like " port 53"
|
||||||
|
pattern_brace = re.compile(
|
||||||
|
r'(?<!\w)(?<!-)' + re.escape(directive) + r'(?!-)(?!\w)[\w\s.-]*\{',
|
||||||
|
re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
for m in pattern_brace.finditer(content):
|
||||||
|
start = m.start()
|
||||||
|
brace_start = m.end() - 1 # position of '{'
|
||||||
|
|
||||||
|
# Find matching closing brace
|
||||||
|
depth = 1
|
||||||
|
i = brace_start + 1
|
||||||
|
while i < len(content) and depth > 0:
|
||||||
|
if content[i] == '{':
|
||||||
|
depth += 1
|
||||||
|
elif content[i] == '}':
|
||||||
|
depth -= 1
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if depth == 0:
|
||||||
|
# Found matching }, now find the trailing ;
|
||||||
|
end = i
|
||||||
|
while end < len(content) and content[end] in ' \t\n':
|
||||||
|
end += 1
|
||||||
|
if end < len(content) and content[end] == ';':
|
||||||
|
end += 1
|
||||||
|
|
||||||
|
# Find indentation from original line
|
||||||
|
line_start = content.rfind('\n', 0, start) + 1
|
||||||
|
indent = ''
|
||||||
|
for ch in content[line_start:start]:
|
||||||
|
if ch in ' \t':
|
||||||
|
indent += ch
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
indented = '\n'.join(
|
||||||
|
(indent + line) if line else line
|
||||||
|
for line in new_block.split('\n')
|
||||||
|
)
|
||||||
|
return content[:start] + indented + content[end:]
|
||||||
|
|
||||||
|
# Pattern 2: value-terminated (simple values without braces)
|
||||||
|
pattern_value = re.compile(
|
||||||
|
r'(?<!\w)(?<!-)' + re.escape(directive) + r'(?!-)(?!\w)[^;]*;',
|
||||||
|
re.MULTILINE
|
||||||
|
)
|
||||||
|
|
||||||
|
m = pattern_value.search(content)
|
||||||
|
if m:
|
||||||
|
start = m.start()
|
||||||
|
end = m.end()
|
||||||
|
|
||||||
|
line_start = content.rfind('\n', 0, start) + 1
|
||||||
|
indent = ''
|
||||||
|
for ch in content[line_start:start]:
|
||||||
|
if ch in ' \t':
|
||||||
|
indent += ch
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
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:]
|
||||||
|
|
||||||
|
# No options block at all — 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")
|
@app.route("/config")
|
||||||
@login_required
|
@login_required
|
||||||
def config_view():
|
def config_view():
|
||||||
|
_ensure_options_file()
|
||||||
try:
|
try:
|
||||||
with open(BIND_CONF_OPTIONS) as f:
|
with open(BIND_CONF_OPTIONS) as f:
|
||||||
options_content = f.read()
|
options_content = f.read()
|
||||||
@@ -1029,12 +1266,26 @@ def config_view():
|
|||||||
config_ok = rc == 0
|
config_ok = rc == 0
|
||||||
config_msg = out + err
|
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)
|
||||||
|
listen_on_v6 = _parse_listen_on_v6(options_content)
|
||||||
|
|
||||||
return render_template("config.html",
|
return render_template("config.html",
|
||||||
options_content=options_content,
|
options_content=options_content,
|
||||||
local_content=local_content,
|
local_content=local_content,
|
||||||
main_content=main_content,
|
main_content=main_content,
|
||||||
config_ok=config_ok,
|
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,
|
||||||
|
listen_on_v6=listen_on_v6,
|
||||||
|
recursion_value=recursion_value)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/config/options", methods=["POST"])
|
@app.route("/config/options", methods=["POST"])
|
||||||
@@ -1045,6 +1296,11 @@ def config_options_save():
|
|||||||
run_cmd(f"cp {BIND_CONF_OPTIONS} {backup}")
|
run_cmd(f"cp {BIND_CONF_OPTIONS} {backup}")
|
||||||
|
|
||||||
# Write to temp and validate
|
# Write to temp and validate
|
||||||
|
# NOTE: BIND_CONF_OPTIONS content must have 'options { ... }' wrapper (it's a complete file)
|
||||||
|
# that gets included from named.conf, so named-checkconf can validate it directly
|
||||||
|
if not re.search(r'^\s*options\s*\{', content, re.MULTILINE):
|
||||||
|
# User may have accidentally removed the options wrapper in text editor
|
||||||
|
content = f"options {{\n{content}\n}};\n"
|
||||||
with open("/tmp/named_check.tmp", 'w') as f:
|
with open("/tmp/named_check.tmp", 'w') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
rc, out, err = run_cmd("named-checkconf /tmp/named_check.tmp")
|
rc, out, err = run_cmd("named-checkconf /tmp/named_check.tmp")
|
||||||
@@ -1056,7 +1312,7 @@ def config_options_save():
|
|||||||
|
|
||||||
with open(BIND_CONF_OPTIONS, 'w') as f:
|
with open(BIND_CONF_OPTIONS, 'w') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
run_cmd(f"chown root:bind {BIND_CONF_OPTIONS}")
|
run_cmd(f"chown root:bind {BIND_CONF_OPTIONS} 2>/dev/null || chown root:named {BIND_CONF_OPTIONS} 2>/dev/null")
|
||||||
|
|
||||||
bind_reload()
|
bind_reload()
|
||||||
|
|
||||||
@@ -1091,6 +1347,119 @@ def config_local_save():
|
|||||||
return redirect(url_for("config_view"))
|
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", "")
|
||||||
|
listen_on_v6_in = (request.form.get("listen_on_v6") or "none").strip().lower()
|
||||||
|
if listen_on_v6_in not in ("any", "none", "localhost"):
|
||||||
|
listen_on_v6_in = "none"
|
||||||
|
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, "listen-on-v6",
|
||||||
|
_format_list_block("listen-on-v6 port 53", [listen_on_v6_in]),
|
||||||
|
)
|
||||||
|
new_content = _replace_or_append_option(
|
||||||
|
new_content, "recursion",
|
||||||
|
f"recursion {recursion_in};\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate BEFORE writing to the live file
|
||||||
|
# Ensure content has options block wrapper (required for named-checkconf)
|
||||||
|
if not re.search(r'^\s*options\s*\{', new_content, re.MULTILINE):
|
||||||
|
# No options block found — wrap the entire content
|
||||||
|
new_content = f"options {{\n{new_content}\n}};\n"
|
||||||
|
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}; listen-on-v6={listen_on_v6_in}; 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"listen-on-v6 {listen_on_v6_in},recursion {recursion_in}",
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
return redirect(url_for("config_view"))
|
||||||
|
|
||||||
|
|
||||||
# ─── Query test ───
|
# ─── Query test ───
|
||||||
|
|
||||||
@app.route("/query", methods=["GET", "POST"])
|
@app.route("/query", methods=["GET", "POST"])
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy.sh - one-click deployment for DNS Web Manager
|
||||||
|
#
|
||||||
|
# From a fresh OS to a running service in one shot:
|
||||||
|
# 1. Install system packages (BIND, Python, pip, venv, git)
|
||||||
|
# 2. Create Python venv and install requirements.txt
|
||||||
|
# 3. Initialize SQLite database (default admin/admin)
|
||||||
|
# 4. Configure BIND (delegates to scripts/setup-bind.sh)
|
||||||
|
# 5. Install + enable systemd service (gunicorn, port 5300)
|
||||||
|
# 6. (Re)start the service and verify
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sudo bash scripts/deploy.sh # full deploy
|
||||||
|
# sudo bash scripts/deploy.sh --dry-run # preview, no changes
|
||||||
|
# sudo bash scripts/deploy.sh --skip-bind # skip BIND setup if already done
|
||||||
|
# sudo bash scripts/deploy.sh --port 5300 # override listen port
|
||||||
|
#
|
||||||
|
# Idempotent: safe to run multiple times. Existing venv/DB/config are
|
||||||
|
# preserved; only missing pieces are created.
|
||||||
|
#
|
||||||
|
# Tunables (env vars):
|
||||||
|
# DNS_WEB_PORT - listen port (default 5300)
|
||||||
|
# DNS_WEB_USER - service user (default root, needed for BIND config)
|
||||||
|
# DNS_WEB_WORKERS - gunicorn worker count (default 2)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ─── parse args ────────────────────────────────────────────────────────────
|
||||||
|
DRY_RUN=0
|
||||||
|
SKIP_BIND=0
|
||||||
|
SERVICE_PORT="${DNS_WEB_PORT:-5300}"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
--skip-bind) SKIP_BIND=1; shift ;;
|
||||||
|
--port) SERVICE_PORT="$2"; shift 2 ;;
|
||||||
|
-h|--help) sed -n '2,22p' "$0"; exit 0 ;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ─── resolve project root from script location ─────────────────────────────
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
SERVICE_NAME="dns-web"
|
||||||
|
SERVICE_USER="${DNS_WEB_USER:-root}"
|
||||||
|
WORKERS="${DNS_WEB_WORKERS:-2}"
|
||||||
|
VENV_DIR="$PROJECT_DIR/venv"
|
||||||
|
|
||||||
|
# ─── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
log() { printf '\033[1;34m[deploy]\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33m[deploy]\033[0m %s\n' "$*" >&2; }
|
||||||
|
err() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
run() {
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
printf ' [DRY-RUN] %s\n' "$*"
|
||||||
|
else
|
||||||
|
"$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
must_be_root() {
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
err "Must run as root (use sudo)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── detect distro via package-manager presence (not os-release) ──────────
|
||||||
|
# File-based detection for BIND layout is in setup-bind.sh; here we only need
|
||||||
|
# the package manager. Check command -v first, fall back to os-release.
|
||||||
|
detect_distro() {
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
DISTRO="debian"
|
||||||
|
PKG_INSTALL=(apt-get install -y)
|
||||||
|
SYS_PKGS=(bind9 bind9utils dnsutils python3 python3-pip python3-venv git)
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
DISTRO="rhel"
|
||||||
|
PKG_INSTALL=(dnf install -y)
|
||||||
|
SYS_PKGS=(bind bind-utils python3 python3-pip python3-virtualenv git)
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
DISTRO="rhel"
|
||||||
|
PKG_INSTALL=(yum install -y)
|
||||||
|
SYS_PKGS=(bind bind-utils python3 python3-pip python3-virtualenv git)
|
||||||
|
else
|
||||||
|
err "Unsupported distro: no apt-get/yum/dnf found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "Detected distro: $DISTRO (pkg mgr: ${PKG_INSTALL[0]})"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── steps ────────────────────────────────────────────────────────────────
|
||||||
|
install_system_deps() {
|
||||||
|
log "Step 1: install system packages"
|
||||||
|
log " packages: ${SYS_PKGS[*]}"
|
||||||
|
if [[ "$DISTRO" == "debian" ]]; then
|
||||||
|
# apt-get needs a refresh of the package index first; yum/dnf don't
|
||||||
|
run apt-get update -qq
|
||||||
|
fi
|
||||||
|
run "${PKG_INSTALL[@]}" "${SYS_PKGS[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_venv() {
|
||||||
|
log "Step 2: set up Python venv"
|
||||||
|
if [[ ! -d "$VENV_DIR" ]]; then
|
||||||
|
log " creating venv at $VENV_DIR"
|
||||||
|
run python3 -m venv "$VENV_DIR"
|
||||||
|
else
|
||||||
|
log " venv already exists, reusing"
|
||||||
|
fi
|
||||||
|
log " upgrading pip"
|
||||||
|
run "$VENV_DIR/bin/pip" install --upgrade pip --quiet
|
||||||
|
log " installing requirements.txt"
|
||||||
|
run "$VENV_DIR/bin/pip" install -r "$PROJECT_DIR/requirements.txt" --quiet
|
||||||
|
}
|
||||||
|
|
||||||
|
init_database() {
|
||||||
|
log "Step 3: initialize SQLite database"
|
||||||
|
run mkdir -p "$PROJECT_DIR/instance"
|
||||||
|
run "$VENV_DIR/bin/python" "$PROJECT_DIR/init_db.py"
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_bind() {
|
||||||
|
log "Step 4: configure BIND (delegating to setup-bind.sh)"
|
||||||
|
if [[ ! -f "$SCRIPT_DIR/setup-bind.sh" ]]; then
|
||||||
|
warn " $SCRIPT_DIR/setup-bind.sh not found; skipping BIND setup"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
printf ' [DRY-RUN] bash %s/setup-bind.sh\n' "$SCRIPT_DIR"
|
||||||
|
else
|
||||||
|
bash "$SCRIPT_DIR/setup-bind.sh"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_systemd_unit() {
|
||||||
|
log "Step 5: install systemd service ($SERVICE_NAME on port $SERVICE_PORT)"
|
||||||
|
local unit_file="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
local gunicorn_bin="$VENV_DIR/bin/gunicorn"
|
||||||
|
|
||||||
|
# Note on Type=exec vs Type=notify: gunicorn 21.x does NOT ship sd_notify
|
||||||
|
# support out of the box. Type=notify would hang for DefaultTimeoutStartSec
|
||||||
|
# (90s) then mark the unit failed. Type=exec considers the unit "running"
|
||||||
|
# as soon as the exec() succeeds, which is what we want.
|
||||||
|
local unit_content
|
||||||
|
unit_content="[Unit]
|
||||||
|
Description=DNS Web Manager (BIND9 web UI)
|
||||||
|
Documentation=https://git.cnbugs.com/AI-Agent/dns-service
|
||||||
|
After=network.target named.service
|
||||||
|
Wants=named.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
User=$SERVICE_USER
|
||||||
|
WorkingDirectory=$PROJECT_DIR
|
||||||
|
ExecStart=$gunicorn_bin --workers $WORKERS --bind 0.0.0.0:$SERVICE_PORT wsgi:app
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
# Optional: override BIND paths via env vars (see README \"路径优先级\")
|
||||||
|
# Environment=BIND_ZONES_DIR=/opt/bind/zones
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
"
|
||||||
|
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
printf ' [DRY-RUN] write %s\n' "$unit_file"
|
||||||
|
printf '%s\n' "$unit_content" | sed 's/^/ | /'
|
||||||
|
else
|
||||||
|
printf '%s\n' "$unit_content" > "$unit_file"
|
||||||
|
chmod 644 "$unit_file"
|
||||||
|
log " wrote $unit_file"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$SERVICE_NAME" 2>&1 | grep -v 'Created symlink' || true
|
||||||
|
log " enabled $SERVICE_NAME"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_service() {
|
||||||
|
log "Step 6: (re)start $SERVICE_NAME"
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
printf ' [DRY-RUN] systemctl restart %s\n' "$SERVICE_NAME"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
systemctl restart "$SERVICE_NAME"
|
||||||
|
sleep 2
|
||||||
|
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||||
|
log " $SERVICE_NAME is active"
|
||||||
|
else
|
||||||
|
err " $SERVICE_NAME failed to start"
|
||||||
|
err " check: journalctl -xe -u $SERVICE_NAME -n 30"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
verify() {
|
||||||
|
log "Step 7: verify deployment"
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
# Gunicorn needs a moment to bind the port after exec(); retry a few
|
||||||
|
# times so we don't false-alarm "not listening" on a slow box.
|
||||||
|
local port_ok=0 http_code
|
||||||
|
for _ in 1 2 3 4 5; do
|
||||||
|
if ss -lnt 2>/dev/null | grep -qE ":$SERVICE_PORT\b"; then
|
||||||
|
port_ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if [[ $port_ok -eq 1 ]]; then
|
||||||
|
log " port $SERVICE_PORT is listening"
|
||||||
|
else
|
||||||
|
warn " port $SERVICE_PORT not listening after 5s (may still be warming up)"
|
||||||
|
fi
|
||||||
|
# HTTP responding? 302 = login redirect (expected), 200 = some page.
|
||||||
|
http_code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$SERVICE_PORT/" --max-time 5 || echo "000")
|
||||||
|
if [[ "$http_code" == "302" || "$http_code" == "200" ]]; then
|
||||||
|
log " HTTP check: $http_code (302=login redirect, 200=page)"
|
||||||
|
else
|
||||||
|
warn " HTTP check returned $http_code (expected 302/200)"
|
||||||
|
warn " check: journalctl -u $SERVICE_NAME -n 30"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_summary() {
|
||||||
|
log "════════════════════════════════════════════════════════"
|
||||||
|
log " Deploy complete"
|
||||||
|
log "════════════════════════════════════════════════════════"
|
||||||
|
log " Service : systemctl status $SERVICE_NAME"
|
||||||
|
log " Logs : journalctl -u $SERVICE_NAME -f"
|
||||||
|
log " URL : http://$(hostname -I 2>/dev/null | awk '{print $1}'):${SERVICE_PORT}"
|
||||||
|
log " Login : admin / admin (change after first login!)"
|
||||||
|
log " Reload : bash $SCRIPT_DIR/deploy.sh"
|
||||||
|
if [[ "$SERVICE_USER" == "root" ]]; then
|
||||||
|
warn " Runs as root (required to manage BIND config + systemctl)."
|
||||||
|
warn " If your env blocks root services, override with DNS_WEB_USER=<user>"
|
||||||
|
warn " and grant that user sudo on systemctl/named/chown instead."
|
||||||
|
fi
|
||||||
|
log "════════════════════════════════════════════════════════"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── main ─────────────────────────────────────────────────────────────────
|
||||||
|
main() {
|
||||||
|
must_be_root
|
||||||
|
detect_distro
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
log "DRY-RUN mode: no changes will be made"
|
||||||
|
log " project dir : $PROJECT_DIR"
|
||||||
|
log " venv : $VENV_DIR"
|
||||||
|
log " service : $SERVICE_NAME ($SERVICE_USER@$SERVICE_PORT, $WORKERS workers)"
|
||||||
|
fi
|
||||||
|
install_system_deps
|
||||||
|
setup_venv
|
||||||
|
init_database
|
||||||
|
if [[ $SKIP_BIND -eq 0 ]]; then
|
||||||
|
configure_bind
|
||||||
|
else
|
||||||
|
warn "Skipping BIND setup (--skip-bind)"
|
||||||
|
fi
|
||||||
|
install_systemd_unit
|
||||||
|
restart_service
|
||||||
|
verify
|
||||||
|
print_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+33
-12
@@ -120,6 +120,11 @@ ensure_named_conf_local() {
|
|||||||
run chmod 640 "$NAMED_CONF_LOCAL"
|
run chmod 640 "$NAMED_CONF_LOCAL"
|
||||||
else
|
else
|
||||||
log " $NAMED_CONF_LOCAL already exists; preserving"
|
log " $NAMED_CONF_LOCAL already exists; preserving"
|
||||||
|
# Check for common path misconfigurations
|
||||||
|
if grep -q '/opt/bind' "$NAMED_CONF_LOCAL"; then
|
||||||
|
warn " ⚠️ WARNING: Found '/opt/bind' path in $NAMED_CONF_LOCAL"
|
||||||
|
warn " This is likely incorrect! Expected zones directory: $ZONES_DIR"
|
||||||
|
fi
|
||||||
# Don't change ownership if file already has content — it may be managed
|
# Don't change ownership if file already has content — it may be managed
|
||||||
# by someone else. Just make sure web process can write to it.
|
# by someone else. Just make sure web process can write to it.
|
||||||
run chmod 644 "$NAMED_CONF_LOCAL"
|
run chmod 644 "$NAMED_CONF_LOCAL"
|
||||||
@@ -145,21 +150,37 @@ ensure_include_in_named_conf() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
restart_named() {
|
restart_named() {
|
||||||
log "Step 5: restart named so include takes effect"
|
log "Step 5: verify config before starting"
|
||||||
if ! systemctl is-active --quiet "$SERVICE_NAME"; then
|
# Always validate config first
|
||||||
warn " $SERVICE_NAME not running; skipping restart"
|
if ! named-checkconf >/dev/null 2>&1; then
|
||||||
|
err " BIND configuration has errors!"
|
||||||
|
err " Running 'named-checkconf' to show details:"
|
||||||
|
named-checkconf
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log " Configuration syntax OK"
|
||||||
|
|
||||||
|
log "Step 6: start/enable named service"
|
||||||
|
# Always enable and start/restart the service
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
printf ' [DRY-RUN] systemctl enable named\n'
|
||||||
|
printf ' [DRY-RUN] systemctl restart named (or start if not running)\n'
|
||||||
|
printf ' [DRY-RUN] sleep 1\n'
|
||||||
|
printf ' [DRY-RUN] systemctl is-active --quiet named && echo "named is active"\n'
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
if [[ $DRY_RUN -eq 1 ]]; then
|
systemctl enable named 2>/dev/null || true
|
||||||
printf ' [DRY-RUN] systemctl restart %s\n' "$SERVICE_NAME"
|
if systemctl is-active --quiet named; then
|
||||||
|
systemctl restart named
|
||||||
else
|
else
|
||||||
run systemctl restart "$SERVICE_NAME"
|
systemctl start named
|
||||||
sleep 1
|
fi
|
||||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
sleep 1
|
||||||
log " $SERVICE_NAME is active"
|
if systemctl is-active --quiet named; then
|
||||||
else
|
log " named is active and running"
|
||||||
err " $SERVICE_NAME failed to start; check 'journalctl -xe -u $SERVICE_NAME'"
|
else
|
||||||
fi
|
err " named failed to start; check 'journalctl -xe -u named'"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,82 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="listen_on_v6">
|
||||||
|
<strong>Listen-on-v6 (IPv6) 53 端口</strong>
|
||||||
|
<span class="text-muted text-sm">启用或禁用 IPv6 DNS 监听。内网无 IPv6 时选 <code>none</code>(关闭)。</span>
|
||||||
|
</label>
|
||||||
|
<select name="listen_on_v6" id="listen_on_v6" class="form-control">
|
||||||
|
<option value="none" {% if listen_on_v6 == 'none' %}selected{% endif %}>none — 关闭 IPv6 监听</option>
|
||||||
|
<option value="any" {% if listen_on_v6 == 'any' %}selected{% endif %}>any — 开启 IPv6 监听(所有接口)</option>
|
||||||
|
<option value="localhost" {% if listen_on_v6 == 'localhost' %}selected{% endif %}>localhost — 仅本机 IPv6</option>
|
||||||
|
</select>
|
||||||
|
</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">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>named.conf.options (选项配置)</h2>
|
<h2>named.conf.options (选项配置)</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user