mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-28 06:02:52 +08:00
359 lines
12 KiB
Lua
359 lines
12 KiB
Lua
-- 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
|