💐 Sync 2026-10-06 12:33:44

This commit is contained in:
github-actions[bot] committed 2026-10-06 12:33:44 +08:00
1 parent 8be03ce939
commit 7fe69dbb3a
246 files changed
+50330 -3414

No files matched your search

+629
View File
@@ -0,0 +1,629 @@
module("luci.controller.minigate", package.seeall)
local function shellquote(value)
value = tostring(value or "")
return "'" .. value:gsub("'", "'\"'\"'") .. "'"
end
local function update_response(command)
local sys = require "luci.sys"
local jsonc = require "luci.jsonc"
local fs = require "nixio.fs"
local raw = sys.exec("/bin/sh /usr/lib/minigate/update.sh " .. command .. " 2>/dev/null") or ""
local ok, data = pcall(jsonc.parse, raw)
if not ok or type(data) ~= "table" then
data = {
status = "error",
progress = 0,
current = "",
latest = "",
available = false,
running = false,
success = false,
message = "更新服务返回了无效结果"
}
end
local release_raw = fs.readfile("/tmp/minigate-update-release.json")
if release_raw and data.latest and data.latest ~= "" then
local release_ok, release = pcall(jsonc.parse, release_raw)
if release_ok and type(release) == "table" then
local tag = tostring(release.tag_name or ""):gsub("^v", "")
if tag == tostring(data.latest) then
local title = type(release.name) == "string" and release.name or ("minigate v" .. tag)
local notes = type(release.body) == "string" and release.body or ""
data.release_title = title
data.release_notes = notes
end
end
end
luci.http.prepare_content("application/json")
luci.http.write_json(data)
end
function index()
entry({"admin","services","minigate"}, alias("admin","services","minigate","general"), "minigate", 60).dependent=false
entry({"admin","services","minigate","general"}, cbi("minigate/general"), "总览", 10)
entry({"admin","services","minigate","ddns"}, cbi("minigate/ddns"), "动态DNS", 20)
entry({"admin","services","minigate","acme"}, cbi("minigate/acme"), "SSL 证书", 30)
entry({"admin","services","minigate","proxy"}, cbi("minigate/proxy"), "反向代理", 40)
entry({"admin","services","minigate","login_guard"}, cbi("minigate/login_guard"), "登录防护", 45)
entry({"admin","services","minigate","log"}, template("minigate/log"), "日志", 50)
entry({"admin","services","minigate","status"}, call("action_status")).leaf=true
entry({"admin","services","minigate","get_log"}, call("action_get_log")).leaf=true
entry({"admin","services","minigate","acme_install"}, call("action_acme_install")).leaf=true
entry({"admin","services","minigate","acme_issue"}, call("action_acme_issue")).leaf=true
entry({"admin","services","minigate","ddns_sync"}, call("action_ddns_sync")).leaf=true
entry({"admin","services","minigate","ddns_history"}, call("action_ddns_history")).leaf=true
entry({"admin","services","minigate","proxy_access"}, call("action_proxy_access")).leaf=true
entry({"admin","services","minigate","geo_lookup"}, call("action_geo_lookup")).leaf=true
entry({"admin","services","minigate","lg_status"}, call("action_lg_status")).leaf=true
entry({"admin","services","minigate","lg_ban"}, call("action_lg_ban")).leaf=true
entry({"admin","services","minigate","lg_unban"}, call("action_lg_unban")).leaf=true
entry({"admin","services","minigate","lg_flush"}, call("action_lg_flush")).leaf=true
entry({"admin","services","minigate","update_status"}, call("action_update_status")).leaf=true
entry({"admin","services","minigate","update_auto"}, call("action_update_auto")).leaf=true
entry({"admin","services","minigate","update_check"}, call("action_update_check")).leaf=true
entry({"admin","services","minigate","update_apply"}, post("action_update_apply")).leaf=true
entry({"admin","services","minigate","uninstall"}, post("action_uninstall")).leaf=true
end
function action_update_status()
update_response("status")
end
function action_update_auto()
update_response("auto")
end
function action_update_check()
update_response("check")
end
function action_update_apply()
local sys = require "luci.sys"
local fs = require "nixio.fs"
local version = luci.http.formvalue("version")
local ok = false
if type(version) ~= "string" or #version > 32 or not (
version:match("^%d+%.%d+%.%d+$") or version:match("^%d+%.%d+%.%d+%-%d+$")) then
luci.http.status(400, "Bad Request")
luci.http.prepare_content("application/json")
luci.http.write_json({success = false, message = "请先查看更新内容并确认版本"})
return
end
if fs.access("/usr/lib/minigate/update.sh") then
ok = sys.call("runner=$(mktemp /tmp/minigate-update-run.XXXXXX)" ..
" && cp /usr/lib/minigate/update.sh \"$runner\" && chmod 700 \"$runner\"" ..
" && ( ( /bin/sh \"$runner\" apply " .. shellquote(version) ..
"; rm -f \"$runner\" ) >/dev/null 2>&1 & )") == 0
end
luci.http.prepare_content("application/json")
luci.http.write_json({
success = ok,
message = ok and "更新任务已启动" or "无法启动更新任务"
})
end
function action_uninstall()
local sys = require "luci.sys"
local fs = require "nixio.fs"
local confirm = luci.http.formvalue("confirm")
local purge = luci.http.formvalue("purge")
local ok = false
if confirm ~= "uninstall-minigate" or (purge ~= "0" and purge ~= "1") then
luci.http.status(400, "Bad Request")
luci.http.prepare_content("application/json")
luci.http.write_json({success = false, message = "卸载确认无效,请重新操作"})
return
end
if fs.access("/usr/lib/minigate/uninstall.sh") then
ok = sys.call("runner=$(mktemp /tmp/minigate-uninstall.XXXXXX)" ..
" && cp /usr/lib/minigate/uninstall.sh \"$runner\" && chmod 700 \"$runner\"" ..
" && ( ( sleep 1; /bin/sh \"$runner\" " .. shellquote(purge) ..
"; rm -f \"$runner\" ) >/dev/null 2>&1 & )") == 0
end
luci.http.prepare_content("application/json")
luci.http.write_json({
success = ok,
message = ok and "卸载任务已启动" or "无法启动卸载任务"
})
end
function action_status()
local uci=require"luci.model.uci".cursor()
local pr=false
local f=io.open("/var/run/minigate-nginx.pid","r")
if f then local p=f:read("*l"); f:close()
if p and tonumber(p) then pr=(os.execute("kill -0 "..p.." 2>/dev/null")==0) end
end
local al={}
for _,lf in ipairs({"/var/log/minigate-ddns.log","/var/log/minigate-acme.log","/var/log/minigate-proxy.log"}) do
local fh=io.open(lf,"r"); if fh then for l in fh:lines() do al[#al+1]=l end; fh:close() end
end
local st=#al-19; if st<1 then st=1 end
local ll={}; for i=st,#al do ll[#ll+1]=al[i] end
local ca=false; local cf=io.open("/etc/crontabs/root","r")
if cf then local c=cf:read("*a"); cf:close(); ca=c:find("minigate")~=nil end
local dl={}
uci:foreach("minigate","ddns",function(s)
dl[#dl+1]={
name=s[".name"],
enabled=s.enabled or"0",
domain=s.domain or"",
ip_version=s.ip_version or"ipv4",
status=s.status or"unknown",
status_msg=s.status_msg or"",
last_ip=s.last_ip or"",
last_ip6=s.last_ip6 or"",
last_update=s.last_update or"",
next_sync=s.next_sync or""
}
end)
-- 全局 IPv6 监听状态
local ipv6_listen = uci:get("minigate","global","ipv6_listen") or "0"
-- 收集代理规则信息
local proxy_rules = {}
local wc_ports = {}
uci:foreach("minigate","proxy_wildcard",function(s)
if (s.enabled == "1" or s.enabled == true) and s.domain then
local base = (s.domain or ""):gsub("^%*%.","")
local ssl_val = tostring(s.ssl or "0")
wc_ports[base] = {port=s.listen_port or "443", ssl=ssl_val}
end
end)
uci:foreach("minigate","subproxy",function(s)
local parent = s.parent_domain or ""
local prefix = s.prefix or ""
local taddr = s.target_addr or ""
local tport = s.target_port or "80"
if prefix ~= "" and parent ~= "" then
local wc = wc_ports[parent] or {}
local lport = wc.port or "443"
local ssl_val = wc.ssl or "0"
local scheme = (ssl_val == "1" or ssl_val == "true") and "https" or "http"
proxy_rules[#proxy_rules+1] = {domain=prefix.."."..parent, target=taddr..":"..tport, listen_port=lport, scheme=scheme, ipv6_listen=ipv6_listen}
end
end)
uci:foreach("minigate","proxy",function(s)
if (s.enabled == "1" or s.enabled == true) and s.domain and s.target_addr then
local ssl_val = tostring(s.ssl or "0")
local scheme = (ssl_val == "1" or ssl_val == "true") and "https" or "http"
proxy_rules[#proxy_rules+1] = {domain=s.domain, target=(s.target_addr or "")..":"..(s.target_port or "80"), listen_port=s.listen_port or "443", scheme=scheme, ipv6_listen=ipv6_listen}
end
end)
luci.http.prepare_content("application/json")
luci.http.write_json({
enabled=uci:get("minigate","global","enabled")or"0",
ipv6_listen=ipv6_listen,
proxy_running=pr,
cron_active=ca,
recent_log=table.concat(ll,"\n"),
ddns_list=dl,
proxy_rules=proxy_rules,
acme={
enabled=uci:get("minigate","acme","enabled")or"0",
status=uci:get("minigate","acme","status")or"unknown",
last_domain=uci:get("minigate","acme","last_domain")or"",
last_issue=uci:get("minigate","acme","last_issue")or"",
cert_expiry=uci:get("minigate","acme","cert_expiry")or"",
last_error=uci:get("minigate","acme","last_error")or""
}
})
end
function action_get_log()
local sys=require"luci.sys"; local s=luci.http.formvalue("source")or"all"
local fs={ddns="/var/log/minigate-ddns.log",acme="/var/log/minigate-acme.log",proxy="/var/log/minigate-proxy.log /var/log/minigate-nginx-error.log"}
local cmd=s=="all"and"cat /var/log/minigate-*.log 2>/dev/null|tail -200"or"cat "..(fs[s]or"").." 2>/dev/null|tail -200"
luci.http.prepare_content("application/json"); luci.http.write_json({log=sys.exec(cmd)or""})
end
function action_acme_install()
local sys=require"luci.sys"; sys.call("/bin/sh /usr/lib/minigate/acme.sh install 2>&1")
local fs=require"nixio.fs"; local ok=fs.access("/etc/minigate/acme/data/acme.sh")and fs.access("/etc/minigate/acme/data/dnsapi/dns_cf.sh")
luci.http.prepare_content("application/json"); luci.http.write_json({success=ok,message=ok and"安装成功"or"安装失败"})
end
function action_acme_issue()
local sys=require"luci.sys"; local d=luci.http.formvalue("domain")or""
local cmd="/bin/sh /usr/lib/minigate/acme.sh issue"; if d~=""then cmd=cmd.." "..shellquote(d)end
sys.call(cmd.." 2>&1")
local uci=require"luci.model.uci".cursor(); local st=uci:get("minigate","acme","status")or"unknown"
local le=uci:get("minigate","acme","last_error")or""
local msg=(st=="ok" and le~="")and"现有证书有效;最近一次签发失败,已保留有效证书"or((st=="ok")and"签发成功!"or"签发失败,查看日志")
luci.http.prepare_content("application/json"); luci.http.write_json({success=(st=="ok"),message=msg})
end
function action_ddns_sync()
local sys=require"luci.sys"; local sec=luci.http.formvalue("section")or""
if sec~=""then sys.call("/bin/sh /usr/lib/minigate/ddns.sh single "..shellquote(sec).." 2>&1")
else sys.call("/bin/sh /usr/lib/minigate/ddns.sh force 2>&1") end
local uci=require"luci.model.uci".cursor()
local st=sec~=""and(uci:get("minigate",sec,"status")or"unknown")or"done"
local ip=sec~=""and(uci:get("minigate",sec,"last_ip")or"")or""
local ip6=sec~=""and(uci:get("minigate",sec,"last_ip6")or"")or""
luci.http.prepare_content("application/json"); luci.http.write_json({success=(st=="ok"or st=="partial"),status=st,ip=ip,ip6=ip6})
end
local function history_section_name(value)
return tostring(value or ""):gsub("[^%w_%-]", "_")
end
local function history_ip_valid(family, ip)
if family == "ipv4" then
return ip:match("^%d+%.%d+%.%d+%.%d+$") ~= nil
end
return ip:match("^[%x:]+$") ~= nil and ip:find(":", 1, true) ~= nil
end
local function read_history_events(path, family, now)
local events = {}
local file = io.open(path, "r")
if not file then return events end
for line in file:lines() do
local raw_ts, ip = line:match("^(%d+)\t([^\t\r\n]+)$")
local ts = tonumber(raw_ts)
if ts and ts > 0 and ts <= now + 300 and history_ip_valid(family, ip or "") then
local previous = events[#events]
if not previous or previous.ip ~= ip then
events[#events + 1] = {timestamp=ts, ip=ip}
end
end
end
file:close()
table.sort(events, function(a, b) return a.timestamp < b.timestamp end)
return events
end
function action_ddns_history()
local uci = require "luci.model.uci".cursor()
local history_dir = os.getenv("MINIGATE_HISTORY_DIR") or "/etc/minigate/ddns-history"
local requested = luci.http.formvalue("section") or ""
local now = os.time()
local cutoff = now - 86400
local rows = {}
local sections = {}
if #requested > 64 or (requested ~= "" and not requested:match("^[%w_%-]+$")) then
luci.http.status(400, "Bad Request")
luci.http.prepare_content("application/json")
luci.http.write_json({success=false, message="DDNS 记录参数无效"})
return
end
uci:foreach("minigate", "ddns", function(section)
local name = section[".name"] or ""
if name ~= "" and (requested == "" or requested == name) then
sections[#sections + 1] = {
name=name,
domain=section.domain or name,
enabled=(section.enabled == "1" or section.enabled == true),
ip_version=section.ip_version or "ipv4"
}
end
end)
for _, section in ipairs(sections) do
if section.enabled then
local families = {}
if section.ip_version == "ipv4" or section.ip_version == "dual" then families[#families + 1] = "ipv4" end
if section.ip_version == "ipv6" or section.ip_version == "dual" then families[#families + 1] = "ipv6" end
for _, family in ipairs(families) do
local path = history_dir .. "/" .. history_section_name(section.domain) .. "." .. family .. ".tsv"
local events = read_history_events(path, family, now)
for index, event in ipairs(events) do
local next_event = events[index + 1]
local end_time = next_event and next_event.timestamp or now
if end_time > cutoff and event.timestamp <= now then
rows[#rows + 1] = {
section=section.name,
domain=section.domain,
family=family,
ip=event.ip,
start_time=event.timestamp,
end_time=end_time,
duration=math.max(0, end_time - event.timestamp),
active=(next_event == nil)
}
end
end
end
end
end
table.sort(rows, function(a, b)
if a.start_time == b.start_time then return a.domain < b.domain end
return a.start_time > b.start_time
end)
luci.http.prepare_content("application/json")
luci.http.write_json({
success=true,
generated_at=now,
window_start=cutoff,
window_seconds=86400,
sections=sections,
records=rows
})
end
function action_proxy_access()
local sys = require "luci.sys"
local limit = tonumber(luci.http.formvalue("limit") or "5") or 5
if limit ~= 20 and limit ~= 50 then
limit = 5
end
-- 读取最近 500 条日志,按 IP 聚合
local raw = sys.exec("tail -n 500 /var/log/minigate-access.log 2>/dev/null") or ""
local visitors = {} -- ip -> {last_time, domain, count, first_time}
local order = {} -- 保持顺序
for line in raw:gmatch("[^\n]+") do
local time = line:match('"time":"([^"]*)"')
local domain = line:match('"domain":"([^"]*)"')
local client = line:match('"client":"([^"]*)"')
if time and client and client ~= "" then
if not visitors[client] then
visitors[client] = { last_time = time, first_time = time, domain = domain or "", count = 1 }
order[#order + 1] = client
else
visitors[client].last_time = time
visitors[client].count = visitors[client].count + 1
if domain and domain ~= "" then
visitors[client].domain = domain
end
end
end
end
-- 按最后访问时间倒序
table.sort(order, function(a, b)
return visitors[a].last_time > visitors[b].last_time
end)
-- 按前端选择取前 N 个,默认 5 个
local result = {}
local now = os.time()
for i = 1, math.min(#order, limit) do
local ip = order[i]
local v = visitors[ip]
-- 解析 ISO 时间判断是否在线(5分钟内)
local online = false
local y,mo,da,h,mi,se = v.last_time:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)")
if y then
local ts = os.time({year=tonumber(y),month=tonumber(mo),day=tonumber(da),hour=tonumber(h),min=tonumber(mi),sec=tonumber(se)})
online = (now - ts) < 300
end
result[#result + 1] = {
ip = ip,
last_time = v.last_time,
domain = v.domain,
count = v.count,
online = online
}
end
luci.http.prepare_content("application/json")
luci.http.write_json({ visitors = result })
end
function action_geo_lookup()
local sys = require "luci.sys"
local fs = require "nixio.fs"
local ip = luci.http.formvalue("ip") or ""
-- 安全校验:只允许 IP 地址字符
if not ip:match("^[%d%.%:a-fA-F]+$") then
luci.http.prepare_content("application/json")
luci.http.write_json({ ip = ip, geo = "无效IP" })
return
end
local cache_dir = "/tmp/minigate-geo-cache"
local cache_file = cache_dir .. "/" .. ip:gsub("[^%w%.%-_:]", "_")
fs.mkdirr(cache_dir)
local cached = fs.readfile(cache_file)
if cached and cached ~= "" then
luci.http.prepare_content("application/json")
luci.http.write_json({ ip = ip, geo = cached:gsub("%s+$", "") })
return
end
local geo = nil
-- API 1: ip9.com.cn(UTF-8,国内快)
if not geo then
local raw = sys.exec("curl -s --connect-timeout 1 --max-time 2 'https://ip9.com.cn/get?ip=" .. ip .. "' 2>/dev/null")
if raw and raw ~= "" then
local country = raw:match('"country":"([^"]*)"') or ""
local prov = raw:match('"prov":"([^"]*)"') or ""
local city = raw:match('"city":"([^"]*)"') or ""
local isp = raw:match('"isp":"([^"]*)"') or ""
local loc = (country .. prov .. city):gsub("中国", "")
if isp ~= "" then loc = loc .. " " .. isp end
loc = loc:gsub("^%s+", ""):gsub("%s+$", "")
if loc ~= "" then geo = loc end
end
end
-- API 2: ip-api.com(UTF-8,国外覆盖好)
if not geo then
local raw = sys.exec("curl -s --connect-timeout 1 --max-time 2 'http://ip-api.com/json/" .. ip .. "?lang=zh-CN&fields=status,country,regionName,city,isp' 2>/dev/null")
if raw and raw ~= "" then
local status = raw:match('"status":"([^"]*)"')
if status == "success" then
local country = raw:match('"country":"([^"]*)"') or ""
local region = raw:match('"regionName":"([^"]*)"') or ""
local city = raw:match('"city":"([^"]*)"') or ""
local isp = raw:match('"isp":"([^"]*)"') or ""
local loc = (country .. region .. city):gsub("中国", "")
if isp ~= "" then loc = loc .. " " .. isp end
loc = loc:gsub("^%s+", ""):gsub("%s+$", "")
if loc ~= "" then geo = loc end
end
end
end
-- API 3: pconline(GBK 需转码)
if not geo then
local raw = sys.exec("curl -s --connect-timeout 1 --max-time 2 'https://whois.pconline.com.cn/ipJson.jsp?ip=" .. ip .. "&json=true' 2>/dev/null | iconv -f gbk -t utf-8 2>/dev/null")
if raw and raw ~= "" then
local addr = raw:match('"addr":"([^"]*)"')
if addr and addr ~= "" then geo = addr:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") end
end
end
fs.writefile(cache_file, geo or "未知")
luci.http.prepare_content("application/json")
luci.http.write_json({ ip = ip, geo = geo or "未知" })
end
-- ============== 登录防护 API ==============
function action_lg_status()
local sys = require "luci.sys"
local fs = require "nixio.fs"
local uci = require "luci.model.uci".cursor()
local watch_limit = tonumber(luci.http.formvalue("watch_limit") or "5") or 5
if watch_limit ~= 20 and watch_limit ~= 30 then
watch_limit = 5
end
local ban_limit = tonumber(luci.http.formvalue("ban_limit") or "5") or 5
if ban_limit ~= 20 and ban_limit ~= 30 then
ban_limit = 5
end
local enabled = uci:get("minigate","login_guard","enabled") or "0"
local threshold = tonumber(uci:get("minigate","login_guard","threshold")) or 3
local bantime = tonumber(uci:get("minigate","login_guard","bantime")) or 43200
local window_s = tonumber(uci:get("minigate","login_guard","window")) or 600
-- 服务是否在跑
local pid_out = sys.exec("pgrep -f '/usr/lib/minigate/login_guard.sh run' 2>/dev/null")
local running = (pid_out ~= nil and pid_out:match("%d") ~= nil)
-- 已封禁列表(解析 nft -j 输出)
-- 注意:nft -j 在 key 和 value 之间有空格,要用 %s* 匹配
-- 每个 elem 内 val 在前 expires 在后,分别提取后按序号配对(避免跨 elem 匹配错乱)
local banned = {}
local raw = sys.exec("nft -j list set inet fw4 login_banned_v4 2>/dev/null") or ""
local ips = {}
for v in raw:gmatch('"val"%s*:%s*"([0-9%.]+)"') do
ips[#ips+1] = v
end
local exps = {}
for e in raw:gmatch('"expires"%s*:%s*(%d+)') do
exps[#exps+1] = tonumber(e)
end
for i = 1, #ips do
if exps[i] then
banned[#banned+1] = { ip = ips[i], remaining = exps[i] }
end
end
-- 按剩余时间倒序
table.sort(banned, function(a,b) return a.remaining > b.remaining end)
local banned_total = #banned
local banned_limited = {}
for i = 1, math.min(banned_total, ban_limit) do
banned_limited[#banned_limited + 1] = banned[i]
end
-- 失败计数中(读 /var/run/minigate/login-guard/counters/)
local watching = {}
local counter_dir = "/var/run/minigate/login-guard/counters"
local now = os.time()
local list_out = sys.exec("ls " .. counter_dir .. " 2>/dev/null") or ""
for ip in list_out:gmatch("[^\n]+") do
local path = counter_dir .. "/" .. ip
local fh = io.open(path, "r")
if fh then
local line = fh:read("*l") or ""
fh:close()
local first, count = line:match("(%d+)%s+(%d+)")
if first and count then
local first_time = tonumber(first)
local stat = fs.stat(path)
local last_time = (stat and stat.mtime) or first_time
local age = now - first_time
if age <= window_s then
watching[#watching+1] = {
ip = ip,
count = tonumber(count),
age = age,
first_time = first_time,
last_time = last_time,
last_seen = os.date("%Y-%m-%d %H:%M:%S", last_time)
}
end
end
end
end
table.sort(watching, function(a,b)
return (a.last_time or 0) > (b.last_time or 0)
end)
local watching_total = #watching
local watching_limited = {}
for i = 1, math.min(watching_total, watch_limit) do
watching_limited[#watching_limited + 1] = watching[i]
end
luci.http.prepare_content("application/json")
luci.http.write_json({
enabled = enabled,
running = running,
threshold = threshold,
bantime = bantime,
window = window_s,
banned = banned_limited,
banned_total = banned_total,
ban_limit = ban_limit,
watching = watching_limited,
watching_total = watching_total,
watch_limit = watch_limit
})
end
function action_lg_ban()
local sys = require "luci.sys"
local ip = luci.http.formvalue("ip") or ""
if not ip:match("^%d+%.%d+%.%d+%.%d+$") then
luci.http.prepare_content("application/json")
luci.http.write_json({success=false, error="无效 IP"})
return
end
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh ban '" .. ip .. "' >/dev/null 2>&1")
luci.http.prepare_content("application/json")
luci.http.write_json({success = (rc == 0), ip = ip})
end
function action_lg_unban()
local sys = require "luci.sys"
local ip = luci.http.formvalue("ip") or ""
if not ip:match("^%d+%.%d+%.%d+%.%d+$") then
luci.http.prepare_content("application/json")
luci.http.write_json({success=false, error="无效 IP"})
return
end
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh unban '" .. ip .. "' >/dev/null 2>&1")
luci.http.prepare_content("application/json")
luci.http.write_json({success = (rc == 0), ip = ip})
end
function action_lg_flush()
local sys = require "luci.sys"
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh flush >/dev/null 2>&1")
luci.http.prepare_content("application/json")
luci.http.write_json({success = (rc == 0)})
end
@@ -0,0 +1,98 @@
local m,s,o
local sys=require"luci.sys"
local fs=require"nixio.fs"
local uc=require"luci.model.uci".cursor()
local function esc(v)
v=tostring(v or "")
v=v:gsub("&","&amp;"):gsub("<","&lt;"):gsub(">","&gt;")
v=v:gsub('"',"&quot;"):gsub("'","&#39;")
return v
end
local function shellquote(v)
v=tostring(v or "")
return "'"..v:gsub("'","'\"'\"'").."'"
end
local dd={}
uc:foreach("minigate","ddns",function(sec) if sec.domain and sec.domain~=""then dd[#dd+1]=sec.domain end end)
m=Map("minigate","MiniGate - SSL 证书","ACME(Let's Encrypt)自动签发。域名和令牌自动关联「动态DNS」。")
s=m:section(NamedSection,"acme","acme","ACME 设置"); s.anonymous=true
o=s:option(Flag,"enabled","启用 ACME"); o.rmempty=false
o=s:option(Value,"email","账户邮箱"); o.placeholder="admin@example.com"; o.rmempty=true
o.description="可选。"
o=s:option(ListValue,"key_type","密钥类型")
o:value("ec-256","ECC P-256(推荐)"); o:value("ec-384","ECC P-384"); o:value("rsa-2048","RSA 2048"); o:value("rsa-4096","RSA 4096")
o.default="ec-256"
o=s:option(Flag,"staging","测试模式"); o.rmempty=false; o.default="1"
o.description="正式使用请关闭。"
-- 证书列表
o=s:option(DummyValue,"_certs","已签发证书"); o.rawhtml=true
o.cfgvalue=function()
local cd="/etc/minigate/certs"
local h='<table class="table"><tr class="tr table-titles"><th class="th">域名</th><th class="th">过期时间</th><th class="th">路径</th></tr>'
local found=false
if fs.dir(cd)then
for entry in fs.dir(cd)do
local fp=cd.."/"..entry
-- lstat 返回 table,检查 type 字段
local st=fs.lstat(fp)
if entry~="_default" and st and st.type=="dir"then
local cert=fp.."/fullchain.pem"
if fs.access(cert)then
found=true
local exp=""
local f=io.popen("openssl x509 -in "..shellquote(cert).." -noout -enddate 2>/dev/null")
if f then local l=f:read("*l"); if l then exp=l:gsub("notAfter=","") end; f:close() end
local dn=entry:gsub("_wildcard_%.", "*.")
h=h..'<tr class="tr"><td class="td"><strong>'..esc(dn)..'</strong></td><td class="td">'..esc(exp)..'</td><td class="td"><code>'..esc(fp)..'/</code></td></tr>'
end
end
end
end
if not found then h=h..'<tr class="tr"><td class="td" colspan="3" style="text-align:center;color:#999;padding:15px">暂无证书</td></tr>' end
return h..'</table>'
end
o=s:option(DummyValue,"_ast","acme.sh 状态"); o.rawhtml=true
o.cfgvalue=function()
if fs.access("/etc/minigate/acme/data/acme.sh")and fs.access("/etc/minigate/acme/data/dnsapi/dns_cf.sh")then return'<span style="color:#4caf50">&#10003; 已安装</span>'
elseif fs.access("/etc/minigate/acme/data/acme.sh")then return'<span style="color:#ff9800">&#9888; 缺DNS插件</span>'
else return'<span style="color:#f44336">&#10007; 未安装</span>'end
end
-- 操作
s=m:section(NamedSection,"acme","acme","操作"); s.anonymous=true
o=s:option(DummyValue,"_act"," "); o.rawhtml=true
o.cfgvalue=function()
local iu=luci.dispatcher.build_url("admin/services/minigate/acme_install")
local su=luci.dispatcher.build_url("admin/services/minigate/acme_issue")
local opts=""
for _,d in ipairs(dd)do opts=opts..'<option value="'..esc(d)..'">'..esc(d)..'</option>' end
return[[
<div style="margin-bottom:15px"><button class="cbi-button cbi-button-reload" id="bi" onclick="doI()" style="min-width:220px">下载并安装 acme.sh</button></div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
<label>签发域名:</label>
<select id="sd" style="padding:5px 10px;border-radius:4px;border:1px solid #ccc;min-width:250px">]]..
(opts~=""and opts or'<option value="">请先配置动态DNS</option>')..[[</select>
<button class="cbi-button cbi-button-apply" id="bs" onclick="doS()">立即签发 / 续期</button></div>
<div id="ap" style="margin-top:12px;display:none"><div style="background:#f0f0f0;border-radius:4px;padding:10px 15px;font-size:13px"><span style="display:inline-block;animation:sp 1s linear infinite;margin-right:8px">&#9203;</span><span id="am">处理中...</span></div></div>
<div id="ar" style="margin-top:12px;display:none"><div id="ai" style="border-radius:4px;padding:10px 15px;font-size:13px"></div></div>
<style>@keyframes sp{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}</style>
<script type="text/javascript">
function sP(m){document.getElementById('ap').style.display='block';document.getElementById('ar').style.display='none';document.getElementById('am').textContent=m}
function sR(ok,m){document.getElementById('ap').style.display='none';document.getElementById('ar').style.display='block';var e=document.getElementById('ai');e.style.background=ok?'#e8f5e9':'#ffebee';e.style.color=ok?'#2e7d32':'#c62828';e.textContent=m}
function doI(){document.getElementById('bi').disabled=true;sP('下载安装中(约30秒)...');XHR.get(']]..iu..[[',null,function(x,d){document.getElementById('bi').disabled=false;d?sR(d.success,d.message):sR(false,'失败');if(d&&d.success)setTimeout(function(){location.reload()},2000)})}
function doS(){var d=document.getElementById('sd').value;if(!d){sR(false,'请选择域名');return}document.getElementById('bs').disabled=true;sP('签发 '+d+'(约1-2分钟)...');XHR.get(']]..su..[[',{domain:d},function(x,r){document.getElementById('bs').disabled=false;r?sR(r.success,r.message):sR(false,'超时');if(r&&r.success)setTimeout(function(){location.reload()},2000)})}
</script>]]
end
return m
+116
View File
@@ -0,0 +1,116 @@
local m,s,o
local sys=require"luci.sys"
m=Map("minigate","MiniGate - 动态DNS","支持多域名、IPv4/IPv6双栈。保存后立即同步。需先启用全局开关。")
m.on_after_commit=function(self)
sys.call("/bin/sh /usr/lib/minigate/ddns.sh force >/dev/null 2>&1 &")
local en=self.uci:get("minigate","global","enabled")
if en=="1"then sys.call("/etc/init.d/minigate restart >/dev/null 2>&1 &") end
end
s=m:section(TypedSection,"ddns","DDNS 记录")
s.anonymous=true; s.addremove=true; s.sortable=true
o=s:option(Flag,"enabled","启用"); o.rmempty=false
o=s:option(Value,"domain","域名"); o.rmempty=false; o.placeholder="*.example.com 或 home.example.com"
o=s:option(Value,"cf_zone_id","Zone ID"); o.rmempty=false; o.placeholder="Cloudflare 区域 ID"
o=s:option(Value,"cf_api_token","API 令牌"); o.rmempty=false; o.password=true
-- ====== 协议版本 ======
o=s:option(ListValue,"ip_version","协议版本")
o:value("ipv4","仅 IPv4(A 记录)")
o:value("ipv6","仅 IPv6(AAAA 记录)")
o:value("dual","双栈(A + AAAA)")
o.default="ipv4"
o.description="选择更新哪种 DNS 记录。双栈模式同时更新 A 和 AAAA 记录。"
o=s:option(ListValue,"ip_source","IP 来源")
o:value("interface","从网卡获取"); o:value("url","从外部URL获取"); o.default="interface"
-- IPv4 网卡
o=s:option(ListValue,"interface","IPv4 网络接口"); o.default="wan"
local uci=require"luci.model.uci".cursor()
uci:foreach("network","interface",function(sec) if sec[".name"]~="loopback"then o:value(sec[".name"],sec[".name"])end end)
o:depends("ip_source","interface")
-- IPv4 URL
o=s:option(ListValue,"ip_url","IPv4 获取地址")
o:value("http://ip.3322.net","ip.3322.net")
o:value("http://members.3322.org/dyndns/getip","members.3322.org")
o:value("http://ns1.dnspod.net:6666","ns1.dnspod.net (腾讯)")
o:value("http://ip.tool.chinaz.com/getip","chinaz.com (站长工具)")
o.default="http://ip.3322.net"
o.description="旁路由/代理环境自动绕过代理直连获取真实公网IP"
o:depends("ip_source","url")
-- IPv6 网卡
o=s:option(ListValue,"interface6","IPv6 网络接口"); o.default="wan6"
o.description="用于获取 IPv6 地址的接口(通常为 wan6)"
uci:foreach("network","interface",function(sec) if sec[".name"]~="loopback"then o:value(sec[".name"],sec[".name"])end end)
o:depends({ip_source="interface",ip_version="ipv6"})
o:depends({ip_source="interface",ip_version="dual"})
-- IPv6 URL
o=s:option(ListValue,"ip_url6","IPv6 获取地址")
o:value("https://api6.ipify.org","api6.ipify.org")
o:value("https://6.ipw.cn","6.ipw.cn")
o:value("https://v6.ident.me","v6.ident.me")
o:value("https://ifconfig.co","ifconfig.co (v6)")
o:value("http://v6.66666.host:66/ip","v6.66666.host")
o.default="https://api6.ipify.org"
o.description="仅通过 IPv6 连接获取地址"
o:depends({ip_source="url",ip_version="ipv6"})
o:depends({ip_source="url",ip_version="dual"})
o=s:option(Value,"check_interval","间隔(秒)"); o.datatype="uinteger"; o.default="300"
-- 状态+手动同步
o=s:option(DummyValue,"_info","运行状态"); o.rawhtml=true
o.cfgvalue=function(self,section)
local st=m.uci:get("minigate",section,"status")or"unknown"
local msg=m.uci:get("minigate",section,"status_msg")or""
local ip=m.uci:get("minigate",section,"last_ip")or""
local ip6=m.uci:get("minigate",section,"last_ip6")or""
local lu=m.uci:get("minigate",section,"last_update")or""
local ns=m.uci:get("minigate",section,"next_sync")or""
local ver=m.uci:get("minigate",section,"ip_version")or"ipv4"
local h=""
if st=="ok"then
h='<span style="color:#4caf50">&#10003; 正常</span>'
elseif st=="partial"then
h='<span style="color:#ff9800">&#9888; 部分成功</span>'
elseif st=="error"then
h='<span style="color:#f44336">&#10007; '..(msg~=""and msg or"异常")..'</span>'
else h='<span style="color:#999">未同步</span>' end
-- 显示 IP 地址
if ip~=""then h=h..'<br><span style="font-size:11px;color:#888">A: '..ip..'</span>' end
if ip6~=""then h=h..'<br><span style="font-size:11px;color:#2196f3">AAAA: '..ip6..'</span>' end
if msg~=""and st~="error"then h=h..'<br><span style="font-size:11px;color:#888">'..msg..'</span>' end
if lu~=""then h=h..'<br><span style="font-size:11px;color:#888">更新: '..lu..'</span>' end
if ns~=""then h=h..'<br><span style="font-size:11px;color:#888">下次: '..ns..'</span>' end
h=h..'<br><button class="cbi-button cbi-button-action" style="margin-top:4px;font-size:12px;padding:2px 10px" '
h=h..'onclick="doSync(\''..section..'\',this)">手动同步</button>'
h=h..'<span id="sr-'..section..'" style="margin-left:8px;font-size:12px"></span>'
return h
end
-- 最近一天 IP 变化明细
s=m:section(SimpleSection)
s.template="minigate/ddns_history"
-- 注入 JS
s=m:section(NamedSection,"global","global"); s.anonymous=true
o=s:option(DummyValue,"_js"," "); o.rawhtml=true
o.cfgvalue=function()
local url=luci.dispatcher.build_url("admin/services/minigate/ddns_sync")
return'<script type="text/javascript">function doSync(s,b){b.disabled=true;b.textContent="同步中...";var e=document.getElementById("sr-"+s);e.textContent="";XHR.get("'..url..'",{section:s},function(x,d){b.disabled=false;b.textContent="手动同步";if(d&&d.success){e.innerHTML=\'<span style="color:#4caf50">\\u2713 \'+d.ip+(d.ip6?\' / \'+d.ip6:\'\')+\'</span>\';setTimeout(function(){location.reload()},1500)}else e.innerHTML=\'<span style="color:#f44336">\\u2717 失败</span>\'})}</script>'
end
return m
@@ -0,0 +1,589 @@
local m, s, o
local sys = require "luci.sys"
m = Map("minigate", "minigate", "轻量级网关管理:动态域名解析(IPv4/IPv6双栈)、SSL 证书签发、反向代理。")
m.on_after_commit = function(self)
local en = self.uci:get("minigate", "global", "enabled")
if en == "1" then
sys.call("/etc/init.d/minigate restart >/dev/null 2>&1 &")
else
sys.call("/etc/init.d/minigate stop >/dev/null 2>&1 &")
end
end
s = m:section(NamedSection, "global", "global", "服务状态")
s.anonymous = true
s:tab("status", "总览")
local su = luci.dispatcher.build_url("admin/services/minigate/status")
o = s:taboption("status", DummyValue, "_status")
o.rawhtml = true
o.cfgvalue = function()
local su = luci.dispatcher.build_url("admin/services/minigate/status")
local au = luci.dispatcher.build_url("admin/services/minigate/proxy_access")
local gu = luci.dispatcher.build_url("admin/services/minigate/geo_lookup")
local uu = luci.dispatcher.build_url("admin/services/minigate/update_status")
local xu = luci.dispatcher.build_url("admin/services/minigate/update_auto")
local cu = luci.dispatcher.build_url("admin/services/minigate/update_check")
local iu = luci.dispatcher.build_url("admin/services/minigate/update_apply")
local du = luci.dispatcher.build_url("admin/services/minigate/uninstall")
local services_url = luci.dispatcher.build_url("admin/services")
return [[
<style>
.mg-wrap{display:flex;flex-direction:column;gap:16px;--mg-success:#2f9e44;--mg-info:#1971c2;--mg-warning:#f08c00;--mg-danger:#e03131;--mg-danger-solid:#c92a2a;--mg-muted:#6b7280;--mg-link:#15803d;--mg-accent:#7c3aed;--mg-surface:#fff;--mg-surface-muted:#f6f8fb;--mg-surface-alt:#f8fafc;--mg-surface-hover:#eef4ff;--mg-control:#fff;--mg-text:#1f2937;--mg-text-soft:#2f3a47;--mg-text-muted:#5f6b7a;--mg-border:#d7dce3;--mg-border-soft:#e6eaf0;--mg-code-bg:#e8edf6;--mg-shadow:0 10px 24px rgba(15,23,42,.08)}
.mg-map{--mg-danger:#c92a2a;--mg-danger-hover:#fff5f5;--mg-danger-border:#f1b8b8}
.mg-page-head{display:flex;align-items:flex-start;justify-content:space-between;gap:28px}
.mg-page-head-copy{min-width:0;flex:1}
.mg-page-head-copy>h2{margin-top:0}
.mg-title-action{display:flex;flex:0 0 auto;margin-top:15px}
.mg-uninstall-trigger{min-width:112px;min-height:38px;border:1px solid var(--mg-danger-border)!important;border-radius:6px!important;background:transparent!important;color:var(--mg-danger)!important;font-weight:600}
.mg-uninstall-trigger:hover,.mg-uninstall-trigger:focus{background:var(--mg-danger-hover)!important}
.mg-status-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
.mg-card{position:relative;min-height:154px;border-radius:8px;padding:18px 18px 16px;background:var(--mg-surface);border:1px solid var(--mg-border);box-shadow:var(--mg-shadow);overflow:hidden}
.mg-card:before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--accent,#777)}
.mg-card-title{font-size:12px;color:var(--mg-text-muted);margin-bottom:12px}
.mg-card-state{font-size:18px;font-weight:700;margin-bottom:14px;line-height:1.25}
.mg-card-body{font-size:12px;color:var(--mg-text-soft);line-height:1.9;word-break:break-word}
.mg-card-body a,.mg-link{color:var(--mg-link);text-decoration:none}
.mg-card-body a:hover,.mg-link:hover{text-decoration:underline}
.mg-panel{border-radius:8px;background:var(--mg-surface);border:1px solid var(--mg-border);box-shadow:var(--mg-shadow);overflow:hidden}
.mg-panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;padding:14px 16px;border-bottom:1px solid var(--mg-border)}
.mg-panel-title{font-size:13px;font-weight:700;color:var(--mg-text)}
.mg-count{color:var(--mg-accent);font-weight:600}
.mg-limit-label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--mg-text-muted);white-space:nowrap}
.mg-select{height:28px;border-radius:6px;border:1px solid var(--mg-border);background:var(--mg-control);color:var(--mg-text);padding:0 8px;font-size:12px}
.mg-table-wrap{overflow-x:auto}
.mg-table{width:100%;border-collapse:collapse;min-width:760px}
.mg-table th{padding:10px 12px;color:var(--mg-text-muted);font-size:11px;font-weight:600;text-align:left;border-bottom:1px solid var(--mg-border);background:var(--mg-surface-muted)}
.mg-table td{padding:11px 12px;color:var(--mg-text);font-size:12px;border-bottom:1px solid var(--mg-border-soft);vertical-align:middle}
.mg-table tr:nth-child(even) td{background:var(--mg-surface-alt)}
.mg-table tr:hover td{background:var(--mg-surface-hover)}
.mg-status{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.mg-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:#777}
.mg-dot.on{background:var(--mg-success);box-shadow:0 0 0 3px rgba(47,158,68,.14)}
.mg-ip{display:inline-block;border-radius:6px;background:var(--mg-code-bg);color:var(--mg-text);padding:2px 7px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px}
.mg-empty{padding:18px 16px;color:var(--mg-text-muted);font-size:12px}
.mg-update-body{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:15px 16px;flex-wrap:wrap}
.mg-update-copy{display:flex;flex-direction:column;gap:5px;min-width:220px;flex:1}
.mg-update-version{font-size:13px;font-weight:700;color:var(--mg-text)}
.mg-update-message{font-size:12px;color:var(--mg-text-muted);line-height:1.6}
.mg-update-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.mg-update-progress{height:3px;background:var(--mg-border-soft);overflow:hidden}
.mg-update-progress span{display:block;width:0;height:100%;background:var(--mg-info);transition:width .25s ease}
.mg-modal{position:fixed;inset:0;z-index:10000;display:none;align-items:center;justify-content:center;padding:20px;background:rgba(15,23,42,.58)}
.mg-modal.is-open{display:flex}
.mg-modal-dialog{display:flex;flex-direction:column;width:min(620px,100%);max-height:min(80vh,720px);border:1px solid var(--mg-border);border-radius:8px;background:var(--mg-surface);box-shadow:0 20px 48px rgba(15,23,42,.28);overflow:hidden}
.mg-modal-head{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;gap:16px;padding:15px 16px;border-bottom:1px solid var(--mg-border)}
.mg-modal-title{margin:0;min-width:0;overflow-wrap:anywhere;color:var(--mg-text);font-size:15px;font-weight:700;line-height:1.4}
.mg-modal-close{display:inline-flex;align-items:center;justify-content:center;flex:0 0 44px;width:44px;height:44px;padding:0;border:0;background:transparent;color:var(--mg-text-muted);font-size:28px;line-height:1;cursor:pointer}
.mg-modal-close:hover{color:var(--mg-text);background:var(--mg-surface-hover)}
.mg-modal-notes{min-height:0;padding:16px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--mg-text-soft);font-size:13px;line-height:1.7;background:var(--mg-surface-muted)}
.mg-modal-actions{display:flex;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end;gap:8px;padding:14px 16px;border-top:1px solid var(--mg-border);background:var(--mg-surface)}
.mg-modal-actions button{min-height:44px}
.mg-modal button:focus{outline:2px solid var(--mg-info);outline-offset:2px}
.mg-uninstall-body{padding:16px;color:var(--mg-text-soft);font-size:13px;line-height:1.7;background:var(--mg-surface-muted)}
.mg-uninstall-body p{margin:0 0 12px}
.mg-uninstall-warning{margin-bottom:14px;padding:11px 12px;border-left:4px solid var(--mg-danger);background:var(--mg-surface);color:var(--mg-text)}
.mg-uninstall-choice{display:flex;align-items:flex-start;gap:10px;padding:12px;border:1px solid var(--mg-border);border-radius:6px;background:var(--mg-surface);color:var(--mg-text);cursor:pointer}
.mg-uninstall-choice input{flex:0 0 auto;width:18px;height:18px;margin:2px 0 0}
.mg-uninstall-detail{margin:8px 0 0;color:var(--mg-text-muted);font-size:12px}
.mg-uninstall-status{min-height:21px;margin-top:10px;color:var(--mg-danger);font-size:12px}
.mg-danger-button{border-color:var(--mg-danger-solid)!important;background:var(--mg-danger-solid)!important;color:#fff!important}
.mg-danger-button:hover{filter:brightness(.94)}
.mg-danger-button:focus{outline-color:var(--mg-danger)!important}
:root[data-darkmode="true"] .mg-wrap{--mg-success:#78c98d;--mg-info:#7db7df;--mg-warning:#d7a64a;--mg-danger:#e58a84;--mg-danger-solid:#a9504d;--mg-muted:#9aa7b3;--mg-link:#7bcf91;--mg-accent:#b69af4;--mg-surface:var(--background-color-medium,#171e26);--mg-surface-muted:var(--background-color-high,#131a22);--mg-surface-alt:var(--background-color-low,#19212a);--mg-surface-hover:#202a34;--mg-control:var(--background-color-high,#111820);--mg-text:var(--text-color-high,#d5dbe3);--mg-text-soft:var(--text-color-high,#c8d0d9);--mg-text-muted:var(--text-color-medium,#9aa7b3);--mg-border:var(--border-color-medium,rgba(148,163,184,.18));--mg-border-soft:var(--border-color-low,rgba(148,163,184,.12));--mg-code-bg:var(--background-color-low,#253142);--mg-shadow:none}
:root[data-darkmode="true"] .mg-map{--mg-danger:#e58a84;--mg-danger-hover:rgba(229,138,132,.1);--mg-danger-border:rgba(229,138,132,.45)}
@media(prefers-color-scheme:dark){
:root:not([data-darkmode]) .mg-wrap{--mg-success:#78c98d;--mg-info:#7db7df;--mg-warning:#d7a64a;--mg-danger:#e58a84;--mg-danger-solid:#a9504d;--mg-muted:#9aa7b3;--mg-link:#7bcf91;--mg-accent:#b69af4;--mg-surface:#171e26;--mg-surface-muted:#131a22;--mg-surface-alt:#19212a;--mg-surface-hover:#202a34;--mg-control:#111820;--mg-text:#d5dbe3;--mg-text-soft:#c8d0d9;--mg-text-muted:#9aa7b3;--mg-border:rgba(148,163,184,.18);--mg-border-soft:rgba(148,163,184,.12);--mg-code-bg:#253142;--mg-shadow:none}
:root:not([data-darkmode]) .mg-map{--mg-danger:#e58a84;--mg-danger-hover:rgba(229,138,132,.1);--mg-danger-border:rgba(229,138,132,.45)}
}
@media(max-width:900px){.mg-status-grid{grid-template-columns:1fr}.mg-card{min-height:auto}}
@media(max-width:640px){.mg-page-head{flex-direction:column;gap:12px}.mg-title-action{width:100%;margin-top:0}.mg-uninstall-trigger{width:100%}.mg-modal{padding:12px}.mg-modal-dialog{max-height:88vh}.mg-modal-actions{display:grid;grid-template-columns:1fr 1fr}.mg-modal-actions button{width:100%;min-width:0}}
</style>
<div class="mg-wrap">
<div id="mg-title-action" class="mg-title-action" hidden>
<button type="button" id="mg-uninstall-open" class="cbi-button mg-uninstall-trigger" onclick="mgOpenUninstallDialog()">卸载</button>
</div>
<div id="mg" class="mg-status-grid">
<div id="mg-ddns-card" class="mg-card" style="--accent:var(--mg-success)">
<div class="mg-card-title">动态 DNS</div>
<div id="mg-d1" class="mg-card-state">--</div>
<div id="mg-d2" class="mg-card-body"></div>
</div>
<div id="mg-cert-card" class="mg-card" style="--accent:var(--mg-info)">
<div class="mg-card-title">SSL/TLS 证书</div>
<div id="mg-a1" class="mg-card-state">--</div>
<div id="mg-a2" class="mg-card-body"></div>
</div>
<div id="mg-proxy-card" class="mg-card" style="--accent:var(--mg-warning)">
<div class="mg-card-title">反向代理</div>
<div id="mg-p1" class="mg-card-state">--</div>
<div id="mg-p2" class="mg-card-body"></div>
</div>
</div>
<div id="mg-update" class="mg-panel">
<div class="mg-panel-head"><div class="mg-panel-title">软件更新</div></div>
<div class="mg-update-body">
<div class="mg-update-copy">
<div id="mg-u-version" class="mg-update-version">当前版本 --</div>
<div id="mg-u-message" class="mg-update-message">正在读取更新状态...</div>
</div>
<div class="mg-update-actions">
<button type="button" id="mg-u-check" class="cbi-button cbi-button-reload" onclick="mgCheckUpdate()">检查更新</button>
<button type="button" id="mg-u-apply" class="cbi-button cbi-button-apply" onclick="mgApplyUpdate()" style="display:none">一键更新</button>
</div>
</div>
<div class="mg-update-progress"><span id="mg-u-progress"></span></div>
</div>
<div id="mg-update-modal" class="mg-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="mg-update-modal-title">
<div class="mg-modal-dialog">
<div class="mg-modal-head">
<h3 id="mg-update-modal-title" class="mg-modal-title">本次更新内容</h3>
<button type="button" class="mg-modal-close" aria-label="关闭更新说明" title="关闭更新说明" onclick="mgCloseUpdateDialog()">&times;</button>
</div>
<div id="mg-update-modal-notes" class="mg-modal-notes" tabindex="0" aria-label="本次更新内容"></div>
<div class="mg-modal-actions">
<button type="button" class="cbi-button" onclick="mgCloseUpdateDialog()">取消</button>
<button type="button" id="mg-update-confirm" class="cbi-button cbi-button-apply" onclick="mgStartUpdate()">确认更新</button>
</div>
</div>
</div>
<div id="mg-visitors" class="mg-panel">
<div class="mg-panel-head">
<div class="mg-panel-title">访问记录 <span id="mg-v-count" class="mg-count"></span></div>
<label class="mg-limit-label">显示
<select id="mg-v-limit" class="mg-select">
<option value="5" selected>5</option>
<option value="20">20</option>
<option value="50">50</option>
</select>条</label>
</div>
<div id="mg-v-list" class="mg-table-wrap"><div class="mg-empty">加载中...</div></div>
</div>
<div id="mg-uninstall-modal" class="mg-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="mg-uninstall-modal-title">
<div class="mg-modal-dialog">
<div class="mg-modal-head">
<h3 id="mg-uninstall-modal-title" class="mg-modal-title">卸载 minigate</h3>
<button type="button" id="mg-uninstall-close" class="mg-modal-close" aria-label="关闭卸载确认" title="关闭卸载确认" onclick="mgCloseUninstallDialog()">&times;</button>
</div>
<div class="mg-uninstall-body">
<p>卸载会停止动态 DNS、证书续期、反向代理和登录防护,并移除 minigate 的程序及 LuCI 页面。</p>
<div class="mg-uninstall-warning">由 minigate 提供的公网访问会立即中断。</div>
<label class="mg-uninstall-choice" for="mg-uninstall-purge">
<input type="checkbox" id="mg-uninstall-purge" onchange="mgUpdateUninstallChoice()">
<span>同时删除配置、证书、封禁记录和日志</span>
</label>
<p id="mg-uninstall-detail" class="mg-uninstall-detail">默认保留这些数据,方便以后重新安装。</p>
<div id="mg-uninstall-status" class="mg-uninstall-status" role="status" aria-live="polite"></div>
</div>
<div class="mg-modal-actions">
<button type="button" id="mg-uninstall-cancel" class="cbi-button" onclick="mgCloseUninstallDialog()">取消</button>
<button type="button" id="mg-uninstall-confirm" class="cbi-button mg-danger-button" onclick="mgStartUninstall()">确认卸载</button>
</div>
</div>
</div>
</div>
<script type="text/javascript">
var _geoCache={};
var _geoPending={};
var _visitorLimit=localStorage.getItem('mgVisitorLimit')||'5';
if(_visitorLimit!='5'&&_visitorLimit!='20'&&_visitorLimit!='50')_visitorLimit='5';
function escHtml(s){
return String(s||'').replace(/[&<>"']/g,function(c){
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
function mgColor(name){
var root=document.querySelector('.mg-wrap');
if(!root)return'#999';
return getComputedStyle(root).getPropertyValue('--mg-'+name).trim()||'#999';
}
function fmtT(iso){
if(!iso)return'--';
var m=iso.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/);
return m?(m[2]+'-'+m[3]+' '+m[4]+':'+m[5]+':'+m[6]):iso;
}
function queryGeo(ip,cb){
if(_geoCache[ip]){cb(_geoCache[ip]);return;}
if(_geoPending[ip]){
_geoPending[ip].push(cb);
return;
}
_geoPending[ip]=[cb];
XHR.get(']] .. gu .. [[',{ip:ip},function(x,d){
var loc=(d&&d.geo)?d.geo:'未知';
var list=_geoPending[ip]||[];
delete _geoPending[ip];
_geoCache[ip]=loc;
for(var i=0;i<list.length;i++)list[i](loc);
});
}
function loadVisitors(){
XHR.get(']] .. au .. [[',{limit:_visitorLimit},function(x,d){
var el=document.getElementById('mg-v-list');
var ct=document.getElementById('mg-v-count');
if(!d||!d.visitors||d.visitors.length===0){
el.innerHTML='<div class="mg-empty">暂无访问记录</div>';
ct.textContent='';
return;
}
ct.textContent='('+d.visitors.length+' 个IP)';
var h='<table class="mg-table">';
h+='<thead><tr><th>状态</th><th>IP 地址</th><th>归属地</th><th>最后访问</th><th>域名</th><th>次数</th></tr></thead><tbody>';
for(var i=0;i<d.visitors.length;i++){
var v=d.visitors[i];
var dot=v.online
?'<span class="mg-dot on" title="在线"></span>'
:'<span class="mg-dot" title="离线"></span>';
var stxt=v.online?'<span style="color:'+mgColor('success')+'">在线</span>':'<span style="color:'+mgColor('muted')+'">离线</span>';
h+='<tr>';
h+='<td><span class="mg-status">'+dot+stxt+'</span></td>';
h+='<td><code class="mg-ip">'+escHtml(v.ip)+'</code></td>';
h+='<td id="geo-'+i+'"><span style="color:'+mgColor('muted')+'">查询中...</span></td>';
h+='<td style="white-space:nowrap">'+escHtml(fmtT(v.last_time))+'</td>';
h+='<td>'+escHtml(v.domain)+'</td>';
h+='<td>'+escHtml(v.count)+'</td>';
h+='</tr>';
}
h+='</tbody></table>';
el.innerHTML=h;
// 逐个查询归属地(避免并发太多)
var qi=0;
function nextGeo(){
if(qi>=d.visitors.length)return;
var idx=qi;qi++;
queryGeo(d.visitors[idx].ip,function(loc){
var ge=document.getElementById('geo-'+idx);
if(ge)ge.innerHTML='<span style="font-size:11px">'+escHtml(loc)+'</span>';
setTimeout(nextGeo,150);
});
}
nextGeo();
});
}
var _updatePolling=false;
var _latestUpdate=null;
var _updateDialogOpener=null;
var _confirmedUpdate=null;
var _bodyOverflow='';
var _updateStarting=false;
var _updateStarted=false;
var _updateReloading=false;
var _uninstallDialogOpener=null;
var _uninstallStarting=false;
function mgPreparePageHeader(){
var action=document.getElementById('mg-title-action');
var wrap=document.querySelector('.mg-wrap');
if(!action||!wrap)return;
var map=wrap;
while(map&&(' '+map.className+' ').indexOf(' cbi-map ')<0)map=map.parentNode;
if(!map)return;
var title=null,description=null;
for(var i=0;i<map.children.length;i++){
var child=map.children[i];
if(!title&&child.tagName&&child.tagName.toLowerCase()==='h2')title=child;
else if(title&&(' '+child.className+' ').indexOf(' cbi-map-descr ')>=0){description=child;break;}
}
if(!title)return;
var head=document.createElement('div');
var copy=document.createElement('div');
head.className='mg-page-head';
copy.className='mg-page-head-copy';
map.className+=' mg-map';
map.insertBefore(head,title);
head.appendChild(copy);
copy.appendChild(title);
if(description)copy.appendChild(description);
action.hidden=false;
head.appendChild(action);
}
function mgOpenUninstallDialog(){
if(_uninstallStarting)return;
var modal=document.getElementById('mg-uninstall-modal');
var purge=document.getElementById('mg-uninstall-purge');
_uninstallDialogOpener=document.getElementById('mg-uninstall-open');
purge.checked=false;
document.getElementById('mg-uninstall-status').textContent='';
mgUpdateUninstallChoice();
modal.className='mg-modal is-open';
modal.setAttribute('aria-hidden','false');
_bodyOverflow=document.body.style.overflow;
document.body.style.overflow='hidden';
document.getElementById('mg-uninstall-confirm').focus();
}
function mgCloseUninstallDialog(){
if(_uninstallStarting)return;
var modal=document.getElementById('mg-uninstall-modal');
modal.className='mg-modal';
modal.setAttribute('aria-hidden','true');
document.body.style.overflow=_bodyOverflow;
if(_uninstallDialogOpener)_uninstallDialogOpener.focus();
}
function mgUpdateUninstallChoice(){
var purge=document.getElementById('mg-uninstall-purge').checked;
document.getElementById('mg-uninstall-detail').textContent=purge
?'配置、证书、封禁记录和日志将一并删除,无法恢复。'
:'默认保留这些数据,方便以后重新安装。';
document.getElementById('mg-uninstall-confirm').textContent=purge?'彻底卸载':'确认卸载';
}
function mgStartUninstall(){
if(_uninstallStarting)return;
var purge=document.getElementById('mg-uninstall-purge').checked?'1':'0';
var confirm=document.getElementById('mg-uninstall-confirm');
var status=document.getElementById('mg-uninstall-status');
_uninstallStarting=true;
confirm.disabled=true;
document.getElementById('mg-uninstall-purge').disabled=true;
document.getElementById('mg-uninstall-cancel').disabled=true;
document.getElementById('mg-uninstall-close').disabled=true;
status.textContent='正在启动卸载任务...';
XHR.post(']] .. du .. [[',{confirm:'uninstall-minigate',purge:purge},function(x,d){
if(!d||!d.success){
_uninstallStarting=false;
confirm.disabled=false;
document.getElementById('mg-uninstall-purge').disabled=false;
document.getElementById('mg-uninstall-cancel').disabled=false;
document.getElementById('mg-uninstall-close').disabled=false;
status.textContent=(d&&d.message)||'无法启动卸载任务';
return;
}
confirm.textContent='卸载中...';
status.textContent='正在移除 minigate,完成后将返回服务页面。';
setTimeout(function(){location.href=']] .. services_url .. [[';},3000);
});
}
function mgRenderUpdate(d){
if(!d)return;
_latestUpdate=d;
var version=document.getElementById('mg-u-version');
var message=document.getElementById('mg-u-message');
var check=document.getElementById('mg-u-check');
var apply=document.getElementById('mg-u-apply');
var progress=document.getElementById('mg-u-progress');
var current=d.current||'--';
version.textContent='当前版本 '+current+(d.latest?' · 最新版本 '+d.latest:'');
message.textContent=d.message||'--';
message.style.color=d.success===false?mgColor('danger'):(d.status=='success'?mgColor('success'):mgColor('muted'));
progress.style.width=Math.max(0,Math.min(100,Number(d.progress)||0))+'%';
progress.style.background=d.success===false?mgColor('danger'):(d.status=='success'?mgColor('success'):mgColor('info'));
check.disabled=!!d.running;
apply.disabled=!!d.running;
document.getElementById('mg-uninstall-open').disabled=!!d.running;
apply.style.display=d.available?'inline-block':'none';
if(d.running&&!_updatePolling){
_updatePolling=true;
setTimeout(mgLoadUpdate,1200);
}else if(!d.running){
_updatePolling=false;
}
if(d.status=='success'&&_updateStarted&&!_updateReloading){
_updateReloading=true;
setTimeout(function(){location.reload()},3500);
}
}
function mgLoadUpdate(){
var keepPolling=_updatePolling;
XHR.get(']] .. uu .. [[',null,function(x,d){
_updatePolling=false;
if(d){
mgRenderUpdate(d);
}else if(keepPolling){
_updatePolling=true;
setTimeout(mgLoadUpdate,2000);
}
});
}
function mgCheckUpdate(){
var check=document.getElementById('mg-u-check');
check.disabled=true;
document.getElementById('mg-u-message').textContent='正在连接 GitHub...';
XHR.get(']] .. cu .. [[',null,function(x,d){
check.disabled=false;
mgRenderUpdate(d||{success:false,message:'检查失败,请稍后重试'});
});
}
function mgAutoCheckUpdate(){
var check=document.getElementById('mg-u-check');
check.disabled=true;
document.getElementById('mg-u-message').textContent='正在自动检查更新...';
XHR.get(']] .. xu .. [[',null,function(x,d){
check.disabled=false;
mgRenderUpdate(d||{success:false,message:'自动检查失败,可稍后手动重试'});
});
}
function mgApplyUpdate(){
if(!_latestUpdate||!_latestUpdate.available||!_latestUpdate.latest||_latestUpdate.running||_updateStarting||_confirmedUpdate)return;
var modal=document.getElementById('mg-update-modal');
var title=document.getElementById('mg-update-modal-title');
var notes=document.getElementById('mg-update-modal-notes');
_updateDialogOpener=document.getElementById('mg-u-apply');
_confirmedUpdate=_latestUpdate;
title.textContent=_latestUpdate.release_title||('更新到 v'+(_latestUpdate.latest||''));
notes.textContent=_latestUpdate.release_notes||'GitHub Release 未提供本次更新说明。';
modal.className='mg-modal is-open';
modal.setAttribute('aria-hidden','false');
_bodyOverflow=document.body.style.overflow;
document.body.style.overflow='hidden';
notes.focus();
}
function mgCloseUpdateDialog(){
var modal=document.getElementById('mg-update-modal');
modal.className='mg-modal';
modal.setAttribute('aria-hidden','true');
document.body.style.overflow=_bodyOverflow;
_confirmedUpdate=null;
if(_updateDialogOpener)_updateDialogOpener.focus();
}
function mgStartUpdate(){
if(!_confirmedUpdate||_updateStarting)return;
var confirmed=_confirmedUpdate;
_updateStarting=true;
mgCloseUpdateDialog();
var apply=document.getElementById('mg-u-apply');
apply.disabled=true;
document.getElementById('mg-u-check').disabled=true;
document.getElementById('mg-u-message').textContent='正在启动更新任务...';
XHR.post(']] .. iu .. [[',{version:confirmed.latest},function(x,d){
_updateStarting=false;
if(!d||!d.success){
confirmed.success=false;
confirmed.message=(d&&d.message)||'无法启动更新任务';
mgRenderUpdate(confirmed);
return;
}
_updateStarted=true;
_updatePolling=true;
setTimeout(mgLoadUpdate,700);
});
}
var updateModal=document.getElementById('mg-update-modal');
var uninstallModal=document.getElementById('mg-uninstall-modal');
if(updateModal){
updateModal.onclick=function(e){if(e.target===updateModal)mgCloseUpdateDialog();};
}
if(uninstallModal){
uninstallModal.onclick=function(e){if(e.target===uninstallModal)mgCloseUninstallDialog();};
}
document.addEventListener('keydown',function(e){
var modal=null;
if(updateModal&&updateModal.className.indexOf('is-open')>=0)modal=updateModal;
else if(uninstallModal&&uninstallModal.className.indexOf('is-open')>=0)modal=uninstallModal;
if(!modal)return;
if(e.key==='Escape'){
e.preventDefault();
if(modal===updateModal)mgCloseUpdateDialog();else mgCloseUninstallDialog();
}
if(e.key==='Tab'){
var items=modal.querySelectorAll('button:not([disabled]),input:not([disabled]),[tabindex="0"]');
var first=items[0],last=items[items.length-1];
if(!first)return;
if(e.shiftKey&&(document.activeElement===first||!modal.contains(document.activeElement))){
e.preventDefault();last.focus();
}else if(!e.shiftKey&&(document.activeElement===last||!modal.contains(document.activeElement))){
e.preventDefault();first.focus();
}
}
});
mgPreparePageHeader();
var limitSel=document.getElementById('mg-v-limit');
if(limitSel){
limitSel.value=_visitorLimit;
limitSel.onchange=function(){
_visitorLimit=this.value;
localStorage.setItem('mgVisitorLimit',_visitorLimit);
loadVisitors();
};
}
XHR.poll(6,']] .. su .. [[',null,function(x,d){
if(!d)return;
var d1=document.getElementById('mg-d1'),d2=document.getElementById('mg-d2');
var card=document.getElementById('mg-ddns-card');
if(d.ddns_list&&d.ddns_list.length>0){
var e=d.ddns_list[0];
var c=e.status=='ok'?mgColor('success'):(e.status=='partial'?mgColor('warning'):(e.enabled=='1'?mgColor('danger'):mgColor('muted')));
var l=e.status=='ok'?'\u2713 \u8fd0\u884c\u4e2d':(e.status=='partial'?'\u26a0 \u90e8\u5206\u6210\u529f':(e.enabled=='1'?'\u26a0 \u5f02\u5e38':'\u672a\u542f\u7528'));
card.style.setProperty('--accent',c);
d1.innerHTML='<span style="color:'+c+'">'+l+'</span>';
var info=(e.domain||'')+'\n';
if(e.last_ip)info+='A: '+e.last_ip+'\n';
if(e.last_ip6)info+='AAAA: '+e.last_ip6+'\n';
var sm=e.status_msg||'';
if(sm){
var dupA=e.last_ip&&sm.indexOf('A:'+e.last_ip)>=0;
var dupAAAA=e.last_ip6&&sm.indexOf('AAAA:'+e.last_ip6)>=0;
var onlyIpStatus=sm.replace(/A:[^;]+;?/g,'').replace(/AAAA:[^;]+;?/g,'').replace(/\s+/g,'')=='';
if(!(onlyIpStatus&&(dupA||dupAAAA)))info+=sm+'\n';
}
if(e.last_update)info+='\u66f4\u65b0: '+e.last_update+'\n';
if(e.next_sync)info+='\u4e0b\u6b21: '+e.next_sync;
if(d.ddns_list.length>1)info+='\n(+'+(d.ddns_list.length-1)+' \u6761\u8bb0\u5f55)';
d2.innerHTML=escHtml(info).replace(/\n/g,'<br>');
}else{d1.innerHTML='<span style="color:'+mgColor('muted')+'">\u672a\u914d\u7f6e</span>';d2.textContent='';card.style.setProperty('--accent',mgColor('muted'));}
var a1=document.getElementById('mg-a1'),a2=document.getElementById('mg-a2');
if(d.acme&&d.acme.enabled=='1'){
a1.innerHTML=d.acme.status=='ok'?'<span style="color:'+mgColor('info')+'">\u2713 \u6709\u6548</span>':'<span style="color:'+mgColor('danger')+'">'+escHtml(d.acme.status)+'</span>';
a2.innerHTML=escHtml(d.acme.last_domain||'')+(d.acme.cert_expiry?'<br>\u8fc7\u671f: '+escHtml(d.acme.cert_expiry):'');
}else{a1.innerHTML='<span style="color:'+mgColor('muted')+'">\u672a\u542f\u7528</span>';a2.textContent='';}
var p1=document.getElementById('mg-p1'),p2=document.getElementById('mg-p2');
p1.innerHTML=d.proxy_running?'<span style="color:'+mgColor('warning')+'">\u2713 \u8fd0\u884c\u4e2d</span>':'<span style="color:'+mgColor('muted')+'">\u5df2\u505c\u6b62</span>';
var pinfo='';
if(d.proxy_rules&&d.proxy_rules.length>0){
for(var i=0;i<d.proxy_rules.length;i++){
var r=d.proxy_rules[i];
var url=r.scheme+'://'+r.domain+(r.listen_port!='443'&&r.listen_port!='80'?':'+r.listen_port:'');
var v6tag=r.ipv6_listen=='1'?' <span style="color:'+mgColor('info')+';font-size:10px">[IPv6]</span>':'';
pinfo+='<div><span class="mg-link">'+escHtml(url)+'</span>'+v6tag+' \u2192 '+escHtml(r.target)+'</div>';
}}else if(d.proxy_running){pinfo='Nginx \u8fd0\u884c\u4e2d';}
p2.innerHTML=pinfo;
});
loadVisitors();
setInterval(loadVisitors,15000);
mgAutoCheckUpdate();
</script>
]] end
s = m:section(NamedSection, "global", "global", "全局设置")
s.anonymous = true
o = s:option(Flag, "enabled", "启用 MiniGate")
o.description = "总开关。关闭后停止 DDNS 定时任务、ACME 续期和反向代理。保存后立即生效。"
o.rmempty = false
o = s:option(Flag, "ipv6_listen", "反向代理监听 IPv6")
o.description = "开启后,Nginx 反向代理将同时监听 IPv4 和 IPv6 地址(listen [::]:port)。"
o.rmempty = false
o.default = "0"
return m
@@ -0,0 +1,451 @@
local m, s, o
local sys = require "luci.sys"
local fs = require "nixio.fs"
m = Map("minigate", "登录防护 (Login Guard)",
"监控 SSH(dropbear)和 LuCI 网页登录失败,达到阈值自动封禁源 IP。" ..
"适用于 SSH 端口被映射到公网、防爆破。LAN 私有 IP 自动豁免。")
m.on_after_commit = function(self)
sys.call("/etc/init.d/minigate reload >/dev/null 2>&1 &")
end
-- ============== 实时状态 ==============
s = m:section(NamedSection, "login_guard", "login_guard", "实时状态")
s.anonymous = true
local lu = luci.dispatcher.build_url("admin/services/minigate/lg_status")
local uu = luci.dispatcher.build_url("admin/services/minigate/lg_unban")
local bu = luci.dispatcher.build_url("admin/services/minigate/lg_ban")
local fu = luci.dispatcher.build_url("admin/services/minigate/lg_flush")
local gu = luci.dispatcher.build_url("admin/services/minigate/geo_lookup")
o = s:option(DummyValue, "_dash")
o.rawhtml = true
o.cfgvalue = function()
local function esc(v)
v = tostring(v or "")
v = v:gsub("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;")
v = v:gsub('"', "&quot;"):gsub("'", "&#39;")
return v
end
local function fmt_duration(s)
s = tonumber(s) or 0
if s >= 3600 then
return string.format("%dh%dm", math.floor(s / 3600), math.floor((s % 3600) / 60))
elseif s >= 60 then
return string.format("%dm%ds", math.floor(s / 60), s % 60)
end
return tostring(s) .. "s"
end
local threshold = tonumber(m.uci:get("minigate", "login_guard", "threshold")) or 3
local now = os.time()
local watching_rows = {}
local watching_items = {}
local counter_dir = "/var/run/minigate/login-guard/counters"
local list_out = sys.exec("ls " .. counter_dir .. " 2>/dev/null") or ""
for ip in list_out:gmatch("[^\n]+") do
local path = counter_dir .. "/" .. ip
local fh = io.open(path, "r")
if fh then
local line = fh:read("*l") or ""
fh:close()
local first, count = line:match("^(%d+)%s+(%d+)")
if first and count then
local stat = fs.stat(path)
local last_time = (stat and stat.mtime) or tonumber(first)
watching_items[#watching_items + 1] = {
ip = ip,
count = tonumber(count) or 0,
age = now - tonumber(first),
last_time = last_time,
last_seen = os.date("%Y-%m-%d %H:%M:%S", last_time)
}
end
end
end
table.sort(watching_items, function(a,b)
return (a.last_time or 0) > (b.last_time or 0)
end)
for i = 1, math.min(#watching_items, 30) do
local item = watching_items[i]
local style = (i > 5) and ' style="display:none"' or ''
watching_rows[#watching_rows + 1] =
'<tr class="lg-watch-row" data-ip="' .. esc(item.ip) .. '" data-index="' .. tostring(i) .. '"' .. style .. '><td><code class="lg-ip">' .. esc(item.ip) .. '</code></td>' ..
'<td id="lg-watch-geo-initial-' .. tostring(i) .. '"><span style="color:var(--lg-muted)">查询中...</span></td>' ..
'<td><span style="font-weight:bold">' .. esc(item.count) .. ' / ' .. esc(threshold) .. '</span></td>' ..
'<td>' .. esc(item.last_seen) .. '</td>' ..
'<td>' .. esc(fmt_duration(item.age)) .. '</td></tr>'
end
local initial_watching = '<div class="lg-empty">无</div>'
if #watching_rows > 0 then
initial_watching =
'<table class="lg-table"><thead><tr><th>IP 地址</th><th>归属地</th><th>失败次数</th><th>最近访问时间</th><th>距首次失败</th></tr></thead><tbody>' ..
table.concat(watching_rows, "") ..
'</tbody></table>'
end
return [[
<style>
.lg-wrap{display:flex;flex-direction:column;gap:16px;--lg-success:#2f9e44;--lg-warning:#f08c00;--lg-danger:#e03131;--lg-muted:#6b7280;--lg-primary:#5b55c8;--lg-surface:#fff;--lg-surface-muted:#f6f8fb;--lg-surface-alt:#f8fafc;--lg-surface-hover:#eef4ff;--lg-control:#fff;--lg-text:#1f2937;--lg-text-muted:#5f6b7a;--lg-border:#d7dce3;--lg-border-soft:#e6eaf0;--lg-code-bg:#e8edf6;--lg-danger-bg:#fee2e2;--lg-danger-text:#991b1b;--lg-shadow:0 10px 24px rgba(15,23,42,.08)}
.lg-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
.lg-card{position:relative;min-height:118px;border-radius:8px;padding:18px;background:var(--lg-surface);border:1px solid var(--lg-border);box-shadow:var(--lg-shadow);overflow:hidden}
.lg-card:before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--accent,#777)}
.lg-card-title{font-size:12px;color:var(--lg-text-muted);margin-bottom:14px}
.lg-card-value{font-size:22px;font-weight:700;line-height:1.25}
.lg-panel{border-radius:8px;background:var(--lg-surface);border:1px solid var(--lg-border);box-shadow:var(--lg-shadow);overflow:hidden}
.lg-panel-head{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;padding:14px 16px;border-bottom:1px solid var(--lg-border)}
.lg-panel-title{font-size:13px;font-weight:700;color:var(--lg-text)}
.lg-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.lg-input{height:30px;min-width:180px;border-radius:6px;border:1px solid var(--lg-border);background:var(--lg-control);color:var(--lg-text);padding:0 10px;font-size:12px;box-sizing:border-box}
.lg-select{height:30px;border-radius:6px;border:1px solid var(--lg-border);background:var(--lg-control);color:var(--lg-text);padding:0 8px;font-size:12px;box-sizing:border-box}
.lg-limit-label{display:flex;align-items:center;gap:6px;color:var(--lg-text-muted);font-size:12px;white-space:nowrap}
.lg-btn{height:30px;border:0;border-radius:6px;padding:0 12px;font-size:12px;color:#fff;cursor:pointer}
.lg-btn-primary{background:var(--lg-primary)}
.lg-btn-danger{background:var(--lg-danger)}
.lg-table-wrap{overflow-x:auto}
.lg-table{width:100%;border-collapse:collapse;min-width:720px}
.lg-table th{padding:10px 12px;color:var(--lg-text-muted);font-size:11px;font-weight:600;text-align:left;border-bottom:1px solid var(--lg-border);background:var(--lg-surface-muted)}
.lg-table td{padding:11px 12px;color:var(--lg-text);font-size:12px;border-bottom:1px solid var(--lg-border-soft);vertical-align:middle}
.lg-table tr:nth-child(even) td{background:var(--lg-surface-alt)}
.lg-table tr:hover td{background:var(--lg-surface-hover)}
.lg-ip{display:inline-block;border-radius:6px;background:var(--lg-code-bg);color:var(--lg-text);padding:2px 7px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px}
.lg-ip-danger{background:var(--lg-danger-bg);color:var(--lg-danger-text)}
.lg-empty{padding:18px 16px;color:var(--lg-text-muted);font-size:12px}
.lg-unban{height:26px;border:1px solid var(--lg-border);border-radius:6px;background:var(--lg-control);color:var(--lg-text);padding:0 10px;font-size:11px;cursor:pointer}
:root[data-darkmode="true"] .lg-wrap{--lg-success:#78c98d;--lg-warning:#d7a64a;--lg-danger:#e58a84;--lg-muted:#9aa7b3;--lg-primary:#8e8add;--lg-surface:var(--background-color-medium,#171e26);--lg-surface-muted:var(--background-color-high,#131a22);--lg-surface-alt:var(--background-color-low,#19212a);--lg-surface-hover:#202a34;--lg-control:var(--background-color-high,#111820);--lg-text:var(--text-color-high,#d5dbe3);--lg-text-muted:var(--text-color-medium,#9aa7b3);--lg-border:var(--border-color-medium,rgba(148,163,184,.18));--lg-border-soft:var(--border-color-low,rgba(148,163,184,.12));--lg-code-bg:var(--background-color-low,#253142);--lg-danger-bg:#42303a;--lg-danger-text:#efb2b0;--lg-shadow:none}
@media(prefers-color-scheme:dark){
:root:not([data-darkmode]) .lg-wrap{--lg-success:#78c98d;--lg-warning:#d7a64a;--lg-danger:#e58a84;--lg-muted:#9aa7b3;--lg-primary:#8e8add;--lg-surface:#171e26;--lg-surface-muted:#131a22;--lg-surface-alt:#19212a;--lg-surface-hover:#202a34;--lg-control:#111820;--lg-text:#d5dbe3;--lg-text-muted:#9aa7b3;--lg-border:rgba(148,163,184,.18);--lg-border-soft:rgba(148,163,184,.12);--lg-code-bg:#253142;--lg-danger-bg:#42303a;--lg-danger-text:#efb2b0;--lg-shadow:none}
}
@media(max-width:900px){.lg-summary{grid-template-columns:1fr}.lg-card{min-height:auto}.lg-input{width:100%;min-width:0}.lg-tools{width:100%}}
</style>
<div class="lg-wrap">
<div id="lg-summary" class="lg-summary">
<div class="lg-card" style="--accent:var(--lg-success)">
<div class="lg-card-title">服务状态</div>
<div id="lg-running" class="lg-card-value">--</div>
</div>
<div class="lg-card" style="--accent:var(--lg-danger)">
<div class="lg-card-title">当前已封禁</div>
<div id="lg-banned-count" class="lg-card-value" style="color:var(--lg-danger)">--</div>
</div>
<div class="lg-card" style="--accent:var(--lg-warning)">
<div class="lg-card-title">失败计数中</div>
<div id="lg-watching-count" class="lg-card-value" style="color:var(--lg-warning)">--</div>
</div>
</div>
<div class="lg-panel">
<div class="lg-panel-head">
<div class="lg-panel-title">已封禁 IP 列表</div>
<div class="lg-tools">
<label class="lg-limit-label">显示
<select id="lg-ban-limit" class="lg-select">
<option value="5">5 条</option>
<option value="20">20 条</option>
<option value="30">30 条</option>
</select>
</label>
<input id="lg-ban-input" class="lg-input" type="text" placeholder="输入 IP 手动封禁" />
<button class="lg-btn lg-btn-primary" onclick="lgManualBan()">封禁</button>
<button class="lg-btn lg-btn-danger" onclick="lgFlushAll()">清空全部</button>
</div>
</div>
<div id="lg-banned-list" class="lg-table-wrap"><div class="lg-empty">加载中...</div></div>
</div>
<div class="lg-panel">
<div class="lg-panel-head">
<div class="lg-panel-title">失败计数中(未达阈值)</div>
<div class="lg-tools">
<label class="lg-limit-label">显示
<select id="lg-watch-limit" class="lg-select">
<option value="5">5 条</option>
<option value="20">20 条</option>
<option value="30">30 条</option>
</select>
</label>
</div>
</div>
<div id="lg-watching-list" class="lg-table-wrap">]] .. initial_watching .. [[</div>
</div>
</div>
<script type="text/javascript">
var _lgGeoCache={};
var _lgGeoPending={};
var _lgBanLimit=5;
var _lgWatchLimit=5;
function lgEscHtml(s){
return String(s||'').replace(/[&<>"']/g,function(c){
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
function lgColor(name){
var root=document.querySelector('.lg-wrap');
if(!root)return'#999';
return getComputedStyle(root).getPropertyValue('--lg-'+name).trim()||'#999';
}
function lgQueryGeo(ip,cb){
if(_lgGeoCache[ip]){cb(_lgGeoCache[ip]);return;}
if(_lgGeoPending[ip]){
_lgGeoPending[ip].push(cb);
return;
}
_lgGeoPending[ip]=[cb];
XHR.get(']] .. gu .. [[',{ip:ip},function(x,d){
var loc=(d&&d.geo)?d.geo:'未知';
var list=_lgGeoPending[ip]||[];
delete _lgGeoPending[ip];
_lgGeoCache[ip]=loc;
for(var i=0;i<list.length;i++)list[i](loc);
});
}
function lgApplyInitialWatchingLimit(){
var rows=document.querySelectorAll('#lg-watching-list .lg-watch-row');
for(var i=0;i<rows.length;i++){
rows[i].style.display=(i<_lgWatchLimit)?'':'none';
}
}
function lgQueryInitialWatchingGeo(){
var rows=document.querySelectorAll('#lg-watching-list .lg-watch-row');
var qi=0;
function next(){
while(qi<rows.length && rows[qi].style.display=='none')qi++;
if(qi>=rows.length)return;
var row=rows[qi++];
var ip=row.getAttribute('data-ip')||'';
var idx=row.getAttribute('data-index')||'';
if(!ip){setTimeout(next,0);return;}
lgQueryGeo(ip,function(loc){
var ge=document.getElementById('lg-watch-geo-initial-'+idx);
if(ge)ge.innerHTML='<span style="font-size:11px">'+lgEscHtml(loc)+'</span>';
setTimeout(next,150);
});
}
next();
}
function fmtDuration(s){
if(s>=3600) return Math.floor(s/3600)+'h'+Math.floor((s%3600)/60)+'m';
if(s>=60) return Math.floor(s/60)+'m'+(s%60)+'s';
return s+'s';
}
function lgUnban(ip,btn){
if(!confirm('确认解封 '+ip+' ?'))return;
btn.disabled=true; btn.textContent='...';
XHR.get(']] .. uu .. [[',{ip:ip},function(x,d){
if(d&&d.success){lgRefresh();}
else{btn.disabled=false;btn.textContent='解封';alert('失败');}
});
}
function lgManualBan(){
var ip=document.getElementById('lg-ban-input').value.trim();
if(!ip){alert('请输入 IP');return;}
if(!ip.match(/^\d+\.\d+\.\d+\.\d+$/)){alert('IP 格式不对');return;}
XHR.get(']] .. bu .. [[',{ip:ip},function(x,d){
if(d&&d.success){
document.getElementById('lg-ban-input').value='';
lgRefresh();
}else alert('失败');
});
}
function lgFlushAll(){
if(!confirm('确认清空所有封禁?此操作不可撤销。'))return;
XHR.get(']] .. fu .. [[',null,function(x,d){
if(d&&d.success)lgRefresh();
});
}
function lgRenderWatching(watching,threshold){
var wEl=document.getElementById('lg-watching-list');
if(!wEl)return;
watching=watching||[];
threshold=Number(threshold)||3;
if(watching.length==0){
wEl.innerHTML='<div class="lg-empty">无</div>';
return;
}
var h2='<table class="lg-table">';
h2+='<thead><tr><th>IP 地址</th><th>归属地</th><th>失败次数</th><th>最近访问时间</th><th>距首次失败</th></tr></thead><tbody>';
for(var j=0;j<watching.length;j++){
var w=watching[j]||{};
var ip=w.ip||'--';
var count=Number(w.count)||0;
var age=Number(w.age)||0;
var lastSeen=w.last_seen||'--';
var pct=Math.min(100,Math.round(count*100/threshold));
var color=pct>=66?lgColor('danger'):(pct>=33?lgColor('warning'):lgColor('muted'));
h2+='<tr>';
h2+='<td><code class="lg-ip">'+lgEscHtml(ip)+'</code></td>';
h2+='<td id="lg-watch-geo-'+j+'"><span style="color:'+lgColor('muted')+'">查询中...</span></td>';
h2+='<td><span style="color:'+color+';font-weight:bold">'+count+' / '+threshold+'</span></td>';
h2+='<td>'+lgEscHtml(lastSeen)+'</td>';
h2+='<td>'+fmtDuration(age)+'</td>';
h2+='</tr>';
}
h2+='</tbody></table>';
wEl.innerHTML=h2;
// 与总览“访问记录”保持一致:逐个查询归属地,避免并发太多。
var qi=0;
function nextWatchingGeo(){
if(qi>=watching.length)return;
var idx=qi;qi++;
var row=watching[idx]||{};
if(!row.ip){
setTimeout(nextWatchingGeo,0);
return;
}
lgQueryGeo(row.ip,function(loc){
var ge=document.getElementById('lg-watch-geo-'+idx);
if(ge) ge.innerHTML='<span style="font-size:11px">'+lgEscHtml(loc)+'</span>';
setTimeout(nextWatchingGeo,150);
});
}
nextWatchingGeo();
}
function lgApplyStatus(d){
if(!d){
var wEl=document.getElementById('lg-watching-list');
if(wEl)wEl.innerHTML='<div class="lg-empty">刷新失败,请稍后再试</div>';
return;
}
// 服务状态
var rEl=document.getElementById('lg-running');
if(d.enabled=='1'){
if(d.running) rEl.innerHTML='<span style="color:'+lgColor('success')+'">✓ 运行中</span>';
else rEl.innerHTML='<span style="color:'+lgColor('danger')+'">✗ 未运行</span>';
}else{
rEl.innerHTML='<span style="color:'+lgColor('muted')+'">未启用</span>';
}
document.getElementById('lg-banned-count').textContent=(d.banned_total!=null)?d.banned_total:(d.banned||[]).length;
document.getElementById('lg-watching-count').textContent=(d.watching_total!=null)?d.watching_total:(d.watching||[]).length;
// 已封禁列表
var listEl=document.getElementById('lg-banned-list');
if(!d.banned||d.banned.length==0){
listEl.innerHTML='<div class="lg-empty">暂无封禁</div>';
}else{
var h='<table class="lg-table">';
h+='<thead><tr><th>IP 地址</th><th>归属地</th><th>剩余时间</th><th>操作</th></tr></thead><tbody>';
for(var i=0;i<d.banned.length;i++){
var b=d.banned[i];
h+='<tr>';
h+='<td><code class="lg-ip lg-ip-danger">'+lgEscHtml(b.ip)+'</code></td>';
h+='<td id="lg-geo-'+i+'"><span style="color:'+lgColor('muted')+'">查询中...</span></td>';
h+='<td>'+fmtDuration(b.remaining)+'</td>';
h+='<td><button class="lg-unban" onclick="lgUnban(\''+b.ip+'\',this)">解封</button></td>';
h+='</tr>';
}
h+='</tbody></table>';
listEl.innerHTML=h;
// 异步查归属地
var qi=0;
(function next(){
if(qi>=d.banned.length)return;
var idx=qi; qi++;
lgQueryGeo(d.banned[idx].ip,function(loc){
var ge=document.getElementById('lg-geo-'+idx);
if(ge) ge.innerHTML='<span style="font-size:11px">'+lgEscHtml(loc)+'</span>';
setTimeout(next,150);
});
})();
}
// 失败计数列表
try{lgRenderWatching(d.watching,d.threshold);}
catch(e){
var wEl=document.getElementById('lg-watching-list');
if(wEl)wEl.innerHTML='<div class="lg-empty">渲染失败:'+lgEscHtml(e.message)+'</div>';
}
}
function lgRefresh(){
XHR.get(']] .. lu .. [[',{ban_limit:_lgBanLimit,watch_limit:_lgWatchLimit},function(x,d){
lgApplyStatus(d);
});
}
var lgBanLimitSel=document.getElementById('lg-ban-limit');
if(lgBanLimitSel){
lgBanLimitSel.value=String(_lgBanLimit);
lgBanLimitSel.onchange=function(){
var v=parseInt(this.value,10);
_lgBanLimit=(v==20||v==30)?v:5;
lgRefresh();
};
}
var lgWatchLimitSel=document.getElementById('lg-watch-limit');
if(lgWatchLimitSel){
lgWatchLimitSel.value=String(_lgWatchLimit);
lgWatchLimitSel.onchange=function(){
var v=parseInt(this.value,10);
_lgWatchLimit=(v==20||v==30)?v:5;
lgApplyInitialWatchingLimit();
lgQueryInitialWatchingGeo();
lgRefresh();
};
}
lgApplyInitialWatchingLimit();
lgQueryInitialWatchingGeo();
lgRefresh();
setInterval(lgRefresh,8000);
</script>
]]
end
-- ============== 设置 ==============
s = m:section(NamedSection, "login_guard", "login_guard", "设置")
s.anonymous = true
o = s:option(Flag, "enabled", "启用登录防护")
o.description = "开启后将自动监控 SSH/LuCI 失败登录。LAN IP 永远不会被封禁。"
o.rmempty = false
o = s:option(Value, "threshold", "失败次数阈值")
o.description = "在「失败窗口」时间内累积达到此次数则封禁。"
o.datatype = "uinteger"
o.default = "3"
o.placeholder = "3"
o = s:option(ListValue, "window", "失败窗口(秒)")
o.description = "在多长时间内累计失败次数。超过此时间未再失败则计数清零。"
o:value("300", "5 分钟")
o:value("600", "10 分钟")
o:value("1800", "30 分钟")
o:value("3600", "1 小时")
o:value("86400", "24 小时")
o.default = "600"
o = s:option(ListValue, "bantime", "封禁时长")
o:value("3600", "1 小时")
o:value("21600", "6 小时")
o:value("43200", "12 小时")
o:value("86400", "24 小时")
o:value("604800", "1 周")
o:value("2592000", "30 天")
o.default = "43200"
o = s:option(DynamicList, "whitelist", "白名单(额外)")
o.description = "除了 LAN 私有段(192.168.x、10.x、127.x、172.16-31.x)默认豁免外,再加这些 IP/CIDR。" ..
"支持单 IP(如 1.2.3.4)或 /8 /16 /24 /32 子网。"
o.placeholder = "如 8.8.8.8 或 203.0.113.0/24"
return m
@@ -0,0 +1,174 @@
local m, s, o
local sys = require "luci.sys"
local uc = require "luci.model.uci".cursor()
m = Map("minigate", "MiniGate - 反向代理", "保存后自动生效。")
m.on_after_commit = function(self)
sys.call("sleep 1 && /bin/sh /usr/lib/minigate/proxy.sh reload >/dev/null 2>&1 &")
end
local ddns_domains = {}
local ddns_ok = {}
local wildcard_domains = {}
local normal_domains = {}
uc:foreach("minigate", "ddns", function(sec)
local d = sec.domain or ""
local st = sec.status or ""
if d ~= "" then
ddns_domains[#ddns_domains + 1] = d
if st == "ok" then ddns_ok[d] = true end
if d:match("^%*%.") then
wildcard_domains[#wildcard_domains + 1] = d
else
normal_domains[#normal_domains + 1] = d
end
end
end)
s = m:section(NamedSection, "global", "global", "HTTP 自动跳转",
"让每个已启用 HTTPS 的监听端口同时接受 HTTP;明文请求会以 308 跳转到同域名、同端口的 HTTPS。")
s.anonymous = true
o = s:option(Flag, "http_redirect", "启用 HTTP 跳转")
o.default = "0"
o.rmempty = false
-- ================================
-- 通配符域名
-- ================================
if #wildcard_domains > 0 then
s = m:section(TypedSection, "proxy_wildcard", "通配符域名",
"配置监听端口和HTTPS,具体转发由「子域名」控制。")
s.anonymous = false
s.addremove = true
s.template = "cbi/tblsection"
s.sectionhead = "名称"
o = s:option(ListValue, "enabled", "启用")
o:value("1", "✓ 启用")
o:value("0", "✗ 禁用")
o.default = "1"
o.rmempty = false
o = s:option(ListValue, "domain", "域名")
o:value("", "-- 请选择 --")
for _, d in ipairs(wildcard_domains) do
local label = d
if ddns_ok[d] then label = d .. " ✓" end
o:value(d, label)
end
o.rmempty = false
o = s:option(Value, "listen_port", "监听端口")
o.datatype = "port"
o.default = "2000"
o.rmempty = false
o = s:option(ListValue, "ssl", "HTTPS")
o:value("1", "✓ 开启")
o:value("0", "✗ 关闭")
o.default = "1"
o.rmempty = false
-- ================================
-- 子域名规则
-- ================================
s = m:section(TypedSection, "subproxy", "子域名规则")
s.anonymous = true
s.addremove = true
s.sortable = true
s.template = "cbi/tblsection"
o = s:option(ListValue, "parent_domain", "主域名")
o:value("", "--")
for _, d in ipairs(wildcard_domains) do
local base = d:gsub("^%*%.", "")
o:value(base, d)
end
o.rmempty = false
o = s:option(Value, "prefix", "前缀")
o.rmempty = false
o.placeholder = "app"
o = s:option(Value, "target_addr", "目标地址")
o.rmempty = false
o.placeholder = "192.168.1.100"
o = s:option(Value, "target_port", "端口")
o.datatype = "port"
o.default = "80"
o.rmempty = false
o = s:option(DummyValue, "_final", "实际域名")
o.rawhtml = true
o.cfgvalue = function(self, section)
local base = m.uci:get("minigate", section, "parent_domain") or ""
local prefix = m.uci:get("minigate", section, "prefix") or ""
if prefix ~= "" and base ~= "" then
return '<strong style="color:#4caf50">' .. prefix .. '.' .. base .. '</strong>'
end
return '<span style="color:#999">--</span>'
end
end -- wildcard_domains
-- ================================
-- 普通域名代理
-- ================================
if #normal_domains > 0 or #wildcard_domains == 0 then
s = m:section(TypedSection, "proxy", "代理规则",
"非通配符域名的反向代理。")
s.anonymous = false
s.addremove = true
s.template = "cbi/tblsection"
s.sortable = true
s.sectionhead = "名称"
o = s:option(ListValue, "enabled", "启用")
o:value("1", "✓ 启用")
o:value("0", "✗ 禁用")
o.default = "1"
o.rmempty = false
o = s:option(ListValue, "domain", "域名")
o:value("", "-- 请选择 --")
for _, d in ipairs(normal_domains) do
local label = d
if ddns_ok[d] then label = d .. " ✓" end
o:value(d, label)
end
o.rmempty = false
o = s:option(Value, "listen_port", "端口")
o.datatype = "port"
o.default = "443"
o.rmempty = false
o = s:option(Value, "target_addr", "目标地址")
o.rmempty = false
o.placeholder = "192.168.1.100"
o = s:option(Value, "target_port", "目标端口")
o.datatype = "port"
o.default = "80"
o.rmempty = false
o = s:option(ListValue, "ssl", "HTTPS")
o:value("1", "✓ 开启")
o:value("0", "✗ 关闭")
o.default = "1"
o.rmempty = false
o = s:option(ListValue, "websocket", "WS")
o:value("0", "✗ 关闭")
o:value("1", "✓ 开启")
o.default = "0"
o.rmempty = false
end -- normal_domains
return m
@@ -0,0 +1,185 @@
<% local history_url = luci.dispatcher.build_url("admin", "services", "minigate", "ddns_history") %>
<style>
.mg-ddns-history{--mh-surface:#fff;--mh-surface-muted:#f6f8fb;--mh-surface-alt:#f8fafc;--mh-hover:#eef4ff;--mh-control:#fff;--mh-text:#1f2937;--mh-muted:#5f6b7a;--mh-border:#d7dce3;--mh-border-soft:#e6eaf0;--mh-code:#e8edf6;--mh-info:#1971c2;--mh-success:#2f9e44;margin-top:18px;padding:0!important;border:1px solid var(--mh-border);border-radius:8px;background:var(--mh-surface);overflow:hidden}
.mg-history-head{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:14px 16px;border-bottom:1px solid var(--mh-border)}
.mg-history-title{margin:0;color:var(--mh-text);font-size:14px;font-weight:700;line-height:1.4}
.mg-history-controls{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.mg-history-label{color:var(--mh-muted);font-size:12px;white-space:nowrap}
.mg-history-select{height:32px;max-width:220px;border:1px solid var(--mh-border);border-radius:6px;background:var(--mh-control);color:var(--mh-text);padding:0 28px 0 9px;font-size:12px}
.mg-history-refresh{min-width:64px;height:32px;border-radius:6px!important;font-size:12px!important;cursor:pointer}
.mg-history-refresh:disabled{cursor:wait;opacity:.65}
.mg-history-summary{padding:9px 16px;border-bottom:1px solid var(--mh-border-soft);background:var(--mh-surface-muted);color:var(--mh-muted);font-size:12px}
.mg-history-table-wrap{overflow-x:auto}
.mg-history-table{width:100%;min-width:820px;border-collapse:collapse}
.mg-history-table th{padding:10px 12px;border-bottom:1px solid var(--mh-border);background:var(--mh-surface-muted);color:var(--mh-muted);font-size:11px;font-weight:600;text-align:left;white-space:nowrap}
.mg-history-table td{padding:11px 12px;border-bottom:1px solid var(--mh-border-soft);color:var(--mh-text);font-size:12px;vertical-align:middle}
.mg-history-table tbody tr:nth-child(even) td{background:var(--mh-surface-alt)}
.mg-history-table tbody tr:hover td{background:var(--mh-hover)}
.mg-history-table tbody tr:last-child td{border-bottom:0}
.mg-history-domain{font-weight:600;word-break:break-all}
.mg-history-family{display:inline-flex;align-items:center;min-width:42px;padding:2px 7px;border-radius:999px;background:rgba(25,113,194,.12);color:var(--mh-info);font-size:11px;font-weight:700;justify-content:center}
.mg-history-family.ipv6{background:rgba(47,158,68,.12);color:var(--mh-success)}
.mg-history-ip{display:inline-block;padding:3px 7px;border-radius:6px;background:var(--mh-code);color:var(--mh-text);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;white-space:nowrap}
.mg-history-active{color:var(--mh-success);font-weight:600;white-space:nowrap}
.mg-history-empty{padding:24px 16px;color:var(--mh-muted);font-size:12px;text-align:center}
:root[data-darkmode="true"] .mg-ddns-history{--mh-surface:var(--background-color-medium,#171e26);--mh-surface-muted:var(--background-color-high,#131a22);--mh-surface-alt:var(--background-color-low,#19212a);--mh-hover:#202a34;--mh-control:var(--background-color-high,#111820);--mh-text:var(--text-color-high,#d5dbe3);--mh-muted:var(--text-color-medium,#9aa7b3);--mh-border:var(--border-color-medium,rgba(148,163,184,.18));--mh-border-soft:var(--border-color-low,rgba(148,163,184,.12));--mh-code:#253142;--mh-info:#7db7df;--mh-success:#78c98d}
@media(prefers-color-scheme:dark){
:root:not([data-darkmode]) .mg-ddns-history{--mh-surface:#171e26;--mh-surface-muted:#131a22;--mh-surface-alt:#19212a;--mh-hover:#202a34;--mh-control:#111820;--mh-text:#d5dbe3;--mh-muted:#9aa7b3;--mh-border:rgba(148,163,184,.18);--mh-border-soft:rgba(148,163,184,.12);--mh-code:#253142;--mh-info:#7db7df;--mh-success:#78c98d}
}
@media(max-width:640px){
.mg-history-head{align-items:flex-start;flex-direction:column}
.mg-history-controls{width:100%}
.mg-history-select{min-width:0;max-width:none;flex:1}
.mg-history-table-wrap{overflow:visible}
.mg-history-table{min-width:0}
.mg-history-table thead{display:none}
.mg-history-table tbody tr{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:5px 12px;padding:12px 14px;border-bottom:1px solid var(--mh-border-soft)}
.mg-history-table tbody tr:nth-child(even){background:var(--mh-surface-alt)}
.mg-history-table tbody tr:last-child{border-bottom:0}
.mg-history-table td{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:2px 0;border:0!important;background:transparent!important;min-width:0}
.mg-history-table td:nth-child(1){justify-content:flex-start;font-size:13px}
.mg-history-table td:nth-child(2){justify-content:flex-end}
.mg-history-table td:nth-child(n+3){grid-column:1/-1}
.mg-history-table td:nth-child(3){justify-content:flex-start;margin:2px 0 4px}
.mg-history-table td:nth-child(4):before{content:"开始时间";color:var(--mh-muted)}
.mg-history-table td:nth-child(5):before{content:"结束时间";color:var(--mh-muted)}
.mg-history-table td:nth-child(6):before{content:"持续时间";color:var(--mh-muted)}
.mg-history-ip{max-width:100%;white-space:normal;overflow-wrap:anywhere}
}
</style>
<div class="cbi-section mg-ddns-history" id="mg-ddns-history">
<div class="mg-history-head">
<h3 class="mg-history-title">最近一天 IP 变化明细</h3>
<div class="mg-history-controls">
<label class="mg-history-label" for="mg-history-filter">DDNS 记录</label>
<select class="mg-history-select" id="mg-history-filter">
<option value="">全部记录</option>
</select>
<button class="cbi-button cbi-button-neutral mg-history-refresh" id="mg-history-refresh" type="button">刷新</button>
</div>
</div>
<div class="mg-history-summary" id="mg-history-summary" aria-live="polite">正在读取...</div>
<div class="mg-history-table-wrap" id="mg-history-table-wrap" hidden>
<table class="mg-history-table" aria-label="最近一天 IP 变化明细">
<thead>
<tr>
<th>DDNS 记录</th>
<th>类型</th>
<th>IP 地址</th>
<th>开始时间</th>
<th>结束时间</th>
<th>持续时间</th>
</tr>
</thead>
<tbody id="mg-history-body"></tbody>
</table>
</div>
<div class="mg-history-empty" id="mg-history-empty" hidden>暂无 IP 变化记录</div>
</div>
<script type="text/javascript">
(function(){
'use strict';
var endpoint='<%=history_url%>';
var records=[];
var filter=document.getElementById('mg-history-filter');
var refresh=document.getElementById('mg-history-refresh');
var summary=document.getElementById('mg-history-summary');
var tableWrap=document.getElementById('mg-history-table-wrap');
var body=document.getElementById('mg-history-body');
var empty=document.getElementById('mg-history-empty');
function asArray(value){
if(Array.isArray(value))return value;
if(value&&typeof value==='object'&&Object.keys(value).length===0)return [];
return null;
}
function formatTime(timestamp){
if(!timestamp)return '-';
return new Date(timestamp*1000).toLocaleString('zh-CN',{hour12:false});
}
function formatDuration(seconds){
seconds=Math.max(0,Number(seconds)||0);
var days=Math.floor(seconds/86400);
var hours=Math.floor((seconds%86400)/3600);
var minutes=Math.floor((seconds%3600)/60);
if(days>0)return days+'天 '+hours+'小时';
if(hours>0)return hours+'小时 '+minutes+'分钟';
if(minutes>0)return minutes+'分钟';
return Math.floor(seconds)+'秒';
}
function cell(row,text,className){
var td=document.createElement('td');
var value=document.createElement(className==='mg-history-ip'?'code':'span');
if(className)value.className=className;
value.textContent=text;
td.appendChild(value);
row.appendChild(td);
}
function render(){
var selected=filter.value;
var visible=records.filter(function(item){return !selected||item.section===selected});
body.textContent='';
visible.forEach(function(item){
var row=document.createElement('tr');
cell(row,item.domain,'mg-history-domain');
cell(row,item.family==='ipv6'?'IPv6':'IPv4','mg-history-family '+(item.family==='ipv6'?'ipv6':''));
cell(row,item.ip,'mg-history-ip');
cell(row,formatTime(item.start_time),'');
cell(row,item.active?'使用中':formatTime(item.end_time),item.active?'mg-history-active':'');
cell(row,formatDuration(item.duration),'');
body.appendChild(row);
});
summary.textContent='最近 24 小时 · '+visible.length+' 段 IP 记录';
tableWrap.hidden=visible.length===0;
empty.hidden=visible.length!==0;
}
function setSections(sections){
var selected=filter.value;
while(filter.options.length>1)filter.remove(1);
(sections||[]).forEach(function(section){
if(!section.enabled)return;
var option=document.createElement('option');
option.value=section.name;
option.textContent=section.domain;
filter.appendChild(option);
});
filter.value=selected;
}
function loadHistory(){
refresh.disabled=true;
refresh.textContent='读取中';
summary.textContent='正在读取...';
XHR.get(endpoint,null,function(xhr,data){
var nextRecords=asArray(data&&data.records);
var nextSections=asArray(data&&data.sections);
refresh.disabled=false;
refresh.textContent='刷新';
if(!data||data.success!==true||nextRecords===null){
records=[];
summary.textContent='读取失败,请稍后重试';
tableWrap.hidden=true;
empty.hidden=false;
empty.textContent='无法读取 IP 变化明细';
return;
}
empty.textContent='暂无 IP 变化记录';
records=nextRecords;
setSections(nextSections||[]);
render();
});
}
filter.addEventListener('change',render);
refresh.addEventListener('click',loadHistory);
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',loadHistory);
else loadHistory();
})();
</script>
+21
View File
@@ -0,0 +1,21 @@
<%+header%>
<h2>MiniGate - 日志</h2>
<div style="margin-bottom:15px">
<select id="ls" style="padding:5px 10px;border-radius:4px;border:1px solid #ccc">
<option value="all">全部</option><option value="ddns">DDNS</option><option value="acme">ACME</option><option value="proxy">代理</option>
</select>
<button class="cbi-button cbi-button-action" onclick="rl()" style="margin-left:10px">刷新</button>
<label style="margin-left:15px"><input type="checkbox" id="ar" checked> 自动刷新(5秒)</label>
</div>
<pre id="lc" style="background:#1e1e2e;color:#cdd6f4;font-family:monospace;font-size:12px;line-height:1.5;padding:15px;border-radius:8px;max-height:600px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;margin:0">加载中...</pre>
<script type="text/javascript">
var lt;
var lu='<%=luci.dispatcher.build_url("admin/services/minigate/get_log")%>';
function rl(){var s=document.getElementById('ls').value;var c=document.getElementById('lc');
XHR.get(lu,{source:s},function(x,d){if(d&&d.log){c.textContent=d.log||'(暂无)';c.scrollTop=c.scrollHeight}else c.textContent='(无法读取)'})}
function sa(){if(lt)clearInterval(lt);if(document.getElementById('ar').checked)lt=setInterval(rl,5000)}
document.getElementById('ar').addEventListener('change',sa);
document.getElementById('ls').addEventListener('change',rl);
rl();sa();
</script>
<%+footer%>