🛸 Sync 2026-10-27 02:27:08

This commit is contained in:
github-actions[bot] committed 2026-10-27 02:27:08 +08:00
1 parent b3798acf79
commit 8b63d5d4a9
78 files changed
+2288 -518

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=minigate
PKG_VERSION:=2026.10.25
PKG_RELEASE:=6
PKG_VERSION:=2026.10.26
PKG_RELEASE:=7
PKG_LICENSE:=MIT
PKG_MAINTAINER:=MiniGate
+22 -17
View File
@@ -2,9 +2,9 @@
一个类似 Lucky 的轻量级 OpenWrt 应用,提供四大核心功能:DDNS、SSL 证书、反向代理、登录防护。
当前版本:**v2026.10.25**。项目名称、仓库名及 OpenWrt 内部包名均为 **`minigate`**。
当前版本:**v2026.10.26**。项目名称、仓库名及 OpenWrt 内部包名均为 **`minigate`**。
> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.25_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。
> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.26_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。
## 项目定位
@@ -74,8 +74,8 @@ minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户
- 后台 watchdog 每 5 分钟自检 nft set,防 fw4 reload 后规则丢失
### 👁 访客追踪
- 总览页面实时显示访客 IP、归属地、在线状态
- 5 分钟内有访问记为「在线」(绿点),否则「离线」(灰点)
- 总览页面显示来源 IP、归属地、最近请求结果、HTTP 状态及转发/拦截次数;被拒绝的扫描也保留在记录中
- 「近5分钟有请求」仅表示近期出现请求,不代表访客在线;已转发也不代表已登录。旧日志缺少上游响应信息时,只能标记为已响应、请求失败或结果未知,不推断为准入放行
- IP 归属地后端查询(ip9.com.cn → ip-api.com → pconline 多源回退)
### 软件更新
@@ -93,20 +93,20 @@ minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户
从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载:
`minigate_2026.10.25_all.ipk`
`minigate_2026.10.26_all.ipk`
**通过 LuCI 界面安装:**
1. 打开 LuCI → **系统** → **软件包**
2. 点击 **上传软件包**
3. 选择 `minigate_2026.10.25_all.ipk`,点击安装
3. 选择 `minigate_2026.10.26_all.ipk`,点击安装
**通过命令行安装:**
```bash
cd /tmp
wget -O minigate_2026.10.25_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.25/minigate_2026.10.25_all.ipk
wget -O minigate_2026.10.26_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate_2026.10.26_all.ipk
opkg update
opkg install /tmp/minigate_2026.10.25_all.ipk
opkg install /tmp/minigate_2026.10.26_all.ipk
rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
/etc/init.d/uhttpd restart
/etc/init.d/minigate restart
@@ -125,7 +125,7 @@ Release 用的 `.ipk` 请用仓库脚本生成,避免生成 Debian 风格 ar
脚本会输出:
```text
dist/minigate_2026.10.25_all.ipk
dist/minigate_2026.10.26_all.ipk
```
该文件外层是 OpenWrt/ImmortalWrt 24.10 兼容的 `tar.gz`,内部成员顺序为 `debian-binary`、`control.tar.gz`、`data.tar.gz`。
@@ -153,7 +153,7 @@ wget -qO- https://raw.githubusercontent.com/tpxcer/minigate/main/scripts/install
需要指定并核对具体版本时,执行:
```bash
d="$(mktemp -d /tmp/minigate.XXXXXX)" && cd "$d" && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.25/minigate-v2026.10.25-src.tar.gz && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.25/SHA256SUMS && grep 'minigate-v2026.10.25-src.tar.gz$' SHA256SUMS | sha256sum -c - && tar xzf minigate-v2026.10.25-src.tar.gz && sh install.sh && /etc/init.d/uhttpd restart && /etc/init.d/minigate restart
d="$(mktemp -d /tmp/minigate.XXXXXX)" && cd "$d" && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate-v2026.10.26-src.tar.gz && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.26/SHA256SUMS && grep 'minigate-v2026.10.26-src.tar.gz$' SHA256SUMS | sha256sum -c - && tar xzf minigate-v2026.10.26-src.tar.gz && sh install.sh && /etc/init.d/uhttpd restart && /etc/init.d/minigate restart
```
其中 `minigate.XXXXXX` 是临时目录模板,系统会自动把六个 `X` 替换为随机字符,避免与已有目录重名。该命令会校验源码包的 SHA-256、保留现有 minigate 配置并重启 LuCI 与 minigate 服务。
@@ -168,14 +168,14 @@ ssh root@192.168.1.1
# 2. 在路由器上直接下载 Release 源码包
cd /tmp
wget -O minigate-src.tar.gz https://github.com/tpxcer/minigate/releases/download/v2026.10.25/minigate-v2026.10.25-src.tar.gz
wget -O minigate-SHA256SUMS https://github.com/tpxcer/minigate/releases/download/v2026.10.25/SHA256SUMS
awk '$2 == "minigate-v2026.10.25-src.tar.gz" {print $1 " minigate-src.tar.gz"}' minigate-SHA256SUMS | sha256sum -c - || exit 1
wget -O minigate-src.tar.gz https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate-v2026.10.26-src.tar.gz
wget -O minigate-SHA256SUMS https://github.com/tpxcer/minigate/releases/download/v2026.10.26/SHA256SUMS
awk '$2 == "minigate-v2026.10.26-src.tar.gz" {print $1 " minigate-src.tar.gz"}' minigate-SHA256SUMS | sha256sum -c - || exit 1
# 3. 解压到独立目录并安装
mkdir -p /tmp/minigate-v2026.10.25
tar xzf minigate-src.tar.gz -C /tmp/minigate-v2026.10.25
cd /tmp/minigate-v2026.10.25
mkdir -p /tmp/minigate-v2026.10.26
tar xzf minigate-src.tar.gz -C /tmp/minigate-v2026.10.26
cd /tmp/minigate-v2026.10.26
sh install.sh
# 4. 启动服务
@@ -225,7 +225,7 @@ SSH 登录后执行上方「方法 2」的一条命令,保留原有配置并
### IPK 升级
```bash
opkg install --force-reinstall /tmp/minigate_2026.10.25_all.ipk
opkg install --force-reinstall /tmp/minigate_2026.10.26_all.ipk
rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
/etc/init.d/minigate restart
```
@@ -408,6 +408,11 @@ minigate/
## 更新日志
### v2026.10.26
- 访问记录区分「已拦截、已转发、已响应、请求失败、结果未知」,显示最近请求的 HTTP 状态与拦截/转发/其他次数
- 「在线/离线」改为近期请求说明,避免把被拒绝的外地扫描误认为访问成功;保留拦截记录便于核查
- 新日志记录上游响应,后端 401/403 不误判为地区拦截;兼容旧日志,缺少信息时不推断转发或登录成功
### v2026.10.25
- 新增可配置的「公网访问范围」:选择一个或多个省级地区,仅允许匹配的公网 IP 访问所有反代站点,其他及未知来源在首次转发前直接断开
- 同时检查 IPv4、IPv6,覆盖 HTTP 跳转与 WebSocket;保留局域网访问,不修改防火墙或限制 SSH、RustDesk
+33 -14
View File
@@ -373,24 +373,38 @@ function action_proxy_access()
end
-- 读取最近 500 条日志,按 IP 聚合
local raw = sys.exec("cat /var/log/minigate-access.log.1 /var/log/minigate-access.log 2>/dev/null | tail -n 500") or ""
local visitors = {} -- ip -> {last_time, domain, count, first_time}
local visitors = {}
local order = {} -- 保持顺序
for line in raw:gmatch("[^\n]+") do
local time = line:match('"time":"([^"]*)"')
local domain = line:match('"domain":"([^"]*)"')
local client = line:match('"client":"([^"]*)"')
local status = tonumber(line:match('"status"%s*:%s*(%d+)'))
local upstream = line:match('"upstream_status":"([^"]*)"')
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
local outcome = "unknown"
if status == 444 or (status == 400 and domain == "_") then
outcome = "blocked"
elseif status and (status >= 500 or status == 499) then
outcome = "error"
elseif status and upstream and upstream ~= "" and upstream ~= "-" then
outcome = "forwarded"
elseif status and status >= 100 and status < 400 then
outcome = "responded"
elseif status and status >= 400 then
outcome = "error"
end
if not visitors[client] then
visitors[client] = { count = 0, forwarded_count = 0, blocked_count = 0, other_count = 0 }
order[#order + 1] = client
end
local v = visitors[client]
v.last_time, v.domain, v.status, v.outcome = time, domain or "", status, outcome
v.count = v.count + 1
local counter = outcome == "forwarded" and "forwarded_count" or
(outcome == "blocked" and "blocked_count" or "other_count")
v[counter] = v[counter] + 1
end
end
@@ -405,19 +419,24 @@ function action_proxy_access()
for i = 1, math.min(#order, limit) do
local ip = order[i]
local v = visitors[ip]
-- 解析 ISO 时间判断是否在线(5分钟内)
local online = false
-- 仅表示最近有请求,不能据此推断访客在线或已通过准入。
local recent = 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
recent = now >= ts and (now - ts) < 300
end
result[#result + 1] = {
ip = ip,
last_time = v.last_time,
domain = v.domain,
count = v.count,
online = online
recent = recent,
outcome = v.outcome,
status = v.status,
forwarded_count = v.forwarded_count,
blocked_count = v.blocked_count,
other_count = v.other_count
}
end
+17 -12
View File
@@ -57,8 +57,8 @@ o.cfgvalue = function()
.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-request-result{display:flex;flex-direction:column;gap:4px}
.mg-request-detail{font-size:11px;color:var(--mg-text-muted);line-height:1.5;white-space:normal}
.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}
@@ -105,9 +105,9 @@ o.cfgvalue = function()
.mg-table td:nth-child(1){order:2;justify-content:flex-end}
.mg-table td:nth-child(n+3){order:3;grid-column:1/-1}
.mg-table td:nth-child(3):before{content:"归属地"}
.mg-table td:nth-child(4):before{content:"最后访问"}
.mg-table td:nth-child(4):before{content:"最后请求"}
.mg-table td:nth-child(5):before{content:"域名"}
.mg-table td:nth-child(6):before{content:"次数"}
.mg-table td:nth-child(6):before{content:"请求次数"}
.mg-table td:before{flex:0 0 auto;color:var(--mg-text-muted)}
}
</style>
@@ -173,6 +173,7 @@ o.cfgvalue = function()
<option value="50">50</option>
</select>条</label>
</div>
<div class="mg-request-detail" style="padding:10px 16px">包含被拦截的请求;结果对应最后一次请求,次数为最近日志内按 IP 汇总。已转发不代表已登录。</div>
<div id="mg-v-list" class="mg-table-wrap"><div class="mg-empty">加载中...</div></div>
</div>
@@ -286,20 +287,24 @@ function loadVisitors(force){
}
ct.textContent='('+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>';
h+='<thead><tr><th>最近结果</th><th>IP 地址</th><th>归属地</th><th>最后请求</th><th>域名</th><th>请求次数</th></tr></thead><tbody>';
for(var i=0;i<visitors.length;i++){
var v=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>';
var outcomes={blocked:['已拦截','danger'],forwarded:['已转发','success'],responded:['已响应','info'],error:['请求失败','warning']};
var outcome=outcomes[v.outcome]||['结果未知','muted'];
var status=typeof v.status==='number'?('HTTP '+v.status):'无状态码';
var activity=typeof v.recent==='boolean'?(v.recent?'近5分钟有请求':'5分钟内无新请求'):'请求时间未知';
h+='<tr>';
h+='<td><span class="mg-status">'+dot+stxt+'</span></td>';
h+='<td><div class="mg-request-result"><span class="mg-status" style="color:'+mgColor(outcome[1])+'">'+outcome[0]+'</span><span class="mg-request-detail">'+escHtml(status)+'</span></div></td>';
h+='<td><code class="mg-ip">'+escHtml(v.ip)+'</code></td>';
h+='<td id="geo-'+i+'">'+mgGeoHtml(_geoCache[v.ip])+'</td>';
h+='<td style="white-space:nowrap">'+escHtml(fmtT(v.last_time))+'</td>';
h+='<td style="white-space:nowrap"><div class="mg-request-result"><span>'+escHtml(fmtT(v.last_time))+'</span><span class="mg-request-detail">'+activity+'</span></div></td>';
h+='<td>'+escHtml(v.domain)+'</td>';
h+='<td>'+escHtml(v.count)+'</td>';
h+='<td><div class="mg-request-result"><span>总计 '+escHtml(String(v.count))+'</span>';
if(typeof v.blocked_count==='number'&&typeof v.forwarded_count==='number'&&typeof v.other_count==='number'){
h+='<span class="mg-request-detail">拦截 '+escHtml(String(v.blocked_count))+' · 转发 '+escHtml(String(v.forwarded_count))+' · 其他 '+escHtml(String(v.other_count))+'</span>';
}
h+='</div></td>';
h+='</tr>';
}
h+='</tbody></table>';
+24
View File
@@ -0,0 +1,24 @@
## 访问记录结果区分
修复仅允许四川访问时,其他地区的被拒请求仍显示为“在线”、容易被误认为访问成功的问题。
- 访问记录显示最近一次请求结果:**已拦截、已转发、已响应、请求失败、结果未知**,同时显示 HTTP 状态码。
- 按 IP 汇总最近 500 条日志中的总次数及拦截、转发、其他次数,保留被拦截的请求供核查;每行结果对应该 IP 最后一次请求。
- 用“近5分钟有请求”替代“在线/离线”;近期出现请求不代表通过准入,已转发不代表已登录。
- 新日志记录上游响应信息,后端 401/403 显示为已转发,不误判为地区拦截;5xx、499 显示请求失败并计入其他。
- 旧日志继续可读,缺少上游信息的成功响应只显示已响应,信息不足显示结果未知;不会猜测已转发。
## 升级说明
现有地区访问范围和局域网访问策略保持不变。安装新版后生效,新日志才具备上游响应信息;已有日志不会补写。被拒请求仍会出现在记录中,并显示拦截结果,不表示地区限制失效。
## 验证
104 项本地测试全部通过,无跳过,覆盖真实 Lua 日志聚合与真实 Nginx 转发/拦截日志、HTTP 跳转和 WebSocket;Shell/Lua 语法、IPK 内容及桌面/手机深浅色测试页面检查通过。本次不自动更新路由器。
## 安装包
- opkg 安装包:`minigate_2026.10.26_all.ipk`。
- 源码安装包:`minigate-v2026.10.26-src.tar.gz`。
- 兼容旧更新器:`luci-app-minigate-v2026.10.26-src.tar.gz`。
- 使用随附 `SHA256SUMS` 校验文件完整性。
+1
View File
@@ -69,6 +69,7 @@ EOF
'"method":"$request_method",'
'"uri":"$request_uri",'
'"status":$status,'
'"upstream_status":"$upstream_status",'
'"size":$body_bytes_sent,'
'"referer":"$http_referer",'
'"ua":"$http_user_agent"}';
+1 -1
View File
@@ -3,7 +3,7 @@
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
CURRENT_VERSION="2026.10.25"
CURRENT_VERSION="2026.10.26"
REPOSITORY="tpxcer/minigate"
API_URL="https://api.github.com/repos/${REPOSITORY}/releases/latest"
DOWNLOAD_ROOT="https://github.com/${REPOSITORY}/releases/download"
+1 -1
View File
@@ -5,7 +5,7 @@ ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
OUT_DIR="${1:-$ROOT_DIR/dist}"
PKG_NAME="minigate"
PKG_VERSION="2026.10.25"
PKG_VERSION="2026.10.26"
PKG_ARCH="all"
PKG_FILE="${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.ipk"
DEPS="libc, luci-base, luci-compat, nginx-ssl, nginx-mod-stream, openssl-util, wget, curl, jsonfilter, coreutils-stat, nftables"
+64
View File
@@ -0,0 +1,64 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const lua = process.env.MINIGATE_TEST_LUA;
const controller = path.resolve(__dirname, '../luasrc/controller/minigate.lua');
test('visitor aggregation distinguishes blocked, upstream, legacy and failed requests', { skip: !lua }, () => {
const input = [
"local raw, response, limit = '', nil, '50'",
"package.preload['luci.sys'] = function() return {exec=function() return raw end} end",
"luci = {http={formvalue=function() return limit end, prepare_content=function() end, write_json=function(v) response=v end}}",
'dofile(' + JSON.stringify(controller) + ')',
"local real_time = os.time",
"os.time = function(v) return v and real_time(v) or real_time({year=2026,month=10,day=26,hour=12,min=0,sec=0}) end",
"local function row(ip, status, upstream, domain, time)",
"raw = raw .. '{\"time\":\"' .. (time or '2026-10-26T11:59:00+08:00') .. '\",\"client\":\"' .. ip .. '\",\"domain\":\"' .. (domain or 'app.example.com') .. '\"' ..",
"(status and ',\"status\":' .. status or '') .. (upstream and ',\"upstream_status\":\"' .. upstream .. '\"' or '') .. '}\\n'",
"end",
"row('mixed', 200, '200')",
"row('mixed', 444, '-')",
"row('mixed', 308, '-')",
"row('blocked', 444, '-')",
"row('default', 400, '-', '_')",
"row('upstream403', 403, '403')",
"row('upstream401', 401, '401')",
"row('legacy200', 200)",
"row('legacy403', 403)",
"row('failure', 502, '502')",
"row('closed', 499, '-')",
"row('missing', 200, '200')",
"row('missing', nil, '200', '')",
"row('old', 200, '200', nil, '2026-10-26T11:54:59+08:00')",
"row('future', 200, '200', nil, '2026-10-26T12:01:00+08:00')",
"raw = raw .. 'invalid line\\n'",
"luci.controller.minigate.action_proxy_access()",
"local by_ip = {}",
"for _, v in ipairs(response.visitors) do",
"by_ip[v.ip] = v",
"assert(v.count == v.blocked_count + v.forwarded_count + v.other_count)",
"assert(v.online == nil)",
"end",
"local v = by_ip.mixed",
"assert(v.count == 3 and v.blocked_count == 1 and v.forwarded_count == 1 and v.other_count == 1)",
"assert(v.outcome == 'responded' and v.status == 308)",
"assert(by_ip.blocked.outcome == 'blocked' and by_ip.blocked.recent)",
"assert(by_ip.default.outcome == 'blocked')",
"assert(by_ip.upstream403.outcome == 'forwarded' and by_ip.upstream403.status == 403)",
"assert(by_ip.upstream401.outcome == 'forwarded')",
"assert(by_ip.legacy200.outcome == 'responded')",
"assert(by_ip.legacy403.outcome == 'error')",
"assert(by_ip.failure.outcome == 'error' and by_ip.closed.outcome == 'error')",
"assert(by_ip.missing.outcome == 'unknown' and by_ip.missing.status == nil and by_ip.missing.domain == '')",
"assert(not by_ip.old.recent and not by_ip.future.recent)",
"limit = 'invalid'",
"luci.controller.minigate.action_proxy_access()",
"assert(#response.visitors == 5)",
"print('visitor aggregation passed')"
].join('\n');
const result = spawnSync(lua, ['-'], { encoding: 'utf8', input });
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /aggregation passed/);
});
+7
View File
@@ -78,6 +78,13 @@ test('real nginx enforces source policy before upstream, redirect and websocket'
assert.equal(fixture.reached(), reached, '拒绝的请求不得到达后端');
assert.match(await request(fixture.redirectPath, '14.196.0.1'), /308 Permanent Redirect/);
assert.match(await request(fixture.socketPath, '14.196.0.1', { websocket: true }), /101 Switching Protocols/);
const rows = fs.readFileSync(path.join(fixture.dir, 'access.log'), 'utf8').trim().split('\n').map(JSON.parse);
assert.ok(rows.some(row => row.status === 200 && row.upstream_status === '200'));
assert.ok(rows.some(row => row.status === 101 && row.upstream_status === '101'));
assert.ok(rows.some(row => row.status === 308 && !row.upstream_status));
const blocked = rows.filter(row => row.status === 444);
assert.equal(blocked.length, 18);
assert.ok(blocked.every(row => !row.upstream_status), '被拒绝请求没有上游响应');
});
test('real nginx permits all sources when disabled and fails closed if selected data is missing', { skip: !nginx }, async t => {
+29
View File
@@ -102,6 +102,35 @@ test('overview polling pauses in a hidden tab and refreshes when visible again',
assert.deepEqual(page.requests.map(item => item.url).sort(), ['au', 'su']);
});
test('overview distinguishes request results and never labels blocked requests as online', () => {
const page = fakePage(overviewScript);
const visitors = ['blocked', 'forwarded', 'responded', 'error', 'unknown'].map((outcome, i) => ({
ip: '198.51.100.' + (i + 1), last_time: '2026-10-26T11:59:00+08:00', domain: 'app.example.com',
outcome, status: [444, 403, 308, 502, null][i], recent: i === 0,
count: 6, blocked_count: i === 0 ? 0 : 1, forwarded_count: 2, other_count: 3
}));
take(page, 'au').callback(null, { visitors });
const html = page.element('mg-v-list').innerHTML;
for (const label of ['已拦截', '已转发', '已响应', '请求失败', '结果未知', 'HTTP 444', 'HTTP 403',
'无状态码', '近5分钟有请求', '5分钟内无新请求', '总计 6', '拦截 1 · 转发 2 · 其他 3', '拦截 0 · 转发 2 · 其他 3']) {
assert.ok(html.includes(label), label);
}
assert.doesNotMatch(html, /在线|离线|mg-dot/);
});
test('overview renders legacy visitor responses as unknown and escapes log content', () => {
const page = fakePage(overviewScript);
take(page, 'au').callback(null, { visitors: [{
ip: '198.51.100.1', domain: '<img src=x onerror=alert(1)>', count: 1,
last_time: '2026-10-26T11:59:00+08:00', online: true
}] });
const html = page.element('mg-v-list').innerHTML;
assert.match(html, /结果未知/);
assert.match(html, /请求时间未知/);
assert.match(html, /&lt;img/);
assert.doesNotMatch(html, /<img|已转发|在线|离线|拦截 undefined/);
});
test('login guard renders empty JSON objects as empty lists and uses POST for changes', () => {
const page = fakePage(loginGuardScript);
take(page, 'lu').callback(null, { enabled: '1', running: true, banned: {}, banned_total: 0, watching: {}, watching_total: 0, threshold: 3 });