mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 11:35:13 +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
@@ -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
|
||||
Reference in new issue
Block a user