mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-04 15:13:56 +08:00
🤞 Sync 2026-09-27 22:16:29
This commit is contained in:
1 parent
8ede4a8320
commit
618f51e9dc
459 files changed
+44817
-2191
No files matched your search
+388
@@ -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
@@ -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
@@ -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("&", "&"):gsub("<", "<"):gsub(">", ">"):gsub('"', """))
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
Reference in new issue
Block a user