🤞 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

@@ -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