mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-13 20:08:05 +08:00
🛸 Sync 2026-10-29 02:04:12
This commit is contained in:
1 parent
b21929fceb
commit
bfd56813e0
37 files changed
+3918
-4346
No files matched your search
+2
-2
@@ -1,8 +1,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=minigate
|
||||
PKG_VERSION:=2026.10.28
|
||||
PKG_RELEASE:=8
|
||||
PKG_VERSION:=2026.10.28-2
|
||||
PKG_RELEASE:=9
|
||||
PKG_LICENSE:=MIT
|
||||
PKG_MAINTAINER:=MiniGate
|
||||
|
||||
|
||||
+22
-15
@@ -2,9 +2,9 @@
|
||||
|
||||
一个类似 Lucky 的轻量级 OpenWrt 应用,提供四大核心功能:DDNS、SSL 证书、反向代理、登录防护。
|
||||
|
||||
当前版本:**v2026.10.28**。界面名称为 **MiniGate**,仓库名及 OpenWrt 内部包名均为 **`minigate`**。
|
||||
当前版本:**v2026.10.28-2**。界面名称为 **MiniGate**,仓库名及 OpenWrt 内部包名均为 **`minigate`**。
|
||||
|
||||
> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.28_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。
|
||||
> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.28-2_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。
|
||||
|
||||
## 项目定位
|
||||
|
||||
@@ -102,20 +102,20 @@ MiniGate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户
|
||||
|
||||
从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载:
|
||||
|
||||
`minigate_2026.10.28_all.ipk`
|
||||
`minigate_2026.10.28-2_all.ipk`
|
||||
|
||||
**通过 LuCI 界面安装:**
|
||||
1. 打开 LuCI → **系统** → **软件包**
|
||||
2. 点击 **上传软件包**
|
||||
3. 选择 `minigate_2026.10.28_all.ipk`,点击安装
|
||||
3. 选择 `minigate_2026.10.28-2_all.ipk`,点击安装
|
||||
|
||||
**通过命令行安装:**
|
||||
|
||||
```bash
|
||||
cd /tmp
|
||||
wget -O minigate_2026.10.28_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.28/minigate_2026.10.28_all.ipk
|
||||
wget -O minigate_2026.10.28-2_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.28-2/minigate_2026.10.28-2_all.ipk
|
||||
opkg update
|
||||
opkg install /tmp/minigate_2026.10.28_all.ipk
|
||||
opkg install /tmp/minigate_2026.10.28-2_all.ipk
|
||||
rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
|
||||
/etc/init.d/uhttpd restart
|
||||
/etc/init.d/minigate restart
|
||||
@@ -134,7 +134,7 @@ Release 用的 `.ipk` 请用仓库脚本生成,避免生成 Debian 风格 ar
|
||||
脚本会输出:
|
||||
|
||||
```text
|
||||
dist/minigate_2026.10.28_all.ipk
|
||||
dist/minigate_2026.10.28-2_all.ipk
|
||||
```
|
||||
|
||||
该文件外层是 OpenWrt/ImmortalWrt 24.10 兼容的 `tar.gz`,内部成员顺序为 `debian-binary`、`control.tar.gz`、`data.tar.gz`。
|
||||
@@ -162,7 +162,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.28/minigate-v2026.10.28-src.tar.gz && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.28/SHA256SUMS && grep 'minigate-v2026.10.28-src.tar.gz$' SHA256SUMS | sha256sum -c - && tar xzf minigate-v2026.10.28-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.28-2/minigate-v2026.10.28-2-src.tar.gz && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.28-2/SHA256SUMS && grep 'minigate-v2026.10.28-2-src.tar.gz$' SHA256SUMS | sha256sum -c - && tar xzf minigate-v2026.10.28-2-src.tar.gz && sh install.sh && /etc/init.d/uhttpd restart && /etc/init.d/minigate restart
|
||||
```
|
||||
|
||||
其中 `minigate.XXXXXX` 是临时目录模板,系统会自动把六个 `X` 替换为随机字符,避免与已有目录重名。该命令会校验源码包的 SHA-256、保留现有 minigate 配置并重启 LuCI 与 minigate 服务。
|
||||
@@ -177,14 +177,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.28/minigate-v2026.10.28-src.tar.gz
|
||||
wget -O minigate-SHA256SUMS https://github.com/tpxcer/minigate/releases/download/v2026.10.28/SHA256SUMS
|
||||
awk '$2 == "minigate-v2026.10.28-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.28-2/minigate-v2026.10.28-2-src.tar.gz
|
||||
wget -O minigate-SHA256SUMS https://github.com/tpxcer/minigate/releases/download/v2026.10.28-2/SHA256SUMS
|
||||
awk '$2 == "minigate-v2026.10.28-2-src.tar.gz" {print $1 " minigate-src.tar.gz"}' minigate-SHA256SUMS | sha256sum -c - || exit 1
|
||||
|
||||
# 3. 解压到独立目录并安装
|
||||
mkdir -p /tmp/minigate-v2026.10.28
|
||||
tar xzf minigate-src.tar.gz -C /tmp/minigate-v2026.10.28
|
||||
cd /tmp/minigate-v2026.10.28
|
||||
mkdir -p /tmp/minigate-v2026.10.28-2
|
||||
tar xzf minigate-src.tar.gz -C /tmp/minigate-v2026.10.28-2
|
||||
cd /tmp/minigate-v2026.10.28-2
|
||||
sh install.sh
|
||||
|
||||
# 4. 启动服务
|
||||
@@ -234,7 +234,7 @@ SSH 登录后执行上方「方法 2」的一条命令,保留原有配置并
|
||||
### IPK 升级
|
||||
|
||||
```bash
|
||||
opkg install --force-reinstall /tmp/minigate_2026.10.28_all.ipk
|
||||
opkg install --force-reinstall /tmp/minigate_2026.10.28-2_all.ipk
|
||||
rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
|
||||
/etc/init.d/minigate restart
|
||||
```
|
||||
@@ -425,6 +425,13 @@ minigate/
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v2026.10.28-2
|
||||
- 修复 v2026.10.28 引入的登录防护回归:达到失败阈值时,封禁地址被修复防火墙的读取过程清空,日志显示「封禁 失败」,新的攻击来源不会被封禁(已有封禁不受影响)。升级后会立即恢复正常封禁
|
||||
|
||||
### v2026.10.28-1
|
||||
- 「公网访问范围」的地区选择改为收起式:收起时只显示已选地区标签和数量,展开后按华北、东北、华东、华中、华南、西南、西北、港澳台分组对齐排列,支持按地区名或大区名搜索、一键清空、点标签上的 × 移除
|
||||
- 修复 v2026.10.28 中关闭「限制公网访问地区」后保存反向代理页会提示未选择地区、导致整页无法保存的问题
|
||||
|
||||
### v2026.10.28
|
||||
- 修复:通配符主域名停用或删除后,其子域名会退回成 443 端口的明文 HTTP 站点,并可能让 Nginx 因两个默认站点无法加载;现在子域名随主域名一起停用,并可单独停用
|
||||
- 修复:同一端口同时有 HTTP 和 HTTPS 规则时生成无法加载的配置;冲突规则会被跳过并在总览提示
|
||||
|
||||
@@ -219,12 +219,24 @@ if regions_file then
|
||||
regions_file:close()
|
||||
end
|
||||
|
||||
-- 34 个地区按大区分组,收起时只显示已选地区,展开后可搜索(模板 minigate/region_select)
|
||||
local REGION_GROUPS = {"华北", "东北", "华东", "华中", "华南", "西南", "西北", "港澳台"}
|
||||
local function region_group(code)
|
||||
local prefix = tonumber(code:sub(1, 2)) or 99
|
||||
if prefix < 20 then return 1 elseif prefix < 30 then return 2 elseif prefix < 40 then return 3
|
||||
elseif prefix < 44 then return 4 elseif prefix < 50 then return 5 elseif prefix < 60 then return 6
|
||||
elseif prefix < 70 then return 7 end
|
||||
return 8
|
||||
end
|
||||
|
||||
o = s:option(MultiValue, "access_regions", "允许的省级地区", "勾选允许访问的地区,可多选。关闭地区限制即恢复所有地区访问。")
|
||||
o.widget = "checkbox"
|
||||
o.size = 6
|
||||
o.template = "minigate/region_select"
|
||||
o.default = "510000"
|
||||
o.rmempty = false
|
||||
o:depends("access_enabled", "1")
|
||||
o.groups = {}
|
||||
for i, name in ipairs(REGION_GROUPS) do o.groups[i] = {name, {}} end
|
||||
-- 旧版本以 list 保存;统一转成以空格分隔的字符串供复选框回显
|
||||
o.cfgvalue = function(self, section)
|
||||
local value = m.uci:get("minigate", section, "access_regions")
|
||||
@@ -235,6 +247,8 @@ local valid_regions = {}
|
||||
for _, region in ipairs(access_regions) do
|
||||
o:value(region[1], region[2])
|
||||
valid_regions[region[1]] = true
|
||||
local group = o.groups[region_group(region[1])][2]
|
||||
group[#group + 1] = region[1]
|
||||
end
|
||||
o.validate = function(self, value, section)
|
||||
local values = {}
|
||||
@@ -247,7 +261,13 @@ o.validate = function(self, value, section)
|
||||
for _, code in ipairs(values) do
|
||||
if not valid_regions[code] then return nil, "请从列表中选择有效的省级地区" end
|
||||
end
|
||||
return value
|
||||
return table.concat(values, " ")
|
||||
end
|
||||
-- 关闭地区限制时这个字段被隐藏、不会提交;LuCI 不在服务端判断依赖,
|
||||
-- 不跳过会把「未提交」当成未选择而报错,导致整页无法保存。
|
||||
o.parse = function(self, section, novld)
|
||||
if self.map:formvalue("cbid.minigate." .. section .. ".access_enabled") ~= "1" then return end
|
||||
return MultiValue.parse(self, section, novld)
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -0,0 +1,94 @@
|
||||
<%+cbi/valueheader%>
|
||||
<%
|
||||
local selected = {}
|
||||
for code in tostring(self:cfgvalue(section) or self.default or ""):gmatch("%S+") do selected[code] = true end
|
||||
local names = {}
|
||||
for i, key in ipairs(self.keylist) do names[key] = self.vallist[i] end
|
||||
%>
|
||||
<div class="mgx-region" id="<%=cbid%>.region">
|
||||
<div class="mgx-region-field" role="button" tabindex="0" aria-expanded="false" aria-controls="<%=cbid%>.panel">
|
||||
<span class="mgx-region-chips"></span>
|
||||
<span class="mgx-region-count mgx-muted"></span>
|
||||
<span class="mgx-region-arrow mgx-muted" aria-hidden="true">▾</span>
|
||||
</div>
|
||||
<div class="mgx-region-panel" id="<%=cbid%>.panel" hidden="hidden">
|
||||
<div class="mgx-region-tools">
|
||||
<input type="text" class="cbi-input-text mgx-region-search" placeholder="搜索地区,如 川、重庆、西南" aria-label="搜索地区" />
|
||||
<button type="button" class="cbi-button mgx-region-clear">清空</button>
|
||||
</div>
|
||||
<% for _, group in ipairs(self.groups) do if #group[2] > 0 then %>
|
||||
<div class="mgx-region-group" data-group="<%=pcdata(group[1])%>">
|
||||
<div class="mgx-region-gname"><%=pcdata(group[1])%></div>
|
||||
<div class="mgx-region-items">
|
||||
<% for _, code in ipairs(group[2]) do %>
|
||||
<label data-name="<%=pcdata(names[code])%>"><input type="checkbox" class="cbi-input-checkbox" name="<%=cbid%>" value="<%=code%>"<% if selected[code] then %> checked="checked"<% end %> /> <%=pcdata(names[code])%></label>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end end %>
|
||||
<div class="mgx-region-empty mgx-muted" hidden="hidden">没有匹配的地区</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
(function(){
|
||||
var root=document.getElementById('<%=cbid%>.region');
|
||||
if(!root)return;
|
||||
var field=root.querySelector('.mgx-region-field'),panel=root.querySelector('.mgx-region-panel');
|
||||
var chips=root.querySelector('.mgx-region-chips'),count=root.querySelector('.mgx-region-count');
|
||||
var boxes=root.querySelectorAll('input[type=checkbox]');
|
||||
function render(){
|
||||
chips.textContent='';
|
||||
var n=0;
|
||||
for(var i=0;i<boxes.length;i++){
|
||||
if(!boxes[i].checked)continue;
|
||||
n++;
|
||||
var chip=document.createElement('span'),remove=document.createElement('button');
|
||||
chip.className='mgx-region-chip';
|
||||
chip.appendChild(document.createTextNode(boxes[i].parentNode.getAttribute('data-name')));
|
||||
remove.type='button';
|
||||
remove.className='mgx-region-remove';
|
||||
remove.setAttribute('aria-label','移除'+boxes[i].parentNode.getAttribute('data-name'));
|
||||
remove.textContent='×';
|
||||
remove.onclick=(function(box){return function(e){e.stopPropagation();box.checked=false;render();};})(boxes[i]);
|
||||
chip.appendChild(remove);
|
||||
chips.appendChild(chip);
|
||||
}
|
||||
if(!n){
|
||||
var hint=document.createElement('span');
|
||||
hint.className='mgx-err';
|
||||
hint.textContent='请至少选择一个地区';
|
||||
chips.appendChild(hint);
|
||||
}
|
||||
count.textContent='已选 '+n+' 个';
|
||||
}
|
||||
function toggle(open){
|
||||
if(open===undefined)open=panel.hidden;
|
||||
panel.hidden=!open;
|
||||
field.setAttribute('aria-expanded',open?'true':'false');
|
||||
if(open)root.querySelector('.mgx-region-search').focus();
|
||||
}
|
||||
field.onclick=function(){toggle();};
|
||||
field.onkeydown=function(e){if(e.key==='Enter'||e.key===' '){e.preventDefault();toggle();}};
|
||||
for(var i=0;i<boxes.length;i++)boxes[i].onchange=render;
|
||||
root.querySelector('.mgx-region-clear').onclick=function(){for(var i=0;i<boxes.length;i++)boxes[i].checked=false;render();};
|
||||
// 按地区名或大区名筛选;回车不提交整页表单
|
||||
var search=root.querySelector('.mgx-region-search');
|
||||
search.onkeydown=function(e){if(e.key==='Enter')e.preventDefault();};
|
||||
search.oninput=function(){
|
||||
var q=search.value.replace(/\s+/g,''),groups=root.querySelectorAll('.mgx-region-group'),shown=0;
|
||||
for(var g=0;g<groups.length;g++){
|
||||
var labels=groups[g].querySelectorAll('label'),any=false,inGroup=!q||groups[g].getAttribute('data-group').indexOf(q)>=0;
|
||||
for(var l=0;l<labels.length;l++){
|
||||
var hit=inGroup||labels[l].getAttribute('data-name').indexOf(q)>=0;
|
||||
labels[l].hidden=!hit;
|
||||
if(hit)any=true;
|
||||
}
|
||||
groups[g].hidden=!any;
|
||||
if(any)shown++;
|
||||
}
|
||||
root.querySelector('.mgx-region-empty').hidden=shown>0;
|
||||
};
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
<%+cbi/valuefooter%>
|
||||
@@ -21,6 +21,25 @@
|
||||
.mgx-actions{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
|
||||
.mgx-sync{margin-top:4px;font-size:12px!important;padding:2px 10px!important}
|
||||
.mgx-sync-result{margin-left:8px;font-size:12px}
|
||||
.mgx-region{max-width:720px}
|
||||
.mgx-region [hidden]{display:none!important}
|
||||
.mgx-region-field{display:flex;flex-wrap:wrap;align-items:center;gap:6px;min-height:34px;padding:5px 8px;border:1px solid var(--mgx-border);border-radius:6px;background:var(--mgx-control);color:var(--mgx-text);cursor:pointer;box-sizing:border-box}
|
||||
.mgx-region-field:focus{outline:2px solid var(--mgx-info);outline-offset:1px}
|
||||
.mgx-region-chips{display:contents}
|
||||
.mgx-region-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 4px 1px 8px;border-radius:4px;background:rgba(25,113,194,.14);color:var(--mgx-info);font-size:12px;line-height:20px}
|
||||
.mgx-region-remove{min-width:0!important;height:20px!important;margin:0!important;padding:0 4px!important;border:0!important;background:transparent!important;color:inherit!important;font-size:14px!important;line-height:20px!important;cursor:pointer;box-shadow:none!important}
|
||||
.mgx-region-count{margin-left:auto;font-size:12px;white-space:nowrap}
|
||||
.mgx-region-arrow{font-size:12px}
|
||||
.mgx-region-panel{margin-top:6px;padding:8px 12px;border:1px solid var(--mgx-border);border-radius:6px;background:var(--mgx-surface)}
|
||||
.mgx-region-tools{display:flex;gap:8px;align-items:center;margin-bottom:4px}
|
||||
.mgx-region-search{flex:1;min-width:0}
|
||||
.mgx-region-group{display:grid;grid-template-columns:4em minmax(0,1fr);gap:4px 10px;padding:6px 0;border-top:1px solid var(--mgx-border)}
|
||||
.mgx-region-gname{padding-top:2px;color:var(--mgx-muted);font-size:12px}
|
||||
.mgx-region-items{display:grid;grid-template-columns:repeat(auto-fill,minmax(9.5em,1fr));gap:4px 12px}
|
||||
.mgx-region-items label{display:flex;align-items:center;gap:6px;margin:0;color:var(--mgx-text);font-size:13px;white-space:nowrap;cursor:pointer}
|
||||
.mgx-region-items input{margin:0}
|
||||
.mgx-region-empty{padding:8px 0;font-size:12px}
|
||||
@media(max-width:640px){.mgx-region-group{grid-template-columns:minmax(0,1fr)}.mgx-region-items{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@keyframes mgx-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
|
||||
.mgx-spin{display:inline-block;margin-right:8px;animation:mgx-spin 1s linear infinite}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
## 地区选择整理
|
||||
|
||||
「反向代理 → 高级设置:公网访问范围」中的地区选择改为收起式控件。原来 34 个复选框依次排开,看起来比较凌乱。
|
||||
|
||||
- 收起时只显示已选地区的标签和已选数量,例如「四川省 ×」「已选 1 个」。点标签上的 × 就能移除对应地区。
|
||||
- 展开后,地区按华北、东北、华东、华中、华南、西南、西北、港澳台分组,各组对齐排列;手机上自动改为两列。
|
||||
- 支持按地区名或大区名搜索,例如输入「川」「重庆」「西南」;也可以一键清空。全部清空时会提示至少选择一个地区。
|
||||
- 复选框仍是 LuCI 原生表单字段,提交和保存方式不变;同时兼容旧版保存为列表的配置。
|
||||
|
||||
## 修复
|
||||
|
||||
修复 v2026.10.28 的一个问题:关闭「限制公网访问地区」后,保存反向代理页会提示未选择地区,导致整页无法保存。原因是地区字段隐藏后不会提交,LuCI 把“未提交”当成“没有选择”。现在只在开启地区限制时检查地区选择,关闭限制时保留原有选择。
|
||||
|
||||
## 验证
|
||||
|
||||
- 本地完整测试全部通过,无跳过。新增测试覆盖三类内容:
|
||||
- 用真实页面数据渲染地区模板:34 个原生复选框、8 个分组、已选状态;
|
||||
- 兼容旧版的列表格式;
|
||||
- 关闭地区限制时不再阻止保存。
|
||||
- 在浏览器中检查了深色、浅色和 375px 手机宽度下的显示,以及展开、搜索、勾选、移除标签、清空和表单提交,均正常,页面没有横向溢出。
|
||||
- 浏览器检查用的是模拟的 LuCI 页面外框,FanchmWrt 主题下的实际效果需安装后确认。
|
||||
- 本次不自动更新路由器。
|
||||
|
||||
## 安装包
|
||||
|
||||
- opkg 安装包:`minigate_2026.10.28-1_all.ipk`。
|
||||
- 源码安装包:`minigate-v2026.10.28-1-src.tar.gz`。
|
||||
- 兼容旧更新器:`luci-app-minigate-v2026.10.28-1-src.tar.gz`。
|
||||
- 使用随附的 `SHA256SUMS` 校验文件完整性。
|
||||
@@ -0,0 +1,24 @@
|
||||
## 修复登录防护无法封禁新 IP
|
||||
|
||||
修复 v2026.10.28 引入的回归:从 v2026.10.28 起,登录失败次数达到阈值后,登录防护封禁的是一个空地址。日志里显示 `封禁 失败:防火墙未接受该地址`,攻击来源的失败次数一直累加,却不会被封禁。
|
||||
|
||||
- 原因:修复防火墙时会读取持久化的封禁文件,读取循环里的 `ip` 变量没有声明为局部变量。shell 的 `local` 是动态作用域,读到文件末尾时把调用方准备封禁的地址清空了。
|
||||
- 修复:把读取循环中的变量改为局部变量。新增的回归测试分别覆盖防火墙集合完好和重载后为空两种情况,确认达到阈值后封禁的是正确地址、写入封禁文件并清除失败计数。
|
||||
- 升级前已生效的封禁不受影响,升级后新的攻击来源恢复正常封禁。
|
||||
|
||||
## 说明
|
||||
|
||||
「公网访问范围」只限制 MiniGate 反代的网站,不会把 IP 加入封禁列表。登录防护页面的封禁列表来自 SSH 和 LuCI 登录失败,不区分地区。SSH 端口对公网开放时,各地的爆破来源都会出现在这个列表里。
|
||||
|
||||
## 验证
|
||||
|
||||
- 本地完整测试全部通过,无跳过;CI 在 Ubuntu(dash)下运行同一套测试。
|
||||
- 新的回归测试在修复前失败,修复后通过;同一场景也已在 dash 下单独复验。
|
||||
- 本次不自动更新路由器。
|
||||
|
||||
## 安装包
|
||||
|
||||
- opkg 安装包:`minigate_2026.10.28-2_all.ipk`。
|
||||
- 源码安装包:`minigate-v2026.10.28-2-src.tar.gz`。
|
||||
- 兼容旧更新器:`luci-app-minigate-v2026.10.28-2-src.tar.gz`。
|
||||
- 使用随附的 `SHA256SUMS` 校验文件完整性。
|
||||
@@ -208,7 +208,8 @@ ensure_nft() {
|
||||
|
||||
# 防火墙重载后,随包的集合会重新出现但内容为空;持久化文件里仍有未过期封禁时恢复
|
||||
sets_lost_bans() {
|
||||
local now set
|
||||
# ip/exp 必须是局部变量:shell 的 local 是动态作用域,读到文件末尾会清空调用方的 ip
|
||||
local now set ip exp
|
||||
now=$(date +%s)
|
||||
for set in "$SET4" "$SET6"; do
|
||||
nft list set $TABLE $set 2>/dev/null | grep -q 'elements' && continue
|
||||
@@ -255,7 +256,7 @@ restore_bans() {
|
||||
local now=$(date +%s)
|
||||
local tmp="${BANS_FILE}.tmp"
|
||||
> "$tmp"
|
||||
local count=0
|
||||
local count=0 ip exp
|
||||
while read -r ip exp; do
|
||||
[ -z "$ip" ] && continue
|
||||
case "$exp" in ''|*[!0-9]*) continue ;; esac
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
export PATH
|
||||
|
||||
CURRENT_VERSION="2026.10.28"
|
||||
CURRENT_VERSION="2026.10.28-2"
|
||||
REPOSITORY="tpxcer/minigate"
|
||||
API_URL="https://api.github.com/repos/${REPOSITORY}/releases/latest"
|
||||
DOWNLOAD_ROOT="https://github.com/${REPOSITORY}/releases/download"
|
||||
|
||||
@@ -5,7 +5,7 @@ ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
OUT_DIR="${1:-$ROOT_DIR/dist}"
|
||||
|
||||
PKG_NAME="minigate"
|
||||
PKG_VERSION="2026.10.28"
|
||||
PKG_VERSION="2026.10.28-2"
|
||||
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"
|
||||
|
||||
@@ -178,3 +178,38 @@ test('upgrades stop the old login guard tree before running the previous init sc
|
||||
assert.ok(treeStop > 0 && treeStop < initStop, 'installer must stop the login guard tree before the old init script');
|
||||
assert.match(installer, /stop_process_tree\(\) \{[\s\S]*pgrep -P "\$1"/);
|
||||
});
|
||||
|
||||
// 回归:v2026.10.28 中修复防火墙时读取 bans.txt 的循环变量覆盖了调用方的 ip,
|
||||
// 达到阈值后用空地址封禁,日志出现「封禁 失败」,攻击者再也不会被封。
|
||||
test('reaching the threshold bans the attacking address even when other bans are persisted', () => {
|
||||
for (const setsEmpty of [false, true]) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-lg-threshold-'));
|
||||
const dataDir = path.join(dir, 'data');
|
||||
const calls = path.join(dir, 'nft.log');
|
||||
fs.mkdirSync(dataDir);
|
||||
fs.writeFileSync(path.join(dataDir, 'bans.txt'), '111.170.34.182 9999999999\n185.246.128.170 9999999999\n');
|
||||
const result = spawnSync('/bin/sh', ['-c', `
|
||||
logger() { :; }
|
||||
uci() { case "$3" in minigate.login_guard.threshold) echo 3 ;; *) return 1 ;; esac; }
|
||||
nft() {
|
||||
printf '%s\\n' "$*" >> ${JSON.stringify(calls)}
|
||||
case "$*" in
|
||||
'list set inet fw4 login_banned_v4'|'list set inet fw4 login_banned_v6') [ ${setsEmpty ? 1 : 0} = 1 ] || echo 'elements = { 111.170.34.182 }' ;;
|
||||
'list chain inet fw4 input') echo '@login_banned_v4 @login_banned_v6' ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
export MINIGATE_LG_LIBRARY_ONLY=1 MINIGATE_LG_DATA_DIR=${JSON.stringify(dataDir)}
|
||||
export MINIGATE_LG_RUN_DIR=${JSON.stringify(path.join(dir, 'run'))} MINIGATE_LG_LOG=${JSON.stringify(path.join(dir, 'lg.log'))}
|
||||
. ${JSON.stringify(script)}
|
||||
for i in 1 2 3; do handle_failure 139.162.161.162; done
|
||||
`], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const log = fs.readFileSync(path.join(dir, 'lg.log'), 'utf8');
|
||||
assert.match(fs.readFileSync(calls, 'utf8'), /add element inet fw4 login_banned_v4 \{ 139\.162\.161\.162 timeout 43200s \}/, `setsEmpty=${setsEmpty}`);
|
||||
assert.match(log, /BAN 139\.162\.161\.162 12小时(达到 3 次失败)/);
|
||||
assert.doesNotMatch(log, /封禁 +失败/);
|
||||
assert.match(fs.readFileSync(path.join(dataDir, 'bans.txt'), 'utf8'), /^139\.162\.161\.162 \d+$/m);
|
||||
assert.equal(fs.existsSync(path.join(dir, 'run/counters/139.162.161.162')), false, 'counter is cleared after a ban');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const lua = process.env.MINIGATE_TEST_LUA;
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const template = path.join(root, 'luasrc/view/minigate/region_select.htm');
|
||||
const proxyPage = path.join(root, 'luasrc/model/cbi/minigate/proxy.lua');
|
||||
const regions = path.join(root, 'root/usr/lib/minigate/geo/regions.txt');
|
||||
|
||||
// 最小的 LuCI 模板解释器:只支持本模板用到的 <% %>、<%= %> 和 <%+ %>(include 输出占位)
|
||||
const renderer = `
|
||||
local function compile(src)
|
||||
local code, pos = {"local _o = {}\\nlocal function _w(v) _o[#_o + 1] = tostring(v) end\\n"}, 1
|
||||
while true do
|
||||
local s, e = src:find("<%", pos, true)
|
||||
local text = src:sub(pos, (s or 0) - 1)
|
||||
if s == nil then text = src:sub(pos) end
|
||||
code[#code + 1] = "_w(" .. string.format("%q", text) .. ")\\n"
|
||||
if not s then break end
|
||||
local close = assert(src:find("%>", e + 1, true), "unclosed tag")
|
||||
local body = src:sub(e + 1, close - 1)
|
||||
local mode = body:sub(1, 1)
|
||||
if mode == "=" then
|
||||
code[#code + 1] = "_w(" .. body:sub(2) .. ")\\n"
|
||||
elseif mode == "+" then
|
||||
code[#code + 1] = "_w('<!--include " .. body:sub(2):gsub("%s", "") .. "-->')\\n"
|
||||
else
|
||||
code[#code + 1] = body .. "\\n"
|
||||
end
|
||||
pos = close + 2
|
||||
end
|
||||
code[#code + 1] = "return table.concat(_o)"
|
||||
return table.concat(code)
|
||||
end
|
||||
|
||||
function render_template(file, scope)
|
||||
local f = assert(io.open(file)); local src = f:read("*a"); f:close()
|
||||
local fn = assert(loadstring(compile(src), file))
|
||||
setfenv(fn, setmetatable(scope, {__index = _G}))
|
||||
return fn()
|
||||
end
|
||||
|
||||
function pcdata(v)
|
||||
return (tostring(v or ""):gsub("[&<>\\"']", {["&"]="&", ["<"]="<", [">"]=">", ['"']=""", ["'"]="'"}))
|
||||
end
|
||||
`;
|
||||
|
||||
// 用 proxy.lua 生成真实的 option(分组、选项、cfgvalue),再渲染模板
|
||||
const pageLoader = `
|
||||
local options, stored = {}, nil
|
||||
local function node()
|
||||
local n = {}
|
||||
function n:option(kind, name)
|
||||
local o = {kind=kind, name=name, keylist={}, vallist={}}
|
||||
function o:value(k, v) self.keylist[#self.keylist + 1] = k; self.vallist[#self.vallist + 1] = v or k end
|
||||
function o:depends() end
|
||||
options[name] = o
|
||||
return o
|
||||
end
|
||||
return n
|
||||
end
|
||||
function Map()
|
||||
local m = node()
|
||||
m.uci = {get=function() return stored end}
|
||||
function m:section() return node() end
|
||||
return m
|
||||
end
|
||||
for _, name in ipairs({'Flag','Value','DynamicList','ListValue','DummyValue','MultiValue','NamedSection','TypedSection','SimpleSection'}) do _G[name] = name end
|
||||
package.preload['luci.model.uci'] = function() return {cursor=function() return {foreach=function() end, get=function() return nil end} end} end
|
||||
package.preload['luci.controller.minigate'] = function() return {ddns_state=function() return {} end} end
|
||||
local real_open = io.open
|
||||
io.open = function(file, mode)
|
||||
if file == '/usr/lib/minigate/geo/regions.txt' then file = ${JSON.stringify(regions)} end
|
||||
return real_open(file, mode)
|
||||
end
|
||||
assert(dofile(${JSON.stringify(proxyPage)}))
|
||||
local o = options.access_regions
|
||||
function render_regions(value)
|
||||
stored = value
|
||||
return render_template(${JSON.stringify(template)}, {self=o, section='global', cbid='cbid.minigate.global.access_regions'})
|
||||
end
|
||||
`;
|
||||
|
||||
function run(body) {
|
||||
const result = spawnSync(lua, ['-'], { encoding: 'utf8', input: renderer + pageLoader + body });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
test('region selector renders 34 native checkboxes grouped by region with saved choices checked', { skip: !lua }, () => {
|
||||
const html = run(`io.write(render_regions({'510000', '500000'}))`);
|
||||
const boxes = html.match(/<input type="checkbox" class="cbi-input-checkbox" name="cbid\.minigate\.global\.access_regions" value="\d{6}"[^>]*>/g) || [];
|
||||
assert.equal(boxes.length, 34);
|
||||
const checked = boxes.filter(box => box.includes('checked="checked"')).map(box => box.match(/value="(\d+)"/)[1]);
|
||||
assert.deepEqual(checked.sort(), ['500000', '510000']);
|
||||
const groups = [...html.matchAll(/data-group="([^"]+)"/g)].map(match => match[1]);
|
||||
assert.deepEqual(groups, ['华北', '东北', '华东', '华中', '华南', '西南', '西北', '港澳台']);
|
||||
const southwest = html.slice(html.indexOf('data-group="西南"'), html.indexOf('data-group="西北"'));
|
||||
for (const name of ['重庆市', '四川省', '贵州省', '云南省', '西藏']) assert.ok(southwest.includes(`data-name="${name}"`), name);
|
||||
assert.match(html, /^<!--include cbi\/valueheader-->/);
|
||||
assert.match(html, /<!--include cbi\/valuefooter-->\s*$/);
|
||||
assert.match(html, /id="cbid\.minigate\.global\.access_regions\.panel" hidden="hidden"/);
|
||||
});
|
||||
|
||||
test('region selector accepts the old list format and falls back to the default region', { skip: !lua }, () => {
|
||||
const out = run(`
|
||||
local function checked(html) local n = 0; for _ in html:gmatch('checked="checked"') do n = n + 1 end; return n end
|
||||
print(checked(render_regions('510000 500000 440000')), checked(render_regions(nil)), render_regions(nil):match('value="510000" checked') ~= nil)
|
||||
`);
|
||||
assert.equal(out.trim(), '3\t1\ttrue');
|
||||
});
|
||||
|
||||
test('region choices are saved as one string and a hidden field never blocks saving the page', { skip: !lua }, () => {
|
||||
const out = run(`
|
||||
local o = options.access_regions
|
||||
print(o.validate(o, {'510000', '500000'}, 'global'), o.validate(o, '510000', 'global'))
|
||||
print(o.validate(o, {'510000', '990000'}, 'global'), (select(2, o.validate(o, nil, 'global'))))
|
||||
local parsed = 0
|
||||
MultiValue = {parse=function() parsed = parsed + 1 end}
|
||||
local form = {}
|
||||
o.map = {formvalue=function(_, key) return form[key] end}
|
||||
o.parse(o, 'global')
|
||||
form['cbid.minigate.global.access_enabled'] = '1'
|
||||
o.parse(o, 'global')
|
||||
print(parsed)
|
||||
`);
|
||||
assert.equal(out.trim(), '510000 500000\t510000\nnil\t请选择至少一个允许的地区\n1');
|
||||
});
|
||||
|
||||
test('region selector script keeps the page form intact and styles follow the theme', () => {
|
||||
const html = fs.readFileSync(template, 'utf8');
|
||||
const style = fs.readFileSync(path.join(root, 'luasrc/view/minigate/style.htm'), 'utf8');
|
||||
// 芯片的移除按钮、清空按钮都不能提交表单;搜索框回车不提交
|
||||
assert.match(html, /remove\.type='button'/);
|
||||
assert.match(html, /<button type="button" class="cbi-button mgx-region-clear">/);
|
||||
assert.match(html, /search\.onkeydown=function\(e\)\{if\(e\.key==='Enter'\)e\.preventDefault\(\);\}/);
|
||||
assert.match(style, /\.mgx-region-items\{display:grid;grid-template-columns:repeat\(auto-fill,minmax\(9\.5em,1fr\)\)/);
|
||||
assert.match(style, /\.mgx-region \[hidden\]\{display:none!important\}/);
|
||||
});
|
||||
Reference in new issue
Block a user