🤞 Sync 2026-09-27 22:16:29

This commit is contained in:
github-actions[bot] committed 2026-09-27 22:16:29 +08:00
1 parent 8ede4a8320
commit 618f51e9dc
459 files changed
+44817 -2191

No files matched your search

+388
View File
@@ -0,0 +1,388 @@
#!/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
+135
View File
@@ -0,0 +1,135 @@
#!/bin/sh
# dnsguard-rollback —— 回滚点管理与自动回滚
#
# dnsguard-rollback 列出可用回滚点
# dnsguard-rollback --arm [分钟] 把"当前 UCI + 当前规则文件"做成回滚点,并挂倒计时自动回滚
# dnsguard-rollback --disarm 取消挂起的自动回滚(= 确认保留;不影响已有回滚点)
# dnsguard-rollback --status 输出一行 JSON:挂起状态 + 可用回滚点
# dnsguard-rollback <时间戳|latest> 还原到指定回滚点(UCI + 规则文件一起还原)并 reload
# dnsguard-rollback --auto <ts> <秒> 内部使用:倒计时结束后自动还原
#
# 为什么要有「自动回滚」:DNS 是全家网的命门,改错了人可能连路由器都访问不到(域名解析不了)。
# 挂一个 N 分钟倒计时,人不在旁边确认就自动还原 —— 与"改 LAN IP 前挂回滚定时器"同一套纪律。
#
# 生成:WorkBuddy 2026-09-26
set -u
BAKDIR=/mnt/data/backup-dnsguard
OUT=/etc/nftables.d/20-dns-guard.nft
REC=/tmp/.dnsguard-rollback
LOG_TAG=dnsguard
snapshot() {
# snapshot <时间戳> 把 UCI + 当前规则文件存进回滚点目录
_t="$1"
mkdir -p "$BAKDIR/$_t" 2>/dev/null
[ -f "$OUT" ] && cp "$OUT" "$BAKDIR/$_t/20-dns-guard.nft" 2>/dev/null
uci export dnsguard > "$BAKDIR/$_t/dnsguard.uci" 2>/dev/null
[ -f "$BAKDIR/$_t/dnsguard.uci" ] && chmod 600 "$BAKDIR/$_t/dnsguard.uci" 2>/dev/null
}
do_restore() {
_t="$1"
if [ ! -d "$BAKDIR/$_t" ]; then
echo "回滚点不存在:$BAKDIR/$_t" >&2
return 1
fi
# 还原 UCI(让界面显示与实际一致)
if [ -f "$BAKDIR/$_t/dnsguard.uci" ]; then
uci import dnsguard < "$BAKDIR/$_t/dnsguard.uci" 2>/dev/null
uci commit dnsguard 2>/dev/null
fi
# 还原规则文件
if [ -f "$BAKDIR/$_t/20-dns-guard.nft" ]; then
cp "$BAKDIR/$_t/20-dns-guard.nft" "$OUT" 2>/dev/null
else
rm -f "$OUT" 2>/dev/null
fi
fw4 reload >/dev/null 2>&1
logger -t "$LOG_TAG" "已回滚到回滚点 $_t(UCI + 规则文件),防火墙已重载"
echo "已回滚到 $_t"
return 0
}
# ---------------- 参数分发 ----------------
case "${1:-}" in
"")
echo "可用回滚点(新 → 旧):"
ls -1t "$BAKDIR" 2>/dev/null | head -20
;;
--status)
PEND=""
if [ -f "$REC" ]; then
read -r RTS RSEC RDL RSRC < "$REC" 2>/dev/null
NOW=$(date +%s)
LEFT=$(( ${RDL:-0} - NOW ))
[ "$LEFT" -lt 0 ] && LEFT=0
PEND="\"pending\":true,\"pending_ts\":\"${RTS:-}\",\"pending_left\":${LEFT},\"pending_total\":${RSEC:-0}"
else
PEND='"pending":false,"pending_ts":"","pending_left":0,"pending_total":0'
fi
LIST=""
for d in $(ls -1t "$BAKDIR" 2>/dev/null | head -10); do
[ -n "$LIST" ] && LIST="$LIST,"
LIST="$LIST\"$d\""
done
printf '{"status":"ok",%s,"backups":[%s]}\n' "$PEND" "$LIST"
;;
--arm)
SEC=$(printf '%s' "${2:-10}" | tr -dc '0-9'); [ -n "$SEC" ] || SEC=10
# 秒数上限保护:1 分钟 ~ 120 分钟
[ "$SEC" -lt 1 ] && SEC=1
[ "$SEC" -gt 120 ] && SEC=120
TS=$(date +%Y%m%d-%H%M%S)
snapshot "$TS"
DL=$(( $(date +%s) + SEC * 60 ))
printf '%s %s %s %s\n' "$TS" "$((SEC * 60))" "$DL" "pending" > "$REC"
setsid /bin/sh -c "/usr/bin/dnsguard-rollback --auto '$TS' '$((SEC * 60))'" >/dev/null 2>&1 &
logger -t "$LOG_TAG" "已挂自动回滚:$SEC 分钟后(若无确认)还原到回滚点 $TS"
printf '{"status":"ok","ts":"%s","seconds":%s}\n' "$TS" "$((SEC * 60))"
;;
--disarm)
if [ -f "$REC" ]; then
read -r RTS RSEC RDL RSRC < "$REC" 2>/dev/null
rm -f "$REC"
# 尽力杀掉倒计时进程;即使没杀干净,--auto 也会因为记录文件不存在而放弃
for pid in $(ps w 2>/dev/null | grep '[d]nsguard-rollback --auto' | awk '{print $1}'); do
kill "$pid" 2>/dev/null
done
logger -t "$LOG_TAG" "已取消自动回滚(确认保留当前设置),回滚点 $RTS 仍保留"
printf '{"status":"ok","message":"已取消自动回滚,当前设置被保留","ts":"%s"}\n' "$RTS"
else
printf '{"status":"ok","message":"没有挂起的自动回滚"}\n'
fi
;;
--auto)
TS="${2:-}"
SEC="${3:-600}"
sleep "$SEC" 2>/dev/null
# 记录文件是唯一真相:被 --disarm 删掉、或被后来的 --arm 覆盖,就什么都不做
[ -f "$REC" ] || exit 0
read -r RTS RSEC RDL RSRC < "$REC" 2>/dev/null
[ "${RTS:-}" = "$TS" ] || exit 0
rm -f "$REC"
logger -t "$LOG_TAG" "自动回滚触发:$((SEC / 60)) 分钟内未确认,正在还原到 $TS"
do_restore "$TS" >/dev/null 2>&1
exit 0
;;
*)
TS="$1"
if [ "$TS" = "latest" ]; then
TS=$(ls -1t "$BAKDIR" 2>/dev/null | head -1)
fi
[ -n "$TS" ] || { echo "找不到回滚点" >&2; exit 1; }
do_restore "$TS"
;;
esac
exit 0
+131
View File
@@ -0,0 +1,131 @@
#!/bin/sh
# dnsguard-selftest —— DNS 防绕过健康自检,输出一行 JSON(供 WebUI 渲染)
#
# 用法:dnsguard-selftest
# 只读:不修改任何配置、不重启任何服务
#
# 生成:WorkBuddy 2026-09-26
set -u
jesc() {
printf '%s' "${1:-}" | tr '\n' '\001' | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\001' '\n' | awk '{printf "%s\\n", $0}' | sed 's/\\n$//'
}
ITEMS=""
add() {
# add <名称> <ok|warn|fail> <说明>
_n="$1"; _s="$2"; _d="$3"
[ -n "$ITEMS" ] && ITEMS="$ITEMS,"
ITEMS="$ITEMS{\"name\":\"$(jesc "$_n")\",\"state\":\"$_s\",\"detail\":\"$(jesc "$_d")\"}"
}
MODE="none"
# ---- 1. AdGuard Home 进程 ----
AGH_PID=$(pidof AdGuardHome 2>/dev/null)
if [ -n "$AGH_PID" ]; then
add "AdGuard Home 进程" ok "运行中,PID $AGH_PID"
AGH_OK=1
else
add "AdGuard Home 进程" fail "未运行 —— 此时若已启用 53 重定向,全屋解析会失败"
AGH_OK=0
fi
# ---- 2. :53 监听 ----
LISTEN=$(netstat -lntup 2>/dev/null | grep ':53 ' | tr -s ' ' ' ')
if [ -n "$LISTEN" ]; then
add ":53 监听(TCP/UDP)" ok "$(printf '%s' "$LISTEN" | sed 's/ *$//' | tr '\n' ';')"
LISTEN_OK=1
else
add ":53 监听(TCP/UDP)" fail "没有任何进程监听 :53"
LISTEN_OK=0
fi
# ---- 3. 本机解析(走 AGH)----
if [ "$AGH_OK" = 1 ]; then
OUT1=$(nslookup baidu.com 127.0.0.1 2>&1)
N1=$(printf '%s' "$OUT1" | grep -c 'Address')
if [ "${N1:-0}" -ge 2 ]; then
IP1=$(printf '%s' "$OUT1" | grep 'Address' | tail -1 | awk '{print $NF}')
add "公网域名解析(baidu.com → 127.0.0.1)" ok "成功,返回 $IP1"
else
add "公网域名解析(baidu.com → 127.0.0.1)" fail "$(printf '%s' "$OUT1" | tr '\n' ' ' | sed 's/ */ /g' | cut -c1-160)"
fi
OUT2=$(nslookup iStoreOS.lan 127.0.0.1 2>&1)
N2=$(printf '%s' "$OUT2" | grep -c 'Address')
if [ "${N2:-0}" -ge 2 ]; then
IP2=$(printf '%s' "$OUT2" | grep 'Address' | tail -1 | awk '{print $NF}')
add "本地域名解析(iStoreOS.lan → 127.0.0.1)" ok "成功,返回 $IP2"
else
add "本地域名解析(iStoreOS.lan → 127.0.0.1)" warn "未返回地址(dnsmasq 5353 或 .lan 转发可能异常)"
fi
else
add "本机解析自检" warn "AdGuard Home 未运行,跳过解析测试"
fi
# ---- 4. 重定向链是否生效 ----
RED=$(nft list chain inet fw4 dns_guard_redirect 2>/dev/null)
if printf '%s' "$RED" | grep -q 'chain dns_guard_redirect'; then
MODE=redirect
RP=$(printf '%s' "$RED" | grep -o 'counter packets [0-9]*' | head -1 | awk '{print $3}')
RB=$(printf '%s' "$RED" | grep -o 'bytes [0-9]*' | awk '{s+=$2} END {print s+0}')
add "53 透明重定向链" ok "已生效 · 已收编 ${RP:-0} 个包 / ${RB:-0} 字节(自上次重载起)"
if [ "${RP:-0}" -gt 0 ]; then
add "端到端收编验证" ok "有 LAN 客户端的 53 查询被改道到本机(计数 > 0)"
else
add "端到端收编验证" warn "计数为 0:可能是刚重载,或当前没有设备使用非本机 DNS"
fi
else
add "53 透明重定向链" warn "不存在(未启用,或总开关关闭)"
fi
# ---- 5. 观察计数链 ----
OBS=$(nft list chain inet fw4 dns_guard_pre_forward 2>/dev/null)
if printf '%s' "$OBS" | grep -q 'chain dns_guard_pre_forward'; then
CNT=$(printf '%s' "$OBS" | grep -o 'packets [0-9]*' | awk '{print $2}' | tr '\n' ' ')
CNT=$(printf '%s' "$CNT" | sed 's/ *$//; s/ */ \/ /g')
add "观察计数链" ok "存在 · 各规则包数 ${CNT:-0}"
else
add "观察计数链" warn "不存在(观察模式未启用)"
fi
# ---- 6. 规则文件归属 ----
FN=/etc/nftables.d/20-dns-guard.nft
if [ -f "$FN" ]; then
MT=$(stat -c '%y' "$FN" 2>/dev/null | cut -c1-19)
if grep -q 'DNS 防绕过' "$FN" 2>/dev/null; then
add "规则文件" ok "$FN · 由本应用托管 · 最后生成 $MT"
else
add "规则文件" warn "$FN 存在但不是本应用生成的(手工文件)—— 点「保存并应用」会被接管"
fi
else
add "规则文件" warn "$FN 不存在(总开关关闭或从未应用过)"
fi
# ---- 7. 例外设备 ----
SKIP=$(uci -q get dnsguard.main.skip_devices)
TMODE=$(uci -q get dnsguard.main.redirect53)
if [ "${TMODE:-0}" = "1" ]; then
if [ -n "$SKIP" ]; then
add "重定向例外设备" ok "已配置:$SKIP"
else
add "重定向例外设备" ok "无(所有 LAN 客户端的明文 DNS 都会被收编)"
fi
fi
# ---- 8. 挂起的自动回滚 ----
RF=/tmp/.dnsguard-rollback
if [ -f "$RF" ]; then
read -r RTS RSEC RDL RSRC < "$RF" 2>/dev/null
NOW=$(date +%s)
LEFT=$(( ${RDL:-0} - NOW ))
[ "$LEFT" -lt 0 ] && LEFT=0
add "自动回滚倒计时" warn "挂起中:剩余约 ${LEFT} 秒后自动还原到快照 $RTS(点「确认保留」可取消)"
else
add "自动回滚倒计时" ok "无挂起"
fi
printf '{"status":"ok","mode":"%s","items":[%s]}\n' "$MODE" "$ITEMS"
exit 0
@@ -0,0 +1,358 @@
-- dnsguard.lua —— LuCI 控制器:DNS 防绕过(53 透明重定向 / 观察计数 / 853·DoH 阻断 / 健康自检 / 回滚)
--
-- 实现方式与同机第三方应用 luci-app-lmclient、以及本机 luci-app-routerreport 一致:
-- 控制器只做「校验参数 -> uci set/commit -> 调本机脚本」,真正写防火墙的动作全在 dnsguard-apply 里。
-- 生成:WorkBuddy 2026-09-26
module("luci.controller.dnsguard", package.seeall)
local sys = require "luci.sys"
local http = require "luci.http"
local uci = require "luci.model.uci".cursor()
local APPLY = "/usr/bin/dnsguard-apply"
local SELFTEST = "/usr/bin/dnsguard-selftest"
local ROLLBACK = "/usr/bin/dnsguard-rollback"
local NFTPREV = "/tmp/dg-preview.nft"
local LOGFILE = "/mnt/data/logs/messages"
local NFTOUT = "/etc/nftables.d/20-dns-guard.nft"
-- ---------- 小工具 ----------
local function jesc(s)
s = tostring(s or "")
s = s:gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\r", "")
s = s:gsub("\n", "\\n"):gsub("\t", " ")
return s
end
local function jstr(s) return '"' .. jesc(s) .. '"' end
local function num(s) return tonumber(s or "") or 0 end
local function exec(cmd)
local out = sys.exec(cmd .. " 2>/dev/null")
return out or ""
end
local function trim(s) return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", "")) end
local function json_out(s)
http.prepare_content("application/json")
http.write(s)
return
end
local function fv(name, maxlen)
local v = http.formvalue(name)
if v == nil then return nil end
v = v:gsub("[\r\n\t]", " ")
if maxlen and #v > maxlen then v = v:sub(1, maxlen) end
return v
end
-- 只保留 IP/CIDR 里合法出现的字符(写入 nft 前的第一道清洗;dnsguard-apply 里还有第二道)
-- ⚠️ 白名单必须含 `/`,否则 CIDR 会被洗成 192.168.1.024 这种怪物(联调时真踩到过)
local function clean_set(s)
s = tostring(s or "")
return trim(s:gsub("[^%w%.%:%-/%s%,]", ""))
end
-- 接口名:字母数字点下划线连字符 + 空格
local function clean_ifaces(s)
s = tostring(s or "")
return trim(s:gsub("[^%w%._%-%s]", ""))
end
local function digits(s)
s = tostring(s or "")
return s:gsub("[^%d]", "")
end
-- ---------- 菜单 ----------
function index()
entry({ "admin", "services", "dnsguard" },
template("dnsguard/main"), _("DNS 防绕过"), 82).acl_depends = { "luci-app-dnsguard" }
entry({ "admin", "services", "dnsguard", "api" },
call("action_api"), nil).acl_depends = { "luci-app-dnsguard" }
end
-- ---------- 动作分发 ----------
function action_api()
local action = fv("action") or ""
if not action:match("^[%w_]+$") then
return json_out('{"status":"error","message":"invalid action"}')
end
if action == "save" then
return action_save()
elseif action == "apply" then
return action_apply()
elseif action == "status" then
return action_status()
elseif action == "selftest" then
return action_selftest()
elseif action == "rules" then
return action_rules()
elseif action == "disarm" then
return action_rollback_cmd("--disarm", "")
elseif action == "rollback" then
return action_restore()
end
return json_out('{"status":"error","message":"unknown action"}')
end
-- ---------- 保存配置 ----------
function action_save()
local function flag(n) return (fv(n) == "1") and "1" or "0" end
local new_enabled = flag("enabled")
local new_redirect = flag("redirect53")
local watch = flag("watch")
local block853 = flag("block853")
local blockdoh = flag("blockdoh")
local target_port = digits(fv("target_port", 6))
local log_limit = digits(fv("log_limit", 4))
local roll_min = digits(fv("rollback_minutes", 4))
local ifaces = clean_ifaces(fv("ifaces", 200))
local skip = clean_set(fv("skip_devices", 400))
local nh4 = clean_set(fv("never_hijack4", 600))
local nh6 = clean_set(fv("never_hijack6", 600))
local doh = clean_set(fv("doh_servers", 900))
-- 校验
if target_port == "" or num(target_port) < 1 or num(target_port) > 65535 then
return json_out('{"status":"error","message":"重定向目标端口必须是 1-65535 的数字"}')
end
if ifaces == "" then
return json_out('{"status":"error","message":"入接口不能为空(默认 br-lan)"}')
end
if log_limit == "" then log_limit = "5" end
if num(log_limit) < 1 or num(log_limit) > 999 then
return json_out('{"status":"error","message":"日志限速必须是 1-999(每分钟条数)"}')
end
if roll_min == "" then roll_min = "10" end
if num(roll_min) < 1 or num(roll_min) > 120 then
return json_out('{"status":"error","message":"自动回滚倒计时必须是 1-120 分钟"}')
end
local c = uci
local OLD_REDIRECT = c:get("dnsguard", "main", "redirect53") or "0"
c:set("dnsguard", "main", "enabled", new_enabled)
c:set("dnsguard", "main", "redirect53", new_redirect)
c:set("dnsguard", "main", "watch", watch)
c:set("dnsguard", "main", "block853", block853)
c:set("dnsguard", "main", "blockdoh", blockdoh)
c:set("dnsguard", "main", "target_port", target_port)
c:set("dnsguard", "main", "ifaces", ifaces)
c:set("dnsguard", "main", "skip_devices", skip)
c:set("dnsguard", "main", "log_limit", log_limit)
c:set("dnsguard", "main", "rollback_minutes", roll_min)
c:set("dnsguard", "limits", "never_hijack4", nh4)
c:set("dnsguard", "limits", "never_hijack6", nh6)
c:set("dnsguard", "limits", "doh_servers", doh)
c:commit("dnsguard")
-- 关 -> 开 的那一刻,自动挂一个回滚倒计时(DNS 改错会让全家上不了网,留条后路)
local armed = ""
if new_enabled == "1" and new_redirect == "1" and OLD_REDIRECT ~= "1" then
local r = exec(ROLLBACK .. " --arm " .. roll_min)
if r:match('"ts":"([%d%-]+)"') then
armed = r:match('"ts":"([%d%-]+)"')
end
end
-- 渲染并应用
local out = trim(exec(APPLY))
local j = out:match("({.*})")
if not j then
return json_out('{"status":"error","message":"配置已保存,但应用失败:' .. jesc(out) .. '"}')
end
local st = j:match('"status":"([%w]+)"') or "error"
local msg = j:match('"message":"(.-)","enabled"') or ""
if st ~= "ok" then
return json_out('{"status":"error","message":' .. jstr(msg) .. ',"apply":' .. jstr(j) .. '}')
end
local red_ok = (j:match('"redirect_active":"(%d)"') == "1")
local file_st = j:match('"file":"([%w]+)"') or ""
local head = "配置已保存"
if file_st == "unchanged" then
head = head .. "(规则无变化,未重载防火墙)"
else
head = head .. "并生效"
end
head = head .. " · 53 重定向:" .. ((new_enabled == "1" and new_redirect == "1") and (red_ok and "已生效" or "未生效") or "关闭")
if armed ~= "" then
head = head .. " · 已挂 " .. roll_min .. " 分钟自动回滚(快照 " .. armed .. "),未确认将自动还原"
end
return json_out('{"status":"ok","message":' .. jstr(head) ..
',"at":' .. jstr(os.date("%H:%M:%S")) ..
',"armed":' .. jstr(armed) ..
',"redirect_active":' .. (red_ok and "true" or "false") ..
',"apply":' .. jstr(j) .. '}')
end
-- ---------- 只重新应用(不改配置)----------
function action_apply()
local out = trim(exec(APPLY .. " --quiet"))
local j = out:match("({.*})")
if not j then
return json_out('{"status":"error","message":"应用失败:' .. jesc(out) .. '"}')
end
local st = j:match('"status":"([%w]+)"') or "error"
local msg = j:match('"message":"(([^"\\]|\\.)*)"') or ""
if st ~= "ok" then
return json_out('{"status":"error","message":' .. jstr(msg) .. ',"apply":' .. jstr(j) .. '}')
end
return json_out('{"status":"ok","message":' .. jstr(msg) ..
',"at":' .. jstr(os.date("%H:%M:%S")) .. ',"apply":' .. jstr(j) .. '}')
end
-- ---------- 状态 ----------
local function nft_chain_summary(name)
local t = exec("nft list chain inet fw4 " .. name)
local active = t:find("chain " .. name) ~= nil
local pk, by, list = 0, 0, {}
for a, b in t:gmatch("counter packets (%d+) bytes (%d+)") do
pk = pk + num(a); by = by + num(b)
list[#list + 1] = '{"packets":' .. num(a) .. ',"bytes":' .. num(b) .. '}'
end
return active, pk, by, "[" .. table.concat(list, ",") .. "]"
end
function action_status()
local c = uci
local function g(sec, opt, def)
local v = c:get("dnsguard", sec, opt)
if v == nil or v == "" then return def or "" end
return v
end
local agh = trim(exec("pidof AdGuardHome"))
local listen = trim(exec("netstat -lntup 2>/dev/null | grep ':53 ' | head -2 | tr -s ' ' ' '"))
local r_active, r_pk, r_by, r_list = nft_chain_summary("dns_guard_redirect")
local o_active, o_pk, o_by, o_list = nft_chain_summary("dns_guard_pre_forward")
local b_active = nft_chain_summary("dns_guard_block")
-- 规则文件是否由本应用托管
local managed = "0"
local mtime = ""
local fh = io.open(NFTOUT, "r")
if fh then
local body = fh:read("*a") or ""
fh:close()
if body:find("DNS 防绕过", 1, true) then managed = "1" end
end
mtime = trim(exec("stat -c '%y' " .. NFTOUT .. " 2>/dev/null | cut -c1-19"))
-- 观察日志条数
local logs = trim(exec("grep -c 'DNS-BYPASS' " .. LOGFILE))
-- 回滚状态
local rb = exec(ROLLBACK .. " --status")
local pending = (rb:find('"pending":true', 1, true) ~= nil)
local pts = rb:match('"pending_ts":"([%d%-]*)"') or ""
local pleft = num(rb:match('"pending_left":(%d+)'))
local backups = rb:match('"backups":%[([^%]]*)%]') or ""
local resp = {
'{"status":"ok"',
'"enabled":' .. jstr(g("main", "enabled", "0")),
'"redirect53":' .. jstr(g("main", "redirect53", "0")),
'"watch":' .. jstr(g("main", "watch", "0")),
'"block853":' .. jstr(g("main", "block853", "0")),
'"blockdoh":' .. jstr(g("main", "blockdoh", "0")),
'"target_port":' .. jstr(g("main", "target_port", "53")),
'"ifaces":' .. jstr(g("main", "ifaces", "br-lan")),
'"skip_devices":' .. jstr(g("main", "skip_devices", "")),
'"log_limit":' .. jstr(g("main", "log_limit", "5")),
'"rollback_minutes":' .. jstr(g("main", "rollback_minutes", "10")),
'"never_hijack4":' .. jstr(g("limits", "never_hijack4", "")),
'"never_hijack6":' .. jstr(g("limits", "never_hijack6", "")),
'"doh_servers":' .. jstr(g("limits", "doh_servers", "")),
'"agh_pid":' .. jstr(agh),
'"listen53":' .. jstr(listen),
'"redirect_active":' .. (r_active and "true" or "false"),
'"redirect_packets":' .. r_pk,
'"redirect_bytes":' .. r_by,
'"redirect_rules":' .. r_list,
'"watch_active":' .. (o_active and "true" or "false"),
'"watch_packets":' .. o_pk,
'"watch_bytes":' .. o_by,
'"watch_rules":' .. o_list,
'"block_active":' .. (b_active and "true" or "false"),
'"managed":' .. jstr(managed),
'"file_mtime":' .. jstr(mtime),
'"bypass_lines":' .. jstr(logs),
'"pending":' .. (pending and "true" or "false"),
'"pending_ts":' .. jstr(pts),
'"pending_left":' .. pleft,
'"backups":[' .. backups .. ']'
}
return json_out(table.concat(resp, ",") .. "}")
end
-- ---------- 健康自检 ----------
function action_selftest()
local out = trim(exec(SELFTEST))
local j = out:match("({.*})")
if not j then
return json_out('{"status":"error","message":"自检脚本执行失败","raw":' .. jstr(out) .. '}')
end
return json_out(j)
end
-- ---------- 预览将要生成的规则 ----------
function action_rules()
exec(APPLY .. " --render-only --out " .. NFTPREV .. " --quiet")
local fh = io.open(NFTPREV, "r")
local text = ""
if fh then
text = fh:read("*a") or ""
fh:close()
end
if text == "" then
return json_out('{"status":"error","message":"渲染预览失败:未取到内容(请查看 dnsguard-apply 是否可执行)"}')
end
local n = select(2, text:gsub("\n", "\n"))
return json_out('{"status":"ok","lines":' .. n .. ',"text":' .. jstr(text) .. '}')
end
-- ---------- 取消自动回滚(确认保留)----------
function action_rollback_cmd(opt, extra)
local out = trim(exec(ROLLBACK .. " " .. opt .. " " .. extra))
local j = out:match("({.*})")
if not j then
return json_out('{"status":"error","message":' .. jstr(out) .. '}')
end
return json_out(j)
end
-- ---------- 还原到指定回滚点 ----------
function action_restore()
local ts = fv("ts", 40) or ""
if ts ~= "latest" and not ts:match("^[%d%-]+$") then
return json_out('{"status":"error","message":"时间戳格式不正确"}')
end
local out = trim(exec(ROLLBACK .. " " .. ts))
return json_out('{"status":"ok","message":' .. jstr(out ~= "" and out or ("已回滚到 " .. ts)) ..
',"at":' .. jstr(os.date("%H:%M:%S")) .. '}')
end
@@ -0,0 +1,524 @@
<%+header%>
<%
local uci = require "luci.model.uci".cursor()
local function g(sec, opt, def)
local v = uci:get("dnsguard", sec, opt)
if v == nil or v == "" then return def or "" end
return v
end
local function e(s)
s = tostring(s or "")
return (s:gsub("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;"):gsub('"', "&quot;"))
end
local function on(sec, opt)
return g(sec, opt, "0") == "1"
end
-- 取 API 地址:优先 build_url,拿不到就退回固定路径(保证页面不白屏)
local api_url = "/cgi-bin/luci/admin/services/dnsguard/api"
local ok_disp, disp = pcall(require, "luci.dispatcher")
if ok_disp and disp and disp.build_url then
local ok1, u1 = pcall(disp.build_url, "admin", "services", "dnsguard", "api")
if ok1 and u1 then api_url = u1 end
end
local cfg_enabled = on("main", "enabled")
local cfg_redirect = on("main", "redirect53")
local cfg_watch = on("main", "watch")
local cfg_853 = on("main", "block853")
local cfg_doh = on("main", "blockdoh")
local cfg_port = g("main", "target_port", "53")
local cfg_ifaces = g("main", "ifaces", "br-lan")
local cfg_skip = g("main", "skip_devices", "")
local cfg_loglimit = g("main", "log_limit", "5")
local cfg_rollmin = g("main", "rollback_minutes", "10")
local cfg_nh4 = g("limits", "never_hijack4", "")
local cfg_nh6 = g("limits", "never_hijack6", "")
local cfg_dohsrv = g("limits", "doh_servers", "")
%>
<style>
/* 主题里 .cbi-section-node 横向 padding 为 0,自定义块必须自己补,否则会顶到左边 */
.dg-pad { padding: 0 1rem .5rem 1rem; }
.dg-table { width: 100%; max-width: 720px; border-collapse: collapse; font-size: 13px; }
.dg-table td { padding: .45rem 0; border-bottom: 1px solid #eceef1; vertical-align: top; }
.dg-table tr:last-child td { border-bottom: 0; }
.dg-table .dg-k { width: 11rem; color: #57606a; }
.dg-mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; }
.dg-ok { color: #0f6e56; font-weight: 600; }
.dg-bad { color: #a32d2d; font-weight: 600; }
.dg-warn{ color: #8a5a00; font-weight: 600; }
.dg-banner { display:none; padding:.6rem .75rem; border-radius:.375rem; margin-bottom:.75rem; font-size:13px; line-height:1.7; white-space:pre-wrap; }
.dg-pre { max-height:300px; overflow:auto; background:#f6f8fa; border-radius:.375rem; padding:.625rem; font-size:12px; line-height:1.55; color:#333; margin:.5rem 0 0; }
</style>
<div class="cbi-map">
<h2 name="content">DNS 防绕过</h2>
<div class="cbi-map-descr">
把局域网内「绕过 AdGuard Home 的 DNS」拉回本机解析:<strong>53 明文</strong>用透明重定向(客户端无感)、
<strong>853 DoT</strong> 用阻断(逼其回落 53)、<strong>DoH 专用 IP</strong> 可选阻断。
配置存于 <code>/etc/config/dnsguard</code>,保存后渲染到 <code>/etc/nftables.d/20-dns-guard.nft</code> 并重载 fw4。
</div>
<div class="cbi-section">
<h3>当前状态</h3>
<div class="cbi-section-node">
<div class="dg-pad">
<div id="dg-banner" class="dg-banner"></div>
<table class="dg-table">
<tr><td class="dg-k">总开关</td><td id="st-enabled">读取中…</td></tr>
<tr><td class="dg-k">AdGuard Home</td><td id="st-agh">—</td></tr>
<tr><td class="dg-k">:53 监听</td><td id="st-listen" class="dg-mono">—</td></tr>
<tr><td class="dg-k">53 透明重定向</td><td id="st-redirect">—</td></tr>
<tr><td class="dg-k">已收编包数</td><td id="st-redpk">—</td></tr>
<tr><td class="dg-k">观察计数(forward)</td><td id="st-watch">—</td></tr>
<tr><td class="dg-k">规则文件</td><td id="st-file">—</td></tr>
<tr><td class="dg-k">观察期累计日志</td><td id="st-logs">—</td></tr>
</table>
</div>
</div>
</div>
<form id="dg-config" onsubmit="return false">
<div class="cbi-section">
<h3>开关</h3>
<div class="cbi-section-node">
<div class="cbi-value">
<label class="cbi-value-title" for="dg-enabled">启用 DNS 防绕过</label>
<div class="cbi-value-field">
<input type="checkbox" id="dg-enabled"<% if cfg_enabled then %> checked="checked"<% end %> />
<div class="cbi-value-description">总开关。关闭后不生成任何规则(等于恢复出厂状态)。</div>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="dg-redirect">53 透明重定向</label>
<div class="cbi-value-field">
<input type="checkbox" id="dg-redirect"<% if cfg_redirect then %> checked="checked"<% end %> />
<div class="cbi-value-description">
把 LAN 内发往外部 <code>:53</code> 的 TCP/UDP 查询改道到本机 AdGuard Home(nat prerouting,优先级 -105)。
客户端<strong>无感</strong>,硬编码 DNS 的设备不用改设置。<br />
⚠️ 只在 AdGuard Home 正常运行时才有意义:<strong>AGH 挂了就会被安全闸门拒绝应用</strong>。
</div>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="dg-watch">观察计数</label>
<div class="cbi-value-field">
<input type="checkbox" id="dg-watch"<% if cfg_watch then %> checked="checked"<% end %> />
<div class="cbi-value-description">
只计数 + 抽样记日志到 <code>/mnt/data/logs/messages</code>,不拦截。<br />
注意:53 被重定向后不再进入 forward 链,<strong>此计数会趋近于 0(只剩 853 / DoH)</strong>,这是正常的。
</div>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="dg-853">阻断 853(DoT)</label>
<div class="cbi-value-field">
<input type="checkbox" id="dg-853"<% if cfg_853 then %> checked="checked"<% end %> />
<div class="cbi-value-description">DoT 无法透明重定向,只能阻断;客户端会回落到 853 之外的明文 53,从而被上面的重定向收编。建议重定向稳定 1 天后再开。</div>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="dg-doh">阻断 DoH 专用 IP</label>
<div class="cbi-value-field">
<input type="checkbox" id="dg-doh"<% if cfg_doh then %> checked="checked"<% end %> />
<div class="cbi-value-description">
⚠️ 风险最高:按 IP 命中,可能误伤「恰好也部署在这些 IP 上」的其它服务(例如 <code>223.5.5.5:443</code>)。
更精准的做法是在 AdGuard Home 里加域名规则。默认关闭。
</div>
</div>
</div>
</div>
</div>
<div class="cbi-section">
<h3>参数</h3>
<div class="cbi-section-node">
<div class="cbi-value">
<label class="cbi-value-title" for="dg-port">重定向目标端口</label>
<div class="cbi-value-field">
<input type="text" class="cbi-input-text" id="dg-port" style="width:80px" value="<%=e(cfg_port)%>" />
<div class="cbi-value-description">本机 AdGuard Home 的监听端口,默认 53。<strong>不要</strong>填 dnsmasq 的 5353。</div>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="dg-ifaces">入接口</label>
<div class="cbi-value-field">
<input type="text" class="cbi-input-text" id="dg-ifaces" style="width:260px" value="<%=e(cfg_ifaces)%>" />
<div class="cbi-value-description">监听哪个接口进来的流量,默认 <code>br-lan</code>。多个用空格分隔。</div>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="dg-skip">例外设备</label>
<div class="cbi-value-field">
<input type="text" class="cbi-input-text" id="dg-skip" style="width:100%;max-width:420px" value="<%=e(cfg_skip)%>" placeholder="留空 = 全屋收编" />
<div class="cbi-value-description">
这些源地址<strong>不做</strong>重定向(IPv4 / IPv6 混填,空格分隔)。只在确实需要保留某个设备走外部 DNS 时使用。<br />
⚠️ 不要把整个 <code>192.168.0.0/16</code> 填进「永不劫持」:那样红米 K80 这类静态指向旧私网地址的设备会继续解析失败。
</div>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="dg-loglimit">日志限速</label>
<div class="cbi-value-field">
<input type="text" class="cbi-input-text" id="dg-loglimit" style="width:80px" value="<%=e(cfg_loglimit)%>" />
<div class="cbi-value-description">观察日志每分钟最多写几条(1-999),防止刷爆日志。</div>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="dg-rollmin">自动回滚倒计时</label>
<div class="cbi-value-field">
<input type="text" class="cbi-input-text" id="dg-rollmin" style="width:80px" value="<%=e(cfg_rollmin)%>" />
<div class="cbi-value-description">
把「53 重定向」从<strong>关改到开</strong>时,自动拍一个回滚快照并倒计时;到点未点「确认保留」就自动还原。<br />
DNS 是全家网的命门,改错了人可能连这台路由器都访问不到(域名解析不了)——这是最后的保险绳。单位:分钟(1-120)。
</div>
</div>
</div>
</div>
</div>
<div class="cbi-section">
<h3>高级:地址集合</h3>
<div class="cbi-section-node">
<div class="cbi-value">
<label class="cbi-value-title" for="dg-nh4">永不劫持(IPv4)</label>
<div class="cbi-value-field">
<textarea class="cbi-input-textarea" id="dg-nh4" rows="3" style="width:100%;max-width:560px"><%=e(cfg_nh4)%></textarea>
<div class="cbi-value-description">指向这些网段的 DNS 查询不会被改道(内网 / 保留段)。空格分隔。</div>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="dg-nh6">永不劫持(IPv6)</label>
<div class="cbi-value-field">
<textarea class="cbi-input-textarea" id="dg-nh6" rows="2" style="width:100%;max-width:560px"><%=e(cfg_nh6)%></textarea>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title" for="dg-dohsrv">DoH 专用地址集合</label>
<div class="cbi-value-field">
<textarea class="cbi-input-textarea" id="dg-dohsrv" rows="4" style="width:100%;max-width:560px"><%=e(cfg_dohsrv)%></textarea>
<div class="cbi-value-description">用于「DoH 专用 IP 阻断」与观察计数,也用于反查哪些设备在走这些地址。空格分隔。</div>
</div>
</div>
</div>
</div>
</form>
<div class="cbi-section">
<h3>操作</h3>
<div class="cbi-section-node">
<div class="dg-pad">
<div id="dg-msg" class="dg-banner"></div>
<div id="dg-pendbox" style="display:none;padding:.6rem .75rem;border-radius:.375rem;margin-bottom:.75rem;font-size:13px;background:#fff6e5;border:1px solid #d9a400;color:#5a3c00"></div>
<div style="margin-bottom:.75rem">
<button type="button" id="btn-save" class="cbi-button cbi-button-apply" style="margin-right:.5rem" onclick="dgSave()">保存并应用</button>
<button type="button" id="btn-apply" class="cbi-button" style="margin-right:.5rem" onclick="dgApply()">按当前配置重新应用</button>
<button type="button" id="btn-test" class="cbi-button cbi-button-action" style="margin-right:.5rem" onclick="dgSelfTest()">健康自检</button>
<button type="button" id="btn-rules" class="cbi-button" onclick="dgRules()">预览将要生成的规则</button>
</div>
<div id="dg-pendbtn" style="display:none;margin-bottom:.75rem">
<button type="button" id="btn-disarm" class="cbi-button cbi-button-negative" onclick="dgDisarm()">确认保留(取消自动回滚)</button>
</div>
<details id="dg-details">
<summary style="cursor:pointer;font-size:12.5px;color:#57606a">自检结果 / 规则预览 / 命令回显(点开查看)</summary>
<div id="dg-checks" style="margin:.5rem 0 0"></div>
<pre id="dg-out" class="dg-pre">(等待操作)</pre>
</details>
</div>
</div>
</div>
<div class="cbi-section">
<h3>回滚点</h3>
<div class="cbi-section-node">
<div class="cbi-value">
<label class="cbi-value-title" for="dg-rb">选择回滚点</label>
<div class="cbi-value-field">
<select class="cbi-input-select" id="dg-rb" style="min-width:200px;margin-right:8px"></select>
<button type="button" id="btn-restore" class="cbi-button cbi-button-negative" onclick="dgRestore()">还原到该回滚点</button>
<div class="cbi-value-description">
还原会同时恢复 <strong>UCI 配置 + 规则文件</strong>并重载防火墙。回滚点目录:<code>/mnt/data/backup-dnsguard/</code>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
var DG_API = '<%=e(api_url)%>';
function dgEsc(s) {
s = (s === null || s === undefined) ? '' : String(s);
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function dgBox(el, kind, text) {
var map = {
ok: ['#e1f5ee', '#0f6e56', '#04342c'],
err: ['#fcebeb', '#a32d2d', '#501313'],
busy: ['#e6f1fb', '#185fa5', '#042c53']
};
var c = map[kind] || map.busy;
el.style.display = 'block';
el.style.background = c[0];
el.style.border = '1px solid ' + c[1];
el.style.color = c[2];
el.textContent = text;
if (el.scrollIntoView) { el.scrollIntoView({ block: 'nearest' }); }
}
function dgMsg(kind, text) { dgBox(document.getElementById('dg-msg'), kind, text); }
function dgOut(t) { document.getElementById('dg-out').textContent = t; }
function dgDetails(openIt) {
var d = document.getElementById('dg-details');
if (openIt) { d.open = true; }
}
function dgBusy(id, on, label) {
var b = document.getElementById(id);
if (!b) { return; }
if (on) {
if (!b.getAttribute('data-orig')) { b.setAttribute('data-orig', b.textContent); }
b.textContent = label || '处理中…';
b.disabled = true;
} else {
var o = b.getAttribute('data-orig');
if (o) { b.textContent = o; }
b.disabled = false;
}
}
function dgVal(id) { var e = document.getElementById(id); return e ? e.value : ''; }
function dgChk(id) { var e = document.getElementById(id); return e && e.checked ? '1' : '0'; }
function dgEnc(o) {
var out = [];
for (var k in o) { out.push(encodeURIComponent(k) + '=' + encodeURIComponent(o[k])); }
return out.join('&');
}
function dgPost(params, cb) {
var x = new XMLHttpRequest();
x.open('POST', DG_API, true);
x.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
x.timeout = 90000;
x.onreadystatechange = function () {
if (x.readyState !== 4) { return; }
var d = null;
try { d = JSON.parse(x.responseText); } catch (err) { d = null; }
cb(x.status, d, x.responseText);
};
x.ontimeout = function () { cb(0, null, '请求超时(90 秒)—— 防火墙重载可能较慢,请刷新页面查看当前状态'); };
x.send(params);
}
function dgFmtBytes(n) {
n = Number(n) || 0;
if (n < 1024) { return n + ' B'; }
if (n < 1048576) { return (n / 1024).toFixed(1) + ' KB'; }
return (n / 1048576).toFixed(1) + ' MB';
}
function dgSave() {
dgMsg('busy', '正在保存并应用(会重载防火墙,几秒钟)…');
dgBusy('btn-save', true, '保存中…');
dgPost(dgEnc({
action: 'save',
enabled: dgChk('dg-enabled'),
redirect53: dgChk('dg-redirect'),
watch: dgChk('dg-watch'),
block853: dgChk('dg-853'),
blockdoh: dgChk('dg-doh'),
target_port: dgVal('dg-port'),
ifaces: dgVal('dg-ifaces'),
skip_devices: dgVal('dg-skip'),
log_limit: dgVal('dg-loglimit'),
rollback_minutes: dgVal('dg-rollmin'),
never_hijack4: dgVal('dg-nh4'),
never_hijack6: dgVal('dg-nh6'),
doh_servers: dgVal('dg-dohsrv')
}), function (code, d, raw) {
dgBusy('btn-save', false);
if (!d) {
dgMsg('err', '保存请求失败(HTTP ' + code + ')\n' + raw);
dgOut(raw); dgDetails(true);
return;
}
dgOut(d.message + '\n\n--- dnsguard-apply 回显 ---\n' + (d.apply || ''));
if (d.status === 'ok') {
var extra = '';
if (d.armed) { extra = '\n🛟 已挂自动回滚,未点「确认保留」将自动还原(快照 ' + d.armed + ')'; }
dgMsg('ok', '✅ ' + d.message + '(' + (d.at || '') + ')' + extra);
dgStatus();
} else {
dgMsg('err', '❌ ' + d.message);
dgDetails(true);
dgStatus();
}
});
}
function dgApply() {
dgMsg('busy', '正在按当前配置重新渲染并应用…');
dgBusy('btn-apply', true, '应用中…');
dgPost(dgEnc({ action: 'apply' }), function (code, d, raw) {
dgBusy('btn-apply', false);
if (!d) { dgMsg('err', '请求失败(HTTP ' + code + ')\n' + raw); dgOut(raw); dgDetails(true); return; }
dgOut(d.message + '\n\n' + (d.apply || ''));
if (d.status === 'ok') { dgMsg('ok', '✅ ' + d.message + '(' + (d.at || '') + ')'); }
else { dgMsg('err', '❌ ' + d.message); dgDetails(true); }
dgStatus();
});
}
function dgSelfTest() {
dgMsg('busy', '正在执行健康自检…');
dgBusy('btn-test', true, '自检中…');
dgDetails(true);
dgPost(dgEnc({ action: 'selftest' }), function (code, d, raw) {
dgBusy('btn-test', false);
if (!d || !d.items) {
dgMsg('err', '自检失败(HTTP ' + code + ')\n' + raw);
dgOut(raw);
return;
}
var html = '<table class="dg-table">';
var bad = 0, warn = 0;
for (var i = 0; i < d.items.length; i++) {
var it = d.items[i];
var cls = it.state === 'ok' ? 'dg-ok' : (it.state === 'warn' ? 'dg-warn' : 'dg-bad');
var tag = it.state === 'ok' ? '正常' : (it.state === 'warn' ? '注意' : '异常');
if (it.state === 'fail') { bad++; }
if (it.state === 'warn') { warn++; }
html += '<tr><td class="dg-k">' + dgEsc(it.name) + '</td><td><span class="' + cls + '">' + tag + '</span> · ' + dgEsc(it.detail) + '</td></tr>';
}
html += '</table>';
document.getElementById('dg-checks').innerHTML = html;
dgOut('(自检明细见上方表格)');
if (bad > 0) { dgMsg('err', '❌ 自检发现 ' + bad + ' 项异常、' + warn + ' 项注意 —— 详见下方明细'); }
else if (warn > 0) { dgMsg('busy', '⚠️ 自检通过,但有 ' + warn + ' 项注意 —— 详见下方明细'); }
else { dgMsg('ok', '✅ 自检全部正常'); }
dgStatus();
});
}
function dgRules() {
dgMsg('busy', '正在渲染规则预览…');
dgBusy('btn-rules', true, '渲染中…');
dgDetails(true);
dgPost(dgEnc({ action: 'rules' }), function (code, d, raw) {
dgBusy('btn-rules', false);
if (!d || d.status !== 'ok') {
dgMsg('err', '❌ 预览失败:' + ((d && d.message) || ('HTTP ' + code)));
dgOut(raw);
return;
}
dgOut(d.text || '');
dgMsg('ok', '✅ 已生成 ' + d.lines + ' 行规则(未应用到防火墙,只是预览)');
});
}
function dgDisarm() {
dgBusy('btn-disarm', true, '处理中…');
dgPost(dgEnc({ action: 'disarm' }), function (code, d, raw) {
dgBusy('btn-disarm', false);
if (!d) { dgMsg('err', '请求失败(HTTP ' + code + ')\n' + raw); return; }
dgMsg(d.status === 'ok' ? 'ok' : 'err', (d.status === 'ok' ? '✅ ' : '❌ ') + (d.message || raw));
dgStatus();
});
}
function dgRestore() {
var sel = document.getElementById('dg-rb');
var ts = sel ? sel.value : '';
if (!ts) { dgMsg('err', '请先选择一个回滚点'); return; }
if (!confirm('确认还原到回滚点 ' + ts + ' ?\n\n会同时恢复 UCI 配置与规则文件,并重载防火墙。')) { return; }
dgMsg('busy', '正在还原到 ' + ts + ' …');
dgBusy('btn-restore', true, '还原中…');
dgPost(dgEnc({ action: 'rollback', ts: ts }), function (code, d, raw) {
dgBusy('btn-restore', false);
if (!d) { dgMsg('err', '请求失败(HTTP ' + code + ')\n' + raw); return; }
dgMsg(d.status === 'ok' ? 'ok' : 'err', (d.status === 'ok' ? '✅ ' : '❌ ') + (d.message || raw));
setTimeout(function () { location.reload(); }, 1500);
});
}
function dgStatus() {
dgPost(dgEnc({ action: 'status' }), function (code, d, raw) {
if (!d) {
document.getElementById('st-enabled').textContent = '状态读取失败(HTTP ' + code + ')';
return;
}
function set(id, html) { document.getElementById(id).innerHTML = html; }
set('st-enabled', d.enabled === '1' ? '<span class="dg-ok">已启用</span>' : '<span class="dg-bad">已关闭</span>');
set('st-agh', d.agh_pid ? '<span class="dg-ok">运行中</span> · PID ' + dgEsc(d.agh_pid) : '<span class="dg-bad">未运行</span>');
set('st-listen', d.listen53 ? dgEsc(d.listen53) : '<span class="dg-bad">无进程监听 :53</span>');
if (d.redirect_active) {
set('st-redirect', '<span class="dg-ok">已生效</span> · 目标 :' + dgEsc(d.target_port) + ' · 接口 ' + dgEsc(d.ifaces) + (d.skip_devices ? ' · 例外 ' + dgEsc(d.skip_devices) : ' · 无例外'));
} else if (d.redirect53 === '1') {
set('st-redirect', '<span class="dg-bad">配置为开启,但链不存在!</span> 请点「健康自检」');
} else {
set('st-redirect', '<span class="dg-warn">未启用</span>(仅观察)');
}
set('st-redpk', d.redirect_packets ? (d.redirect_packets + ' 个包 / ' + dgFmtBytes(d.redirect_bytes) + '(自上次重载)') : '0(未有流量被收编)');
if (d.watch_active) {
set('st-watch', d.watch_packets + ' 个包 / ' + dgFmtBytes(d.watch_bytes) + (d.redirect_active && Number(d.watch_packets) === 0 ? ' · 53 已改道,计数归零属正常' : ''));
} else {
set('st-watch', '<span class="dg-warn">未启用</span>');
}
if (d.managed === '1') {
set('st-file', '<span class="dg-ok">由本应用托管</span> · 最后生成 ' + dgEsc(d.file_mtime));
} else if (d.file_mtime) {
set('st-file', '<span class="dg-warn">存在但不是本应用生成</span> · ' + dgEsc(d.file_mtime) + '(点「保存并应用」会被接管)');
} else {
set('st-file', '不存在(总开关关闭或从未应用过)');
}
set('st-logs', dgEsc(d.bypass_lines || '0') + ' 行(DNS-BYPASS)');
// 自动回滚横幅
var pb = document.getElementById('dg-pendbox');
var pbtn = document.getElementById('dg-pendbtn');
if (d.pending) {
pb.style.display = 'block';
pb.textContent = '🛟 自动回滚倒计时进行中:约 ' + d.pending_left + ' 秒后(快照 ' + d.pending_ts + ')若无确认将自动还原,并重载防火墙。确认当前设置没问题就点下面的「确认保留」。';
pbtn.style.display = 'block';
} else {
pb.style.display = 'none';
pbtn.style.display = 'none';
}
// 回滚点下拉
var sel = document.getElementById('dg-rb');
if (sel && d.backups) {
var cur = sel.value;
var html = '';
for (var i = 0; i < d.backups.length; i++) {
html += '<option value="' + dgEsc(d.backups[i]) + '">' + dgEsc(d.backups[i]) + '</option>';
}
sel.innerHTML = html || '<option value="">(暂无回滚点)</option>';
if (cur) { sel.value = cur; }
}
});
}
window.addEventListener('load', function () { dgStatus(); });
</script>
<%+footer%>
@@ -0,0 +1,11 @@
{
"luci-app-dnsguard": {
"description": "Grant UCI access for the DNS guard (DNS bypass prevention / 53 transparent redirect) LuCI application",
"read": {
"uci": [ "dnsguard" ]
},
"write": {
"uci": [ "dnsguard" ]
}
}
}
@@ -0,0 +1,4 @@
{
"config": "dnsguard",
"init": "dnsguard"
}