Files
op-packages/luci-app-dnsguard/root/usr/lib/lua/luci/controller/dnsguard.lua
T

359 lines
12 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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