mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 16:44:23 +08:00
389 lines
12 KiB
Bash
Executable File
389 lines
12 KiB
Bash
Executable File
#!/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
|