Files

389 lines
12 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/sh
# dnsguard-apply —— 把 UCI(/etc/config/dnsguard) 渲染成 /etc/nftables.d/20-dns-guard.nft 并应用
#
# 设计原则(与邮件日报应用一致):本脚本是唯一"写防火墙"的地方,WebUI 只调用它。
#
# 用法:
# dnsguard-apply 渲染 + 校验 + 落盘 + fw4 reload,输出一行 JSON
# dnsguard-apply --quiet 同上,不输出
# dnsguard-apply --render-only 只渲染到 --out 指定文件(不动 /etc、不 reload),用于界面预览
# dnsguard-apply --no-reload 渲染 + 落盘,但不 reload(文件变更下次 fw4 reload 生效)
#
# 安全闸门:
# ① 片段先包进临时表名做 nft -c 语法校验,不通过绝不落盘
# ② 落盘后用 fw4 check 全量校验真实规则集,不通过立即还原旧文件
# ③ reload 后核对 dns_guard_redirect 链是否真的存在,不存在立即还原
# ④ redirect53=1 而 AdGuardHome 未运行 -> 直接拒绝(不然全屋解析全挂)
#
# 生成:WorkBuddy 2026-09-26
set -u
CFG=dnsguard
OUT=/etc/nftables.d/20-dns-guard.nft
BAKDIR=/mnt/data/backup-dnsguard
GENMARK='由 LuCI 应用「DNS 防绕过」自动生成'
QUIET=0
RENDER_ONLY=0
NO_RELOAD=0
OUTFILE=""
while [ $# -gt 0 ]; do
case "$1" in
--quiet) QUIET=1 ;;
--render-only) RENDER_ONLY=1 ;;
--no-reload) NO_RELOAD=1 ;;
--out) shift; OUTFILE="${1:-}" ;;
*) : ;;
esac
shift
done
# ---------------- 工具 ----------------
uget() { uci -q get "$CFG.$1" 2>/dev/null; }
bool() {
case "${1:-}" in
1|on|true|yes|enabled) echo 1 ;;
*) echo 0 ;;
esac
}
digits_only() { printf '%s' "${1:-}" | tr -dc '0-9'; }
# 单行转义成 JSON 字符串内容(换行 -> \n,反斜杠与双引号转义)
jesc() {
printf '%s' "${1:-}" | tr '\n' '\001' | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\001' '\n' | awk '{printf "%s\\n", $0}' | sed 's/\\n$//'
}
# 统一的行输出:整行作为单个参数传入,避免 ${VAR} 里的双引号被 shell 吃掉
p() { printf '%s\n' "$1"; }
out_json() {
[ "$QUIET" -eq 1 ] && return 0
printf '%s\n' "$1"
}
EMIT_JSON() {
out_json "{\"status\":\"${STATUS:-error}\",\"message\":\"$(jesc "${MSG:-}")\",\"enabled\":\"${ENABLED:-0}\",\"redirect\":\"${REDIRECT:-0}\",\"watch\":\"${WATCH:-0}\",\"block853\":\"${B853:-0}\",\"blockdoh\":\"${BDOH:-0}\",\"file\":\"${FILE_STATE:-none}\",\"reload\":\"${RELOAD_STATE:-skipped}\",\"redirect_active\":\"${REDIRECT_ACTIVE:-0}\",\"target\":\"${TARGET:-53}\",\"ifaces\":\"$(jesc "${IFACES:-}")\",\"backup\":\"$(jesc "${BAK_TS:-}")\"}"
}
fail() {
STATUS=error
MSG="$1"
EMIT_JSON
exit "${2:-1}"
}
# 只保留合法 IP/CIDR,其余丢弃 —— 防止把垃圾写进 nft 导致整个防火墙加载失败
# ⚠️ 必须用 printf '%s\n' 补结尾换行:否则 while read 会把「最后一个元素」丢掉(踩过)
set_elems() {
printf '%s\n' "${1:-}" | sed 's/[[:space:]]\{1,\}/\n/g' | while read -r e; do
[ -n "$e" ] || continue
case "$e" in
*:*)
printf '%s' "$e" | grep -qE '^[0-9a-fA-F:]+(/[0-9]{1,3})?$' && printf '%s ' "$e" ;;
*)
printf '%s' "$e" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$' && printf '%s ' "$e" ;;
esac
done
}
# 允许的入接口(字母数字点下划线连字符)
set_ifaces() {
printf '%s\n' "${1:-}" | sed 's/[[:space:]]\{1,\}/\n/g' | while read -r e; do
[ -n "$e" ] || continue
printf '%s' "$e" | grep -qE '^[0-9A-Za-z._-]+$' && printf '%s ' "$e"
done
}
split4() { for e in ${1:-}; do case "$e" in *:*) : ;; *) printf '%s ' "$e" ;; esac; done; }
split6() { for e in ${1:-}; do case "$e" in *:*) printf '%s ' "$e" ;; *) : ;; esac; done; }
# 空格分隔 -> nft 集合元素 "a, b, c"
join_elems() {
printf '%s' "${1:-}" | sed 's/^ *//; s/ *$//; s/ */, /g'
}
# ---------------- 1. 读配置 ----------------
ENABLED=$(bool "$(uget main.enabled)")
REDIRECT=$(bool "$(uget main.redirect53)")
WATCH=$(bool "$(uget main.watch)")
B853=$(bool "$(uget main.block853)")
BDOH=$(bool "$(uget main.blockdoh)")
TARGET=$(digits_only "$(uget main.target_port)"); [ -n "$TARGET" ] || TARGET=53
LOGLIMIT=$(digits_only "$(uget main.log_limit)"); [ -n "$LOGLIMIT" ] || LOGLIMIT=5
IFACES=$(set_ifaces "$(uget main.ifaces)")
[ -n "$IFACES" ] || IFACES="br-lan"
IFACES=$(printf '%s' "$IFACES" | sed 's/ *$//')
SKIP_RAW=$(set_elems "$(uget main.skip_devices)")
SKIP4=$(split4 "$SKIP_RAW")
SKIP6=$(split6 "$SKIP_RAW")
NH4=$(set_elems "$(uget limits.never_hijack4)")
NH6=$(set_elems "$(uget limits.never_hijack6)")
DOH=$(set_elems "$(uget limits.doh_servers)")
# 接口集合:单接口写 "br-lan",多接口写 { "a", "b" }
IIF_RENDER=$(printf '%s' "$IFACES" | sed 's/^ *//; s/ *$//' | awk '{
n = split($0, a, " ")
if (n <= 1) { print "\"" a[1] "\"" }
else { s = "{ "; for (i = 1; i <= n; i++) { s = s "\"" a[i] "\"" (i < n ? ", " : " ") } s = s "}"; print s }
}')
STATUS=ok
MSG=""
FILE_STATE=none
RELOAD_STATE=skipped
REDIRECT_ACTIVE=0
BAK_TS=""
# 安全闸门 ④:重定向开着但 AGH 没跑 —— 无论如何都不能生成这条规则
AGH_PID=$(pidof AdGuardHome 2>/dev/null)
if [ "$ENABLED" = "1" ] && [ "$REDIRECT" = "1" ] && [ -z "$AGH_PID" ]; then
fail "AdGuard Home 未运行(pidof 为空)—— 已拒绝应用 53 透明重定向:此时重定向会让全屋解析失败。请先启动 AdGuard Home。" 3
fi
# ---------------- 2. 渲染 ----------------
TMPD="/tmp/dg-apply.$$"
mkdir -p "$TMPD" 2>/dev/null
TMP="$TMPD/20-dns-guard.nft"
gen() {
p "## ============================================================"
p "## DNS 防绕过 —— ${GENMARK},请勿手工编辑"
p "## 生成时间:$(date '+%Y-%m-%d %H:%M:%S') (= 规则上一次实际变化的时间;幂等比较时会忽略本行)"
p "## 入口:LuCI -> 服务 -> DNS 防绕过 回滚:/usr/bin/dnsguard-rollback"
p "## 备份目录:$BAKDIR"
p "## 本文件由 fw4 在 inet/fw4 表上下文内 include(见 /etc/nftables.d/README)"
p "## ============================================================"
p ""
if [ "$ENABLED" != "1" ]; then
p "## 总开关已关闭:不生成任何链(等于恢复出厂状态)"
return 0
fi
# ---- 集合 ----
if [ -n "$DOH" ]; then
p "set doh_servers {"
p " type ipv4_addr"
p " flags interval"
p " elements = { $(join_elems "$DOH") }"
p "}"
p ""
fi
p "## 永不劫持的目标(内网 / 保留段)—— 避免把指向内网 DNS 的查询也改道"
p "set dns_never_hijack4 {"
p " type ipv4_addr"
p " flags interval"
[ -n "$NH4" ] && p " elements = { $(join_elems "$NH4") }"
p "}"
p ""
p "set dns_never_hijack6 {"
p " type ipv6_addr"
p " flags interval"
[ -n "$NH6" ] && p " elements = { $(join_elems "$NH6") }"
p "}"
p ""
if [ "$REDIRECT" = "1" ] && [ -n "$SKIP_RAW" ]; then
p "## 设备级例外:这些源地址不做透明重定向(界面「例外设备」)"
p "set dns_hijack_skip4 {"
p " type ipv4_addr"
p " flags interval"
[ -n "$SKIP4" ] && p " elements = { $(join_elems "$SKIP4") }"
p "}"
p ""
p "set dns_hijack_skip6 {"
p " type ipv6_addr"
p " flags interval"
[ -n "$SKIP6" ] && p " elements = { $(join_elems "$SKIP6") }"
p "}"
p ""
fi
# ---- 层 ①:53 透明重定向 ----
if [ "$REDIRECT" = "1" ]; then
if [ -n "$SKIP_RAW" ]; then
S4="ip saddr != @dns_hijack_skip4"
S6="ip6 saddr != @dns_hijack_skip6"
else
S4=""
S6=""
fi
p "## 层 ① 53 透明重定向 -> AdGuard Home(nat prerouting priority -105,早于 fw4 dstnat -100)"
p "## 客户端无感:以为还在跟 8.8.8.8 说话,实际被改道到本机 :${TARGET}"
p "chain dns_guard_redirect {"
p " type nat hook prerouting priority -105; policy accept;"
p ""
p " iifname ${IIF_RENDER} meta l4proto { tcp, udp } th dport 53 ${S4} ip daddr != @dns_never_hijack4 counter redirect to :${TARGET}"
p " iifname ${IIF_RENDER} meta l4proto { tcp, udp } th dport 53 ${S6} ip6 daddr != @dns_never_hijack6 counter redirect to :${TARGET}"
p "}"
p ""
fi
# ---- 层 ②③:853 / DoH 阻断(默认关闭)----
if [ "$B853" = "1" ] || [ "$BDOH" = "1" ]; then
p "## 层 ②③ 853(DoT) 与 DoH 专用地址阻断(filter forward priority -2,早于 fw4 默认链)"
p "chain dns_guard_block {"
p " type filter hook forward priority -2; policy accept;"
p ""
if [ "$B853" = "1" ]; then
if [ -n "$SKIP_RAW" ]; then SB="ip saddr != @dns_hijack_skip4"; else SB=""; fi
p " ## DoT 阻断:客户端会回落到明文 53,从而被上面的重定向收编"
p " iifname ${IIF_RENDER} meta l4proto tcp th dport 853 ${SB} counter reject with tcp reset"
p " iifname ${IIF_RENDER} meta l4proto udp th dport 853 ${SB} counter reject"
p ""
fi
if [ "$BDOH" = "1" ] && [ -n "$DOH" ]; then
p " ## DoH 专用地址阻断(按 IP 命中,可能误伤「恰好也在这个 IP 上」的其它服务,慎用)"
p " iifname ${IIF_RENDER} tcp dport 443 ip daddr @doh_servers counter reject with tcp reset"
p ""
fi
p "}"
p ""
fi
# ---- 观察计数(阶段 1 沿用)----
if [ "$WATCH" = "1" ]; then
p "## 观察计数:只计数 + 抽样记日志,不拦截"
p "## 注意:53 被上面重定向后不再进 forward 链,此链的 53 计数会趋近于 0(只剩 853/DoH)"
p "chain dns_guard_pre_forward {"
p " type filter hook forward priority -1; policy accept;"
p ""
p " iifname ${IIF_RENDER} oifname \"pppoe-wan\" meta l4proto { tcp, udp } th dport { 53, 853 } counter log prefix \"DNS-BYPASS-53853: \" limit rate ${LOGLIMIT}/minute"
[ -n "$DOH" ] && p " iifname ${IIF_RENDER} oifname \"pppoe-wan\" meta l4proto tcp th dport 443 ip daddr @doh_servers counter log prefix \"DNS-BYPASS-DOH: \" limit rate ${LOGLIMIT}/minute"
p "}"
p ""
fi
return 0
}
gen > "$TMP" 2>/dev/null
if [ ! -s "$TMP" ]; then
rm -rf "$TMPD"
fail "渲染失败:生成的规则文件为空" 4
fi
# ---------------- 3. 语法校验(临时表包裹)----------------
validate() {
CK="$TMPD/check.nft"
{ echo "table inet dgcheck {"; cat "$1"; echo "}"; } > "$CK" 2>/dev/null
ERR=$(nft -c -f "$CK" 2>&1)
RC=$?
rm -f "$CK"
if [ "$RC" -ne 0 ]; then
printf '%s' "$ERR"
return 1
fi
return 0
}
ERRMSG=$(validate "$TMP")
if [ -n "$ERRMSG" ]; then
rm -rf "$TMPD"
fail "nft 语法校验未通过,已中止(未改动任何文件):$ERRMSG" 5
fi
# ---------------- 4. 仅渲染(界面预览)----------------
if [ "$RENDER_ONLY" -eq 1 ]; then
DEST="${OUTFILE:-/tmp/dg-preview.nft}"
cp "$TMP" "$DEST" 2>/dev/null
rm -rf "$TMPD"
STATUS=ok
MSG="已渲染到 $DEST(未应用到防火墙)"
FILE_STATE=preview
EMIT_JSON
exit 0
fi
# ---------------- 5. 落盘 + 应用 ----------------
# 幂等比较:必须忽略会变的「生成时间」行,否则每次都比出差异、白白重载一次防火墙
strip_volatile() { sed '/^## 生成时间:/d' "$1"; }
if [ -f "$OUT" ]; then
strip_volatile "$TMP" > "$TMPD/norm.new" 2>/dev/null
strip_volatile "$OUT" > "$TMPD/norm.old" 2>/dev/null
if cmp -s "$TMPD/norm.new" "$TMPD/norm.old"; then
FILE_STATE=unchanged
rm -rf "$TMPD"
if [ "$REDIRECT" = "1" ]; then
nft list chain inet fw4 dns_guard_redirect >/dev/null 2>&1 && REDIRECT_ACTIVE=1
fi
STATUS=ok
MSG="规则无变化(未重新加载防火墙)"
EMIT_JSON
exit 0
fi
fi
# 备份旧文件
OLD="/tmp/dg-old.$$.nft"
cp "$OUT" "$OLD" 2>/dev/null
if [ -f "$OUT" ]; then
BAK_TS=$(date +%Y%m%d-%H%M%S)
mkdir -p "$BAKDIR/$BAK_TS" 2>/dev/null
cp "$OUT" "$BAKDIR/$BAK_TS/20-dns-guard.nft" 2>/dev/null
fi
cp "$TMP" "$OUT" 2>/dev/null
chmod 644 "$OUT" 2>/dev/null
FILE_STATE=written
# fw4 全量校验:不通过就立刻还原
if ! fw4 check >/dev/null 2>&1; then
CKERR=$(fw4 check 2>&1 | grep -v 'Automatically including' | grep -v 'docker_nat' | head -5)
if [ -f "$OLD" ]; then
cp "$OLD" "$OUT" 2>/dev/null
else
rm -f "$OUT"
fi
rm -f "$OLD"; rm -rf "$TMPD"
fail "fw4 全量校验未通过,已还原旧规则:$CKERR" 6
fi
if [ "$NO_RELOAD" -eq 1 ]; then
RELOAD_STATE="deferred"
rm -f "$OLD"; rm -rf "$TMPD"
STATUS=ok
MSG="规则已写入 $OUT(未重载,下次防火墙重载生效)"
EMIT_JSON
exit 0
fi
fw4 reload >/dev/null 2>&1
RELOAD_STATE=done
# 应用后核对:开了重定向就必须真有这条链
if [ "$REDIRECT" = "1" ]; then
if nft list chain inet fw4 dns_guard_redirect >/dev/null 2>&1; then
REDIRECT_ACTIVE=1
else
if [ -f "$OLD" ]; then
cp "$OLD" "$OUT" 2>/dev/null
fw4 reload >/dev/null 2>&1
fi
rm -f "$OLD"; rm -rf "$TMPD"
fail "规则已写入但重定向链未生效(已还原旧规则)—— 请检查 /etc/nftables.d 与 fw4 日志" 7
fi
fi
rm -f "$OLD"; rm -rf "$TMPD"
STATUS=ok
MSG="规则已应用并重载防火墙"
EMIT_JSON
exit 0