🎁 Sync 2026-10-28 19:35:15

This commit is contained in:
github-actions[bot] committed 2026-10-28 19:35:15 +08:00
1 parent 85209f310d
commit 390801c844
90 files changed
+5821 -1967

No files matched your search

+9 -10
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=minigate
PKG_VERSION:=2026.10.26
PKG_RELEASE:=7
PKG_VERSION:=2026.10.28
PKG_RELEASE:=8
PKG_LICENSE:=MIT
PKG_MAINTAINER:=MiniGate
@@ -43,16 +43,15 @@ define Package/$(PKG_NAME)/install
# Backend scripts
$(INSTALL_DIR) $(1)/usr/lib/minigate
$(INSTALL_BIN) ./root/usr/lib/minigate/ddns.sh $(1)/usr/lib/minigate/
$(INSTALL_BIN) ./root/usr/lib/minigate/acme.sh $(1)/usr/lib/minigate/
$(INSTALL_BIN) ./root/usr/lib/minigate/proxy.sh $(1)/usr/lib/minigate/
$(INSTALL_BIN) ./root/usr/lib/minigate/geofence.sh $(1)/usr/lib/minigate/
$(INSTALL_BIN) ./root/usr/lib/minigate/login_guard.sh $(1)/usr/lib/minigate/
$(INSTALL_BIN) ./root/usr/lib/minigate/update.sh $(1)/usr/lib/minigate/
$(INSTALL_BIN) ./root/usr/lib/minigate/uninstall.sh $(1)/usr/lib/minigate/
$(INSTALL_BIN) ./root/usr/lib/minigate/after_commit.sh $(1)/usr/lib/minigate/
$(INSTALL_BIN) ./root/usr/lib/minigate/*.sh $(1)/usr/lib/minigate/
$(CP) ./root/usr/lib/minigate/geo $(1)/usr/lib/minigate/
# Login guard nftables sets and rules, loaded by fw4 on every reload
$(INSTALL_DIR) $(1)/usr/share/nftables.d/table-pre
$(INSTALL_DIR) $(1)/usr/share/nftables.d/chain-pre/input
$(INSTALL_DATA) ./root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft $(1)/usr/share/nftables.d/table-pre/
$(INSTALL_DATA) ./root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft $(1)/usr/share/nftables.d/chain-pre/input/
# Config
$(INSTALL_DIR) $(1)/etc/config
$(CP) ./root/etc/config/minigate $(1)/etc/config/
+79 -48
View File
@@ -1,14 +1,14 @@
# minigate - OpenWrt 轻量网关管理
# MiniGate - OpenWrt 轻量网关管理
一个类似 Lucky 的轻量级 OpenWrt 应用,提供四大核心功能:DDNS、SSL 证书、反向代理、登录防护。
当前版本:**v2026.10.26**。项目名称、仓库名及 OpenWrt 内部包名均为 **`minigate`**。
当前版本:**v2026.10.28**。界面名称为 **MiniGate**,仓库名及 OpenWrt 内部包名均为 **`minigate`**。
> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.26_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。
> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.28_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。
## 项目定位
minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户,目标是在 LuCI 中集中完成域名解析、证书签发、反向代理和登录防护配置,减少在路由器上手动维护多组脚本、Nginx 配置和防火墙规则的成本。
MiniGate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户,目标是在 LuCI 中集中完成域名解析、证书签发、反向代理和登录防护配置,减少在路由器上手动维护多组脚本、Nginx 配置和防火墙规则的成本。
项目重点关注以下场景:
@@ -36,54 +36,63 @@ minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户
- 自动检测 WAN IP 变化并更新 DNS 记录
- 公网记录变化成功后无重启刷新 sing-box 与 dnsmasq DNS 缓存,减少局域网继续命中旧地址
- 同名同类型记录重复时,保留匹配当前 IP 的记录并清理其余旧记录,避免旧 IP 继续参与解析
- 支持从网络接口或外部 URL 获取 IP
- 支持从网络接口或外部 URL 获取 IP;外部查询默认用 HTTPS,失败时依次回退
- 只向 Cloudflare 推送公网地址:软路由在上级路由 NAT 之后时,网卡上的 192.168.x 等内网地址会被拒绝并提示改用外部 URL
- 运行状态保存在 `/var/run/minigate`,定时检查不再改写 `/etc/config/minigate`
- 每条记录可配置检查间隔(最小 5 分钟,按 5 分钟取整)
- 一键手动触发更新
- 一键手动触发更新,在后台执行并原地刷新状态,不会丢掉表单里未保存的修改
- 最近一天 IP 变化明细,显示 IPv4/IPv6 地址、开始时间、结束时间和持续时长
### 🔒 SSL/TLS 证书 (ACME)
- **Let's Encrypt** 自动证书签发
- 使用 **DNS-01** 挑战方式(Cloudflare)
- 支持 ECC / RSA 多种密钥类型
- 自动续期(通过 cron 每天检查)
- 自动续期(通过 cron 每天检查);剩余超过 30 天时手动签发不会重复申请,避免触发频率限制
- 证书列表显示到期日期、剩余天数,并区分测试证书和正式证书
- acme.sh 固定为 3.1.6 版本,下载后校验 SHA-256 再安装
- 签发在后台执行,页面轮询结果,不受网页请求超时影响
- Staging 模式用于测试
### 🔄 反向代理
- 基于 **Nginx** 的轻量反向代理
- **IPv6 监听**支持(listen [::]:port 双栈)
- HTTP/2、WebSocket 支持
- HTTP/2;所有站点自动支持 WebSocket,无需逐条开启
- 自动 SSL 证书关联
- 已启用 HTTPS 的任意监听端口可自动识别明文 HTTP,并以 308 跳转到同域名、同端口的 HTTPS
- 安全 Headers(HSTS 等)
- 多站点管理
- 多站点管理;子域名可单独停用,通配符主域名停用时其子域名一并停用
- 同一端口只能用于一种协议(HTTP 或 HTTPS),冲突的规则会被跳过,并在总览页提示原因
- 配置检查失败时自动恢复上一份可用配置,正在运行的站点不受影响;缺少证书的站点会在总览页提示
- Nginx 由 procd 管理,异常退出后自动拉起
- **直接 IP 访问拒绝**:只允许域名访问,扫描器直接断开
- **未知 Host 拒绝**:同一监听端口下,未配置的域名/前缀不会落到第一个反代站点
- **局域网直连解析**(可选):内网设备把代理域名直接解析到本机 LAN 地址,公网 IP 变化、上游 DNS 缓存过期或主路由不支持回流时,内网访问不受影响,流量也不进入代理
- **公网访问范围**(可选):选择允许访问的省级地区,IPv4/IPv6 在首次转发前检查,未命中白名单直接断开;局域网私有地址继续可用
### 🛡 登录防护 (Login Guard)
- 监控 **SSH(dropbear)** 和 **LuCI 网页** 登录失败
- 监控 **SSH(dropbear)** 和 **LuCI 网页** 登录失败,支持 **IPv4 和 IPv6** 来源
- 在「失败窗口」内累积达到阈值(默认 3 次/10 分钟)→ 自动用 nftables 封禁源 IP
- 可配置封禁时长(1 小时 / 6 小时 / **12 小时(默认)** / 24 小时 / 1 周 / 30 天)
- **LAN 私有段自动豁免**(192.168.x / 10.x / 127.x / 172.16-31.x),可加额外白名单
- **LAN 私有段自动豁免**(192.168.x / 10.x / 127.x / 172.16-31.x、IPv6 ULA / 链路本地,以及 LAN 分配的 IPv6 子网),可加额外白名单(IPv4/IPv6,任意前缀长度)
- **滑动时间窗口**:超过窗口未再失败则计数自动清零
- 持久化存储 `/etc/minigate/login-guard/bans.txt`,重启/固件升级后已生效封禁自动恢复
- LuCI 页面实时显示封禁列表、剩余时间、归属地(结合归属地查询 API)
- 失败计数列表默认显示 5 条,支持切换 5 / 20 / 30 条,并复用访客追踪归属地查询;显示最近访问时间并按最新时间排序
- 支持网页**一键解封**、**手动封禁**、**清空全部**
- 后台 watchdog 每 5 分钟自检 nft set,防 fw4 reload 后规则丢失
- 支持网页**一键解封**、**手动封禁**、**清空全部**(清空按钮在列表底部,避免误点)
- 封禁集合与拦截规则随包放在 `/usr/share/nftables.d`,防火墙每次重载自动加载;重载清空的封禁 30 秒内从持久化文件恢复
- 由 procd 管理,异常退出后自动拉起;停止时结束所有子进程
### 👁 访客追踪
- 总览页面显示来源 IP、归属地、最近请求结果、HTTP 状态及转发/拦截次数;被拒绝的扫描也保留在记录中
- 「近5分钟有请求」仅表示近期出现请求,不代表访客在线;已转发也不代表已登录。旧日志缺少上游响应信息时,只能标记为已响应、请求失败或结果未知,不推断为准入放行
- IP 归属地后端查询(ip9.com.cn → ip-api.com → pconline 多源回退)
- IP 归属地优先查随包的离线省级地址库,命中时不把访客 IP 发给外部服务;境外地址再用 HTTPS 接口(ip9.com.cn → pconline)查询,缓存最多保留 500 条
### 软件更新
- 登录 LuCI 后打开 **服务 → minigate → 总览**,自动检查 GitHub 最新正式版;不会自动下载或安装
- 登录 LuCI 后打开 **服务 → MiniGate → 总览**,自动检查 GitHub 最新正式版;不会自动下载或安装
- 10 分钟内复用成功检查结果;「检查更新」可随时手动重新检查
- 点击「一键更新」先显示本次 Release 更新内容,确认后才开始安装
- 安装前校验 SHA-256 并备份程序和配置,安装或服务验证失败时尝试回滚
- 总览标题区提供「卸载」入口;二次确认默认保留配置、证书、封禁记录和日志,也可主动选择彻底清理
- 总览页底部「危险操作」区提供「卸载」入口;二次确认默认保留配置、证书、封禁记录和日志,也可主动选择彻底清理
---
@@ -93,20 +102,20 @@ minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户
从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载:
`minigate_2026.10.26_all.ipk`
`minigate_2026.10.28_all.ipk`
**通过 LuCI 界面安装:**
1. 打开 LuCI → **系统** → **软件包**
2. 点击 **上传软件包**
3. 选择 `minigate_2026.10.26_all.ipk`,点击安装
3. 选择 `minigate_2026.10.28_all.ipk`,点击安装
**通过命令行安装:**
```bash
cd /tmp
wget -O minigate_2026.10.26_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate_2026.10.26_all.ipk
wget -O minigate_2026.10.28_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.28/minigate_2026.10.28_all.ipk
opkg update
opkg install /tmp/minigate_2026.10.26_all.ipk
opkg install /tmp/minigate_2026.10.28_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 +134,7 @@ Release 用的 `.ipk` 请用仓库脚本生成,避免生成 Debian 风格 ar
脚本会输出:
```text
dist/minigate_2026.10.26_all.ipk
dist/minigate_2026.10.28_all.ipk
```
该文件外层是 OpenWrt/ImmortalWrt 24.10 兼容的 `tar.gz`,内部成员顺序为 `debian-binary`、`control.tar.gz`、`data.tar.gz`。
@@ -153,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.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
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
```
其中 `minigate.XXXXXX` 是临时目录模板,系统会自动把六个 `X` 替换为随机字符,避免与已有目录重名。该命令会校验源码包的 SHA-256、保留现有 minigate 配置并重启 LuCI 与 minigate 服务。
@@ -168,20 +177,20 @@ 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.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
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
# 3. 解压到独立目录并安装
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
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
sh install.sh
# 4. 启动服务
/etc/init.d/minigate restart
# 5. 访问 LuCI → 服务 → minigate
# 5. 访问 LuCI → 服务 → MiniGate
```
### 方法 4:OpenWrt SDK 编译(适用所有版本,含 APK)
@@ -207,7 +216,7 @@ APK 的内部版本格式要求将同日序号 `-N` 写为 `-rN`,SDK 会自动
### LuCI 一键更新
进入 **服务 → minigate → 总览 → 软件更新**:
进入 **服务 → MiniGate → 总览 → 软件更新**:
1. 打开总览时自动检查,也可以点击「检查更新」立即重新检查
2. 有新版本时点击「一键更新」,弹出该版本的更新内容
@@ -225,7 +234,7 @@ SSH 登录后执行上方「方法 2」的一条命令,保留原有配置并
### IPK 升级
```bash
opkg install --force-reinstall /tmp/minigate_2026.10.26_all.ipk
opkg install --force-reinstall /tmp/minigate_2026.10.28_all.ipk
rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
/etc/init.d/minigate restart
```
@@ -244,7 +253,7 @@ rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
### 方式 1:LuCI 一键卸载(推荐)
进入 **服务 → minigate → 总览**,点击标题右侧的「卸载」:
进入 **服务 → MiniGate → 总览**,点击页面底部「危险操作」区的「卸载」:
1. 阅读中断提示并确认卸载范围
2. 默认不勾选数据清理,保留 `/etc/config/minigate`、证书、封禁记录和日志
@@ -287,28 +296,29 @@ opkg remove minigate 2>/dev/null || apk del minigate 2>/dev/null
## 使用指南
### 第一步:基础配置
1. 进入 **LuCI → 服务 → minigate**
2. 在「总览」页面开启 minigate
3. 如需 IPv6 反代监听,开启「反向代理监听 IPv6」
1. 进入 **LuCI → 服务 → MiniGate**
2. 在「总览」页面顶部勾选「启用 MiniGate」,点击「保存并应用」
3. 如需 IPv6 反代监听,在「反向代理 → 高级设置」开启「监听 IPv6」
### 第二步:配置 DDNS
1. 切换到 **DDNS** 标签页
2. 启用 DDNS,填入 Cloudflare Zone ID 和 API Token
3. 设置域名,选择协议版本(IPv4 / IPv6 / 双栈)
4. 保存并应用
4. 软路由在上级路由后面时,「IP 来源」选「从外部URL获取」
5. 保存并应用
### 第三步:签发 SSL 证书
1. 切换到 **SSL 证书** 标签页,启用 ACME
2. 先用 Staging 模式测试
3. 点击「立即签发 / 续期」
4. 成功后关闭 Staging 重新签发正式证书
2. 先用测试模式(Staging)验证
3. 点击「立即签发」,签发在后台进行,可以离开页面
4. 成功后关闭测试模式并保存,再签发正式证书;证书列表会标出「测试证书」和「正式证书」
### 第四步:配置反向代理
1. 切换到 **反向代理** 标签页
2. 添加规则,填写域名、目标地址和端口
3. 启用 SSL(自动关联 ACME 证书)
4. 如需输入 `http://域名:监听端口` 后自动进入 HTTPS,开启「HTTP 自动跳转」
5. 保存并应用;minigate 会自动跟随每条 HTTPS 规则的监听端口,无需额外指定固定跳转端口
4. 如需输入 `http://域名:监听端口` 后自动进入 HTTPS,在页面下方「高级设置」开启「HTTP 跳转」
5. 保存并应用;MiniGate 会自动跟随每条 HTTPS 规则的监听端口,无需额外指定固定跳转端口
6. 如需内网访问不绕公网,开启「局域网直连解析」(见下方说明)
### 局域网直连解析
@@ -326,11 +336,11 @@ opkg remove minigate 2>/dev/null || apk del minigate 2>/dev/null
- 关闭开关或停止 minigate 时会移除该文件;也可以手动执行 `/usr/lib/minigate/proxy.sh lan-dns` 重新同步
### 设置公网访问范围
在 **反向代理 → 公网访问范围** 中启用「限制公网访问地区」,再添加允许的省级地区(支持多个)。例如仅保留「四川省」,保存应用后,只允许地址库判定为四川的公网 IP 访问。默认关闭,升级不会自动收紧现有访问范围。
在 **反向代理 → 高级设置:公网访问范围** 中启用「限制公网访问地区」,再勾选允许的省级地区(支持多个)。例如仅保留「四川省」,保存应用后,只允许地址库判定为四川的公网 IP 访问。默认关闭,升级不会自动收紧现有访问范围。
- 所有普通域名、子域名、HTTP 跳转及 WebSocket 均在转发前检查;其他地区、境外及归属地未知来源返回 Nginx `444`(直接断开,无响应正文)
- IPv4、IPv6 使用同一策略;RFC1918 私有 IPv4、回环、IPv6 ULA 和链路本地地址继续放行;LAN 接口已分配的全球 IPv6 子网也自动豁免。使用「局域网直连解析」可通过 LAN IPv4 访问
- 不修改防火墙,不限制 SSH、RustDesk 等其他服务;启用此功能时不再运行旧版地区日志扫描封禁逻辑,已有登录防护独立生效
- 不修改防火墙,不限制 SSH、RustDesk 等其他服务;旧版的地区日志扫描封禁已移除,升级时会把旧配置迁移到这里;登录防护独立生效
- 使用实际连接来源(同端口分流时为本机 stream 提供的 PROXY protocol),不信任访客提交的 `X-Forwarded-For` / `X-Real-IP`;若前面还有 CDN 或隐藏来源的代理,看到的是该代理的出口 IP
- 数据来自 [ip2region](https://github.com/lionsoul2014/ip2region),随安装包附带 34 个省级地区的离线 CIDR,不需要在线归属地接口或额外 Nginx 模块。版本、源文件 SHA-256 和网段数见 `root/usr/lib/minigate/geo/metadata.json`,原许可证随包保留
- 地址归属可能误判或随运营商调整变化;外出、漫游或使用外省代理出口时可能被拦截。数据缺失或配置为空时仍拒绝公网,不自动退回全部放行。可从局域网关闭开关恢复访问
@@ -358,7 +368,7 @@ python3 scripts/build-geo-data.py /path/to/ip2region/data
安装后,反向代理自动拒绝直接使用 IP 地址访问的请求(返回 444 断开连接)。同时,每个监听端口都会生成 `default_server` 兜底规则,未配置的 Host / 子域名前缀不会落到同端口第一个反代站点。只有通过正确域名访问才能到达后端服务,这可以有效防止网络扫描器探测和域名串站。
### 访客追踪
总览页面实时显示所有通过反向代理访问的 IP 地址、归属地和在线状态,帮助你发现异常访问。
总览页面实时显示所有通过反向代理访问的 IP 地址、归属地和最近请求结果,帮助你发现异常访问。
---
@@ -380,16 +390,23 @@ minigate/
│ │ ├── acme.lua # ACME 证书
│ │ ├── proxy.lua # 反向代理
│ │ └── login_guard.lua # 登录防护
│ └── view/minigate/log.htm # 日志页面
│ └── view/minigate/
│ ├── log.htm # 日志页面(按时间合并各来源)
│ ├── style.htm # 各配置页共用的深浅色样式
│ ├── proxy_empty.htm # 反代页无域名时的提示
│ └── ddns_history.htm # 最近一天 IP 变化明细
└── root/
├── etc/config/minigate # UCI 配置(含 login_guard 段)
├── etc/init.d/minigate # 服务脚本(启停含 login_guard)
├── etc/init.d/minigate # procd 服务脚本(Nginx、登录防护、配置变更自动重载)
├── usr/share/nftables.d/ # 登录防护封禁集合与规则(随 fw4 加载)
└── usr/lib/minigate/
├── ddns.sh # DDNS(双栈)
├── acme.sh # 证书管理
├── proxy.sh # 反代(协议分流+IPv6+访问日志)
├── login_guard.sh # 登录防护 watcher + CLI
├── after_commit.sh # LuCI 保存后等配置提交再应用
├── login_guard.sh # 登录防护 watcher + CLI(IPv4/IPv6)
├── job.sh # 签发证书、手动同步等后台任务
├── geo_lookup.sh # 离线省级归属地查询
├── uninstall.sh # 卸载
└── update.sh # GitHub Release 安全更新器
```
@@ -408,6 +425,20 @@ minigate/
## 更新日志
### v2026.10.28
- 修复:通配符主域名停用或删除后,其子域名会退回成 443 端口的明文 HTTP 站点,并可能让 Nginx 因两个默认站点无法加载;现在子域名随主域名一起停用,并可单独停用
- 修复:同一端口同时有 HTTP 和 HTTPS 规则时生成无法加载的配置;冲突规则会被跳过并在总览提示
- 配置检查失败时恢复上一份可用配置;生成过程加锁,避免保存、续期和更新同时触发时互相覆盖
- 服务改由 procd 管理:Nginx 与登录防护异常退出后自动拉起,LuCI「保存并应用」后只重载受影响的部分,不再轮询等待配置写入
- 登录防护支持 IPv6 来源、任意前缀长度的白名单;封禁规则随防火墙重载自动加载,重载后的封禁 30 秒内恢复;封禁失败不再误记为已封禁
- DDNS 只推送公网地址,外部查询默认用 HTTPS;运行状态移出 `/etc/config/minigate`,定时检查不再改写配置
- 证书:固定 acme.sh 3.1.6 并校验 SHA-256;剩余超过 30 天不重复签发;签发和手动同步改为后台任务,不受网页超时影响
- 所有站点自动支持 WebSocket;归属地优先查离线地址库,境外地址才用 HTTPS 接口
- 移除旧版地区日志扫描(升级时迁移到请求前准入);定时任务只管理本插件脚本,不再误删用户任务
- 界面:名称统一为 MiniGate;总开关移到总览顶部,未启用时有提示;DDNS 卡片列出全部记录并按最差状态着色;证书卡片显示到期日期、剩余天数和测试证书;反代地址可直接点击,并显示配置问题;卸载移到底部「危险操作」区
- 界面:日志「全部」按时间合并各来源,新增登录防护、软件更新来源,向上翻看时不再被拉回底部;DDNS 手动同步不再刷新整页;反代页规则在前、高级设置在后,地区改为复选框,启用开关统一为勾选框;配置页跟随深色主题;表单增加域名、Zone ID、检查间隔、阈值和白名单校验
- CI 对每次推送和 PR 运行测试,发布任务只在测试通过后执行
### v2026.10.26
- 访问记录区分「已拦截、已转发、已响应、请求失败、结果未知」,显示最近请求的 HTTP 状态与拦截/转发/其他次数
- 「在线/离线」改为近期请求说明,避免把被拒绝的外地扫描误认为访问成功;保留拦截记录便于核查
+55 -3
View File
@@ -13,6 +13,9 @@ dependency_ready() {
# 页面基于 CBI,由 luci-compat 提供;模块已存在时不依赖包数据库记录
[ -f "${MINIGATE_LUCI_DIR:-/usr/lib/lua/luci}/cbi.lua" ]
;;
nginx-ssl)
command -v nginx >/dev/null 2>&1
;;
*)
return 1
;;
@@ -23,13 +26,13 @@ install_dependencies() {
local p
if command -v opkg >/dev/null 2>&1; then
PKG="opkg"
for p in curl jsonfilter nftables nginx-mod-stream openssl-util luci-compat; do
for p in nginx-ssl curl jsonfilter nftables nginx-mod-stream openssl-util luci-compat; do
dependency_ready "$p" && continue
opkg list-installed 2>/dev/null | grep -q "^${p} " || { opkg update 2>/dev/null; opkg install "$p" 2>/dev/null; }
done
elif command -v apk >/dev/null 2>&1; then
PKG="apk"
for p in curl jsonfilter nftables nginx-mod-stream openssl-util luci-compat; do
for p in nginx-ssl curl jsonfilter nftables nginx-mod-stream openssl-util luci-compat; do
dependency_ready "$p" && continue
apk info -e "$p" >/dev/null 2>&1 || apk add "$p" 2>/dev/null
done
@@ -69,11 +72,28 @@ fi
echo "[*] 已清理旧的独立 login-guard"
}
# 旧版的事后地区扫描已由反代请求前准入取代:移除它的防火墙集合,避免继续全局封禁
remove_legacy_geofence() {
local handle
command -v nft >/dev/null 2>&1 || return 0
for handle in $(nft -a list chain inet fw4 input 2>/dev/null |
awk 'index($0, "@minigate_blocked") { for (i=1; i<=NF; i++) if ($i=="handle") print $(i+1) }'); do
nft delete rule inet fw4 input handle "$handle" 2>/dev/null || true
done
nft delete set inet fw4 minigate_blocked 2>/dev/null || true
}
remove_legacy_geofence
mkdir -p /usr/lib/minigate
mkdir -p /etc/minigate/acme /etc/minigate/certs /etc/minigate/nginx/sites /etc/minigate/nginx/streams /etc/minigate/login-guard
rm -f /usr/lib/minigate/after_commit.sh /usr/lib/minigate/geofence.sh
cp "$D"/root/usr/lib/minigate/*.sh /usr/lib/minigate/; chmod +x /usr/lib/minigate/*.sh
cp -R "$D"/root/usr/lib/minigate/geo /usr/lib/minigate/
cp "$D"/root/etc/init.d/minigate /etc/init.d/; chmod +x /etc/init.d/minigate
# 登录防护的封禁集合和规则随防火墙每次重载自动加载
mkdir -p /usr/share/nftables.d/table-pre /usr/share/nftables.d/chain-pre/input
cp "$D"/root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft /usr/share/nftables.d/table-pre/
cp "$D"/root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft /usr/share/nftables.d/chain-pre/input/
# 配置:保留旧的,但确保有 login_guard 区段
if [ -f /etc/config/minigate ]; then
@@ -92,6 +112,38 @@ fi
# v1.3.11 起由每条 HTTPS 规则的监听端口直接承担 HTTP 跳转。
uci -q delete minigate.global.http_redirect_port 2>/dev/null || true
# 旧版地区扫描的允许省份迁移到请求前准入(按名称前缀匹配地区代码)
migrate_legacy_geofence() {
local allowed codes name code
[ "$(uci -q get minigate.global.geo_enabled)" = "1" ] || return 0
[ "$(uci -q get minigate.global.access_enabled)" = "1" ] && return 0
allowed=$(uci -q get minigate.global.geo_allowed)
codes=""
for name in $(echo "$allowed" | tr ',' ' '); do
code=$(awk -v name="$name" 'index($2, name) == 1 { print $1; exit }' "$D"/root/usr/lib/minigate/geo/regions.txt)
[ -n "$code" ] && codes="$codes $code"
done
[ -n "$codes" ] || return 0
uci -q delete minigate.global.access_regions
for code in $codes; do uci -q add_list minigate.global.access_regions="$code"; done
uci -q set minigate.global.access_enabled=1
echo "[*] 已将旧版地区限制迁移为反代请求前准入:$codes"
}
migrate_legacy_geofence
uci -q delete minigate.global.geo_enabled 2>/dev/null || true
uci -q delete minigate.global.geo_allowed 2>/dev/null || true
uci -q delete minigate.global.blocked_ips 2>/dev/null || true
# 运行状态已移到 /var/run/minigate,清掉旧版写在配置文件里的状态字段
for sec in $(uci -q -X show minigate 2>/dev/null | grep '=ddns$' | cut -d. -f2 | cut -d= -f1); do
for opt in status status_msg last_ip last_ip6 last_update next_sync; do
uci -q delete "minigate.${sec}.${opt}" 2>/dev/null || true
done
done
for opt in status last_domain last_issue cert_expiry last_error; do
uci -q delete "minigate.acme.${opt}" 2>/dev/null || true
done
uci -q commit minigate 2>/dev/null || true
mkdir -p /usr/lib/lua/luci/controller
@@ -100,7 +152,7 @@ mkdir -p /usr/lib/lua/luci/view/minigate
cp "$D"/luasrc/controller/minigate.lua /usr/lib/lua/luci/controller/
cp "$D"/luasrc/model/cbi/minigate/*.lua /usr/lib/lua/luci/model/cbi/minigate/
cp "$D"/luasrc/view/minigate/*.htm /usr/lib/lua/luci/view/minigate/
rm -rf /tmp/luci-* 2>/dev/null
rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache 2>/dev/null
/etc/init.d/minigate enable 2>/dev/null
/etc/init.d/minigate start 9>&- 2>/dev/null || true
echo ""; echo "==== 安装完成 ===="
+454 -241
View File
@@ -1,10 +1,173 @@
module("luci.controller.minigate", package.seeall)
local RUN_DIR = "/var/run/minigate"
local JOB_DIR = RUN_DIR .. "/jobs"
local DDNS_STATE_DIR = RUN_DIR .. "/ddns"
local ACME_STATE_FILE = RUN_DIR .. "/acme.state"
local PROXY_STATUS_FILE = RUN_DIR .. "/proxy.status"
local CERT_DIR = "/etc/minigate/certs"
local function shellquote(value)
value = tostring(value or "")
return "'" .. value:gsub("'", "'\"'\"'") .. "'"
end
local function write_json(data)
luci.http.prepare_content("application/json")
luci.http.write_json(data)
end
local function bad_request(message, extra)
luci.http.status(400, "Bad Request")
local data = extra or {}
data.success = false
data.message = message
write_json(data)
end
-- 读取 key=value 状态文件;warning 等可重复的键收集为数组
function read_state(path)
local data = {}
local file = io.open(path, "r")
if not file then return nil end
for line in file:lines() do
local key, value = line:match("^([%w_]+)=(.*)$")
if key == "warning" then
data.warnings = data.warnings or {}
data.warnings[#data.warnings + 1] = value
elseif key then
data[key] = value
end
end
file:close()
return data
end
-- 与 ddns.sh 的 state_key 和 IP 历史文件使用相同的域名清洗规则
function state_key(domain)
return (tostring(domain or ""):gsub("[^%w_%-]", "_"))
end
function ddns_state(domain)
if not domain or domain == "" then return {} end
return read_state(DDNS_STATE_DIR .. "/" .. state_key(domain) .. ".state") or {}
end
local MONTHS = {Jan=1, Feb=2, Mar=3, Apr=4, May=5, Jun=6, Jul=7, Aug=8, Sep=9, Oct=10, Nov=11, Dec=12}
-- 公历日期转 UTC 秒数,不依赖路由器时区
local function utc_epoch(y, m, d, hh, mm, ss)
y = (m <= 2) and (y - 1) or y
local era = math.floor(y / 400)
local yoe = y - era * 400
local mp = (m + 9) % 12
local doy = math.floor((153 * mp + 2) / 5) + d - 1
local doe = yoe * 365 + math.floor(yoe / 4) - math.floor(yoe / 100) + doy
local days = era * 146097 + doe - 719468
return days * 86400 + hh * 3600 + mm * 60 + ss
end
-- openssl 的 notAfter 形如 "Dec 16 12:00:00 2026 GMT"
function parse_cert_time(value)
local mon, d, hh, mm, ss, y = tostring(value or ""):match("^(%a+)%s+(%d+)%s+(%d+):(%d+):(%d+)%s+(%d+)")
if not mon or not MONTHS[mon] then return nil end
return utc_epoch(tonumber(y), MONTHS[mon], tonumber(d), tonumber(hh), tonumber(mm), tonumber(ss))
end
-- 读取已签发证书:域名、到期日期、剩余天数、是否为测试证书
function cert_list()
local fs = require "nixio.fs"
local certs = {}
if not fs.dir(CERT_DIR) then return certs end
local now = os.time()
for entry in fs.dir(CERT_DIR) do
local path = CERT_DIR .. "/" .. entry
local st = fs.lstat(path)
if entry ~= "_default" and st and st.type == "dir" and fs.access(path .. "/fullchain.pem") then
local handle = io.popen("openssl x509 -in " .. shellquote(path .. "/fullchain.pem") .. " -noout -enddate -issuer 2>/dev/null")
local output = handle and handle:read("*a") or ""
if handle then handle:close() end
local not_after = output:match("notAfter=([^\n]+)")
local issuer = output:match("issuer=([^\n]+)") or ""
local expires = parse_cert_time(not_after)
local item = {
domain = entry:gsub("^_wildcard_%.", "*."),
path = path,
staging = (issuer:lower():find("staging", 1, true) or issuer:lower():find("fake", 1, true)) and true or false
}
if expires then
item.expires = expires
item.expires_date = os.date("%Y-%m-%d", expires)
item.days_left = math.floor((expires - now) / 86400)
end
certs[#certs + 1] = item
end
end
table.sort(certs, function(a, b) return (a.expires or 0) < (b.expires or 0) end)
return certs
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"}, post("action_acme_install")).leaf=true
entry({"admin","services","minigate","acme_issue"}, post("action_acme_issue")).leaf=true
entry({"admin","services","minigate","acme_job"}, call("action_acme_job")).leaf=true
entry({"admin","services","minigate","ddns_sync"}, post("action_ddns_sync")).leaf=true
entry({"admin","services","minigate","ddns_job"}, call("action_ddns_job")).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"}, post("action_lg_ban")).leaf=true
entry({"admin","services","minigate","lg_unban"}, post("action_lg_unban")).leaf=true
entry({"admin","services","minigate","lg_flush"}, post("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
-- ============== 后台任务 ==============
local function job_state(name)
return read_state(JOB_DIR .. "/" .. name .. ".state") or {}
end
-- 同名任务正在运行时不重复启动;超过 15 分钟仍为 running 视为异常退出
local function job_running(name)
local state = job_state(name)
local started = tonumber(state.started or "")
return state.status == "running" and started ~= nil and os.time() - started < 900
end
local function start_job(name, command)
local sys = require "luci.sys"
if job_running(name) then return true end
return sys.call("( /bin/sh /usr/lib/minigate/job.sh " .. name .. " " .. command ..
" ) </dev/null >/dev/null 2>&1 &") == 0
end
local function job_response(name)
local state = job_state(name)
return {
running = job_running(name),
done = state.status == "done",
code = tonumber(state.code or ""),
finished = tonumber(state.finished or "")
}
end
-- ============== 软件更新与卸载 ==============
local function update_response(command)
local sys = require "luci.sys"
local jsonc = require "luci.jsonc"
@@ -30,42 +193,14 @@ local function update_response(command)
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 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"}, post("action_acme_install")).leaf=true
entry({"admin","services","minigate","acme_issue"}, post("action_acme_issue")).leaf=true
entry({"admin","services","minigate","ddns_sync"}, post("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"}, post("action_lg_ban")).leaf=true
entry({"admin","services","minigate","lg_unban"}, post("action_lg_unban")).leaf=true
entry({"admin","services","minigate","lg_flush"}, post("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
write_json(data)
end
function action_update_status()
@@ -88,9 +223,7 @@ function action_update_apply()
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 = "请先查看更新内容并确认版本"})
bad_request("请先查看更新内容并确认版本")
return
end
@@ -101,8 +234,7 @@ function action_update_apply()
"; rm -f \"$runner\" ) >/dev/null 2>&1 & )") == 0
end
luci.http.prepare_content("application/json")
luci.http.write_json({
write_json({
success = ok,
message = ok and "更新任务已启动" or "无法启动更新任务"
})
@@ -116,9 +248,7 @@ function action_uninstall()
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 = "卸载确认无效,请重新操作"})
bad_request("卸载确认无效,请重新操作")
return
end
@@ -129,136 +259,236 @@ function action_uninstall()
"; rm -f \"$runner\" ) >/dev/null 2>&1 & )") == 0
end
luci.http.prepare_content("application/json")
luci.http.write_json({
write_json({
success = ok,
message = ok and "卸载任务已启动" or "无法启动卸载任务"
})
end
-- ============== 总览状态 ==============
local STATUS_RANK = {error=4, partial=3, unknown=2, stopped=1, ok=0}
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
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""
local enabled = uci:get("minigate", "global", "enabled") or "0"
-- DDNS:列出所有记录,并给出最差状态用于卡片颜色
local dl = {}
local worst = nil
uci:foreach("minigate", "ddns", function(s)
local st = ddns_state(s.domain)
local status = st.status or "unknown"
local on = s.enabled == "1"
if not on then status = "disabled" end
dl[#dl + 1] = {
name = s[".name"],
enabled = on and "1" or "0",
domain = s.domain or "",
ip_version = s.ip_version or "ipv4",
status = status,
status_msg = st.message or "",
last_ip = st.ip or "",
last_ip6 = st.ip6 or "",
last_update = st.updated or "",
next_sync = st.next or ""
}
if on and (worst == nil or (STATUS_RANK[status] or 2) > (STATUS_RANK[worst] or 2)) then worst = status end
end)
-- 全局 IPv6 监听状态
local ipv6_listen = uci:get("minigate","global","ipv6_listen") or "0"
local ipv6_listen = uci:get("minigate", "global", "ipv6_listen") or "0"
-- 收集代理规则信息
-- 反代规则:与 proxy.sh 一致,主域名未启用的子域名不显示
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}
uci:foreach("minigate", "proxy_wildcard", function(s)
if s.enabled == "1" and s.domain then
local base = (s.domain or ""):gsub("^%*%.", "")
wc_ports[base] = {port = s.listen_port or "443", ssl = tostring(s.ssl or "0")}
end
end)
uci:foreach("minigate","subproxy",function(s)
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}
local wc = wc_ports[parent]
if prefix ~= "" and wc and (s.enabled or "1") == "1" then
proxy_rules[#proxy_rules + 1] = {
domain = prefix .. "." .. parent,
target = (s.target_addr or "") .. ":" .. (s.target_port or "80"),
listen_port = wc.port,
scheme = wc.ssl == "1" and "https" or "http",
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}
uci:foreach("minigate", "proxy", function(s)
if s.enabled == "1" and s.domain and s.target_addr then
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 = tostring(s.ssl or "1") == "1" and "https" or "http",
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""
local proxy_state = read_state(PROXY_STATUS_FILE) or {}
local acme_state = read_state(ACME_STATE_FILE) or {}
write_json({
enabled = enabled,
ipv6_listen = ipv6_listen,
proxy_running = pr,
proxy_check = proxy_state.result or "",
proxy_message = proxy_state.message or "",
proxy_time = proxy_state.time or "",
proxy_warnings = proxy_state.warnings or {},
ddns_status = worst or "",
ddns_list = dl,
proxy_rules = proxy_rules,
certs = cert_list(),
acme = {
enabled = uci:get("minigate", "acme", "enabled") or "0",
staging = uci:get("minigate", "acme", "staging") or "0",
result = acme_state.result or "",
domain = acme_state.domain or "",
message = acme_state.message or "",
time = acme_state.time 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"}
if not fs[s] then s="all" end
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""})
-- ============== 日志 ==============
local LOG_SOURCES = {
ddns = {{"DDNS", "/var/log/minigate-ddns.log"}},
acme = {{"证书", "/var/log/minigate-acme.log"}},
proxy = {{"反代", "/var/log/minigate-proxy.log"}, {"Nginx", "/var/log/minigate-nginx-error.log"}},
login_guard = {{"登录防护", "/var/log/minigate-login-guard.log"}},
update = {{"更新", "/var/log/minigate-update.log"}}
}
local LOG_ORDER = {"ddns", "acme", "proxy", "login_guard", "update"}
local function tail_lines(path, count)
local lines = {}
local file = io.open(path, "r")
if not file then return lines end
for line in file:lines() do
lines[#lines + 1] = line
if #lines > count then table.remove(lines, 1) end
end
file:close()
return lines
end
-- 统一时间戳便于合并:nginx 用 2026/10/28,其余脚本用 2026-10-28
local function line_time(line)
local stamp = line:match("^(%d%d%d%d[-/]%d%d[-/]%d%d %d%d:%d%d:%d%d)")
return stamp and stamp:gsub("/", "-") or nil
end
-- 「全部」按时间合并各来源,每行标出来源;没有时间戳的行(如 curl 输出)跟随上一行
function merge_logs(sources, limit)
local rows = {}
for source_index, source in ipairs(sources) do
local last = ""
for line_index, line in ipairs(tail_lines(source[2], limit)) do
last = line_time(line) or last
rows[#rows + 1] = {time = last, source = source_index, index = line_index, text = "[" .. source[1] .. "] " .. line}
end
end
table.sort(rows, function(a, b)
if a.time ~= b.time then return a.time < b.time end
if a.source ~= b.source then return a.source < b.source end
return a.index < b.index
end)
local out = {}
for i = math.max(1, #rows - limit + 1), #rows do out[#out + 1] = rows[i].text end
return table.concat(out, "\n")
end
function action_get_log()
local source = luci.http.formvalue("source") or "all"
local sources = {}
if LOG_SOURCES[source] then
sources = LOG_SOURCES[source]
else
for _, name in ipairs(LOG_ORDER) do
for _, item in ipairs(LOG_SOURCES[name]) do sources[#sources + 1] = item end
end
end
write_json({log = merge_logs(sources, 300)})
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"安装失败"})
local ok = start_job("acme", "/bin/sh /usr/lib/minigate/acme.sh install")
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})
local d = luci.http.formvalue("domain") or ""
if d ~= "" and (#d > 253 or not d:match("^[%*%w][%w%.%-]*$")) then
bad_request("域名无效")
return
end
local ok = start_job("acme", "/bin/sh /usr/lib/minigate/acme.sh issue " .. shellquote(d))
write_json({success = ok, message = ok and "已开始签发" or "无法启动签发任务"})
end
function action_acme_job()
local fs = require "nixio.fs"
local data = job_response("acme")
local state = read_state(ACME_STATE_FILE) or {}
data.result = state.result or ""
data.message = state.message or ""
data.domain = state.domain or ""
data.installed = fs.access("/etc/minigate/acme/data/acme.sh") and fs.access("/etc/minigate/acme/data/dnsapi/dns_cf.sh") and true or false
write_json(data)
end
-- ============== DDNS ==============
local function valid_ddns_section(sec)
return sec ~= "" and #sec <= 64 and sec:match("^[%w_]+$") and
require"luci.model.uci".cursor():get("minigate", sec) == "ddns"
end
function action_ddns_sync()
local sys=require"luci.sys"; local sec=luci.http.formvalue("section")or""
if sec~="" and (#sec>64 or not sec:match("^[%w_]+$") or require"luci.model.uci".cursor():get("minigate",sec)~="ddns") then
luci.http.status(400,"Bad Request")
luci.http.prepare_content("application/json"); luci.http.write_json({success=false,status="invalid",message="DDNS 记录参数无效"})
local sec = luci.http.formvalue("section") or ""
if not valid_ddns_section(sec) then
bad_request("DDNS 记录参数无效", {status = "invalid"})
return
end
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})
local ok = start_job("ddns-" .. sec, "/bin/sh /usr/lib/minigate/ddns.sh single " .. shellquote(sec))
write_json({success = ok, message = ok and "已开始同步" or "无法启动同步任务"})
end
function action_ddns_job()
local sec = luci.http.formvalue("section") or ""
if not valid_ddns_section(sec) then
bad_request("DDNS 记录参数无效", {status = "invalid"})
return
end
local uci = require "luci.model.uci".cursor()
local data = job_response("ddns-" .. sec)
local st = ddns_state(uci:get("minigate", sec, "domain"))
data.status = st.status or "unknown"
data.message = st.message or ""
data.ip = st.ip or ""
data.ip6 = st.ip6 or ""
data.updated = st.updated or ""
data.next = st.next or ""
data.success = data.status == "ok" or data.status == "partial"
write_json(data)
end
local function history_section_name(value)
@@ -302,9 +532,7 @@ function action_ddns_history()
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 记录参数无效"})
bad_request("DDNS 记录参数无效")
return
end
@@ -354,8 +582,7 @@ function action_ddns_history()
return a.start_time > b.start_time
end)
luci.http.prepare_content("application/json")
luci.http.write_json({
write_json({
success=true,
generated_at=now,
window_start=cutoff,
@@ -365,6 +592,8 @@ function action_ddns_history()
})
end
-- ============== 访问记录 ==============
function action_proxy_access()
local sys = require "luci.sys"
local limit = tonumber(luci.http.formvalue("limit") or "5") or 5
@@ -440,8 +669,25 @@ function action_proxy_access()
}
end
luci.http.prepare_content("application/json")
luci.http.write_json({ visitors = result })
write_json({ visitors = result })
end
-- ============== 归属地 ==============
local GEO_CACHE_DIR = "/tmp/minigate-geo-cache"
local GEO_CACHE_MAX = 500
-- 缓存超过上限时删除最旧的一批,避免长期遭遇扫描时占满内存
local function prune_geo_cache()
local sys = require "luci.sys"
local count = tonumber((sys.exec("ls " .. GEO_CACHE_DIR .. " 2>/dev/null | wc -l") or ""):match("%d+") or "0") or 0
if count > GEO_CACHE_MAX then
sys.call("cd " .. GEO_CACHE_DIR .. " && ls -tr | head -n 100 | xargs rm -f -- 2>/dev/null")
end
end
local function json_field(raw, key)
return raw:match('"' .. key .. '"%s*:%s*"([^"]*)"') or ""
end
function action_geo_lookup()
@@ -449,73 +695,60 @@ function action_geo_lookup()
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" })
if #ip > 39 or not ip:match("^[%d%.%:a-fA-F]+$") then
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 cache_file = GEO_CACHE_DIR .. "/" .. ip:gsub("[^%w%.%-_:]", "_")
fs.mkdirr(GEO_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+$", "") })
write_json({ ip = ip, geo = cached:gsub("%s+$", "") })
return
end
local geo = nil
-- 先查随包的离线省级地址库,命中时不把访客 IP 发给任何外部服务
local geo = (sys.exec("/bin/sh /usr/lib/minigate/geo_lookup.sh " .. shellquote(ip) .. " 2>/dev/null") or ""):gsub("%s+$", "")
if geo == "" then geo = nil end
-- API 1: ip9.com.cn(UTF-8,国内快)
-- 境外或离线库未覆盖的地址,再用 HTTPS 接口查询
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
local raw = sys.exec("curl -s --proto '=https' --connect-timeout 1 --max-time 2 " .. shellquote("https://ip9.com.cn/get?ip=" .. ip) .. " 2>/dev/null") or ""
local loc = (json_field(raw, "country") .. json_field(raw, "prov") .. json_field(raw, "city")):gsub("中国", "")
local isp = json_field(raw, "isp")
if isp ~= "" then loc = loc .. " " .. isp end
loc = loc:gsub("^%s+", ""):gsub("%s+$", "")
if loc ~= "" then geo = loc 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
local raw = sys.exec("curl -s --proto '=https' --connect-timeout 1 --max-time 2 " .. shellquote("https://whois.pconline.com.cn/ipJson.jsp?ip=" .. ip .. "&json=true") .. " 2>/dev/null | iconv -f gbk -t utf-8 2>/dev/null") or ""
local addr = json_field(raw, "addr")
if addr ~= "" then geo = addr:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") end
end
fs.writefile(cache_file, geo or "未知")
luci.http.prepare_content("application/json")
luci.http.write_json({ ip = ip, geo = geo or "未知" })
prune_geo_cache()
write_json({ ip = ip, geo = geo or "未知" })
end
-- ============== 登录防护 API ==============
local function valid_ban_ip(ip)
if ip:match("^%d+%.%d+%.%d+%.%d+$") then return true end
return #ip <= 39 and ip:match("^[%x:]+$") ~= nil and select(2, ip:gsub(":", "")) >= 2
end
local function parse_ban_set(raw, banned)
-- nft -j 在 key 和 value 之间有空格,要用 %s* 匹配;按 elem 块配对 val 与 expires
for block in raw:gmatch('{[^{}]-"val"%s*:%s*"[^"]+"[^{}]-}') do
local ip = block:match('"val"%s*:%s*"([%x:%.]+)"')
local expires = tonumber(block:match('"expires"%s*:%s*(%d+)') or "")
if ip and expires then banned[#banned + 1] = { ip = ip, remaining = expires } end
end
end
function action_lg_status()
local sys = require "luci.sys"
local fs = require "nixio.fs"
@@ -535,28 +768,13 @@ function action_lg_status()
local bantime = tonumber(uci:get("minigate","login_guard","bantime")) or 43200
local window_s = tonumber(uci:get("minigate","login_guard","window")) or 600
-- 服务是否在跑
-- 服务是否在跑(procd 以 /bin/sh login_guard.sh run 启动)
local pid_out = sys.exec("pgrep -f '^/bin/sh /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
parse_ban_set(sys.exec("nft -j list set inet fw4 login_banned_v4 2>/dev/null") or "", banned)
parse_ban_set(sys.exec("nft -j list set inet fw4 login_banned_v6 2>/dev/null") or "", banned)
-- 按剩余时间倒序
table.sort(banned, function(a,b) return a.remaining > b.remaining end)
local banned_total = #banned
@@ -569,28 +787,29 @@ function action_lg_status()
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)
}
if fs.dir(counter_dir) then
for ip in fs.dir(counter_dir) 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
@@ -604,8 +823,7 @@ function action_lg_status()
watching_limited[#watching_limited + 1] = watching[i]
end
luci.http.prepare_content("application/json")
luci.http.write_json({
write_json({
enabled = enabled,
running = running,
threshold = threshold,
@@ -623,32 +841,27 @@ 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"})
if not valid_ban_ip(ip) then
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})
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh ban " .. shellquote(ip) .. " >/dev/null 2>&1")
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"})
if not valid_ban_ip(ip) then
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})
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh unban " .. shellquote(ip) .. " >/dev/null 2>&1")
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)})
write_json({success = (rc == 0)})
end
+55 -52
View File
@@ -1,7 +1,7 @@
local m,s,o
local sys=require"luci.sys"
local fs=require"nixio.fs"
local uc=require"luci.model.uci".cursor()
local ctl=require"luci.controller.minigate"
local function esc(v)
v=tostring(v or "")
@@ -10,96 +10,99 @@ local function esc(v)
return v
end
local function shellquote(v)
v=tostring(v or "")
return "'"..v:gsub("'","'\"'\"'").."'"
local function acme_installed()
return fs.access("/etc/minigate/acme/data/acme.sh") and fs.access("/etc/minigate/acme/data/dnsapi/dns_cf.sh")
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」。")
m=Map("minigate","MiniGate - SSL 证书","ACME(Let's Encrypt)自动签发,每天自动续期。域名和令牌自动关联「动态DNS」。")
s=m:section(SimpleSection); s.template="minigate/style"
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.datatype="string"
o.description="可选,用于接收 Let's Encrypt 到期提醒。"
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.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
local certs=ctl.cert_list()
local h=''
if uc:get("minigate","acme","staging")=="1" then
h=h..'<div class="mgx-note">测试模式已开启:签发的证书浏览器不信任。正式使用请关闭测试模式并保存,再点击下方「立即签发」。</div>'
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
h=h..'<table class="table"><tr class="tr table-titles"><th class="th">域名</th><th class="th">到期</th><th class="th">类型</th></tr>'
for _,c in ipairs(certs) do
local left=c.days_left
local cls=(left==nil and "mgx-muted") or (left<0 and "mgx-err") or ((left<14 or c.staging) and "mgx-warn") or "mgx-ok"
local when=c.expires_date and (c.expires_date..(left<0 and "(已过期)" or "(剩 "..left.." 天)")) or "未知"
h=h..'<tr class="tr"><td class="td"><strong>'..esc(c.domain)..'</strong></td><td class="td"><span class="'..cls..'">'..esc(when)..'</span></td>'
h=h..'<td class="td">'..(c.staging and '<span class="mgx-warn">测试证书(不受信任)</span>' or '<span class="mgx-ok">正式证书</span>')..'</td></tr>'
end
if #certs==0 then h=h..'<tr class="tr"><td class="td mgx-muted" colspan="3" style="text-align:center;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
if acme_installed() then return'<span class="mgx-ok">&#10003; 已安装</span>'
elseif fs.access("/etc/minigate/acme/data/acme.sh")then return'<span class="mgx-warn">&#9888; 缺 DNS 插件,请重新安装</span>'
else return'<span class="mgx-err">&#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 ju=luci.dispatcher.build_url("admin/services/minigate/acme_job")
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">]]..
local install_button=acme_installed() and "" or
'<div class="mgx-actions"><button type="button" class="cbi-button cbi-button-reload" id="bi" onclick="doI()" style="min-width:220px">下载并安装 acme.sh</button></div>'
return install_button..[[
<div class="mgx-actions">
<label for="sd">签发域名:</label>
<select id="sd" class="mgx-select">]]..
(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" class="mg-acme-note" style="display:none"><span style="display:inline-block;animation:sp 1s linear infinite;margin-right:8px">&#9203;</span><span id="am">处理中...</span></div>
<div id="ar" style="display:none"><div id="ai" class="mg-acme-note" role="status"></div></div>
<style>@keyframes sp{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
.mg-acme-note{margin-top:12px;border-radius:4px;padding:10px 15px;font-size:13px;line-height:1.6;background:rgba(127,127,127,.12);border:1px solid rgba(127,127,127,.2)}
.mg-acme-note.ok{background:rgba(47,158,68,.12);border-color:rgba(47,158,68,.32);color:#2e7d32}
.mg-acme-note.fail{background:rgba(224,49,49,.1);border-color:rgba(224,49,49,.3);color:#c62828}
:root[data-darkmode="true"] .mg-acme-note.ok{color:#78c98d}
:root[data-darkmode="true"] .mg-acme-note.fail{color:#e58a84}
@media(prefers-color-scheme:dark){:root:not([data-darkmode]) .mg-acme-note.ok{color:#78c98d}:root:not([data-darkmode]) .mg-acme-note.fail{color:#e58a84}}
</style>
<button type="button" class="cbi-button cbi-button-apply" id="bs" onclick="doS()">立即签发</button></div>
<div class="mgx-small mgx-muted">证书剩余超过 30 天时不会重复签发,避免触发 Let's Encrypt 频率限制;到期前 30 天内会自动续期。</div>
<div id="ap" class="mgx-note is-info" style="display:none"><span class="mgx-spin">&#9203;</span><span id="am">处理中...</span></div>
<div id="ar" style="display:none"><div id="ai" class="mgx-note" role="status"></div></div>
<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.className='mg-acme-note '+(ok?'ok':'fail');e.textContent=m}
function sR(ok,m){document.getElementById('ap').style.display='none';document.getElementById('ar').style.display='block';var e=document.getElementById('ai');e.className='mgx-note '+(ok?'is-ok':'is-err');e.textContent=m}
function acmePost(url,args,cb){var done=false;function finish(x,d){if(done)return;done=true;cb(d)}var p=XHR.post(url,args,finish);if(p&&typeof p.catch==='function')p.catch(function(){finish(null,null)})}
function doI(){document.getElementById('bi').disabled=true;sP('下载安装中(约30秒)...');acmePost(']]..iu..[[',null,function(d){document.getElementById('bi').disabled=false;d?sR(d.success,d.message):sR(false,'请求未完成:安装可能仍在后台进行,请稍后刷新页面查看 acme.sh 状态');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分钟)...');acmePost(']]..su..[[',{domain:d},function(r){document.getElementById('bs').disabled=false;r?sR(r.success,r.message):sR(false,'请求超时:签发可能仍在后台进行,请稍后刷新页面查看证书状态');if(r&&r.success)setTimeout(function(){location.reload()},2000)})}
function acmeGet(url,cb){var done=false;function finish(x,d){if(done)return;done=true;cb(d)}var p=XHR.get(url,null,finish);if(p&&typeof p.catch==='function')p.catch(function(){finish(null,null)})}
function acmeWait(button,kind,tries){
acmeGet(']]..ju..[[',function(d){
if(d&&d.running&&tries<150){setTimeout(function(){acmeWait(button,kind,tries+1)},2000);return}
if(button)button.disabled=false;
if(!d){sR(false,'无法读取任务结果,请稍后刷新页面查看');return}
if(d.running){sR(false,'任务仍在进行,请稍后刷新页面查看');return}
var ok,msg;
if(kind==='install'){ok=d.code===0&&d.installed;msg=ok?'安装成功':'安装失败,请查看 ACME 日志';}
else{ok=d.code===0&&d.result==='ok';msg=d.message||(ok?'签发成功':'签发失败,请查看 ACME 日志');}
sR(ok,msg);
if(ok)setTimeout(function(){location.reload()},2500);
});
}
function doI(){var b=document.getElementById('bi');b.disabled=true;sP('正在下载并校验 acme.sh(约30秒)...');acmePost(']]..iu..[[',null,function(d){if(!d||!d.success){b.disabled=false;sR(false,(d&&d.message)||'无法启动安装任务');return}setTimeout(function(){acmeWait(b,'install',0)},2000)})}
function doS(){var d=document.getElementById('sd').value;if(!d){sR(false,'请选择域名');return}var b=document.getElementById('bs');b.disabled=true;sP('正在签发 '+d+'(约1-2分钟,可离开本页)...');acmePost(']]..su..[[',{domain:d},function(r){if(!r||!r.success){b.disabled=false;sR(false,(r&&r.message)||'无法启动签发任务');return}setTimeout(function(){acmeWait(b,'issue',0)},3000)})}
</script>]]
end
+105 -59
View File
@@ -1,5 +1,5 @@
local m,s,o
local sys=require"luci.sys"
local ctl=require"luci.controller.minigate"
local function esc(v)
v=tostring(v or "")
@@ -8,23 +8,47 @@ local function esc(v)
return v
end
m=Map("minigate","MiniGate - 动态DNS","支持多域名、IPv4/IPv6双栈。保存后立即同步。需先启用全局开关。")
-- 只重新同步 DDNS,不重启反向代理和登录防护
m.on_after_commit=function(self)
sys.call("/bin/sh /usr/lib/minigate/after_commit.sh ddns >/dev/null 2>&1")
-- 域名:可带 *. 前缀,每段 1-63 位字母数字或连字符,不以连字符开头或结尾
local function valid_domain(value)
local name=tostring(value or ""):gsub("^%*%.","")
if #name==0 or #name>253 then return false end
local count=0
for label in (name.."."):gmatch("([^.]*)%.") do
if #label<1 or #label>63 or not label:match("^[%w%-]+$") or label:match("^%-") or label:match("%-$") then return false end
count=count+1
end
return count>=2
end
-- 保存并应用后由 procd 的配置触发器只重新同步 DDNS,不重启反向代理和登录防护
m=Map("minigate","MiniGate - 动态DNS","支持多域名、IPv4/IPv6双栈。保存并应用后立即同步。需先在「总览」启用 MiniGate。")
s=m:section(SimpleSection); s.template="minigate/style"
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.validate=function(self,value)
if valid_domain(value) then return value end
return nil,"域名格式不正确,例如 home.example.com 或 *.example.com"
end
o=s:option(Value,"cf_zone_id","Zone ID"); o.rmempty=false; o.placeholder="Cloudflare 区域 ID"
o.description="在 Cloudflare 域名概览页右下角「API」区域可以找到。"
o.validate=function(self,value)
if tostring(value or ""):match("^%x+$") and #value==32 then return value end
return nil,"Zone ID 应为 32 位十六进制字符"
end
o=s:option(Value,"cf_api_token","API 令牌"); o.rmempty=false; o.password=true
o.description="在 Cloudflare「我的个人资料 → API 令牌」创建,权限选「区域 → DNS → 编辑」,区域资源选本域名。"
o.validate=function(self,value)
if tostring(value or ""):match("^[%w%-_]+$") then return value end
return nil,"API 令牌只包含字母、数字、- 和 _,请检查是否复制了多余的空格"
end
-- ====== 协议版本 ======
o=s:option(ListValue,"ip_version","协议版本")
@@ -36,6 +60,7 @@ o.description="选择更新哪种 DNS 记录。双栈模式同时更新 A 和 AA
o=s:option(ListValue,"ip_source","IP 来源")
o:value("interface","从网卡获取"); o:value("url","从外部URL获取"); o.default="interface"
o.description="软路由在上级路由后面(网卡是 192.168.x.x 等内网地址)时,请选「从外部URL获取」。内网地址不会被推送到 Cloudflare。"
-- IPv4 网卡
o=s:option(ListValue,"interface","IPv4 网络接口"); o.default="wan"
@@ -45,17 +70,17 @@ 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:value("https://ddns.oray.com/checkip","ddns.oray.com(HTTPS,推荐)")
o:value("http://ip.3322.net","ip.3322.net(HTTP)")
o:value("http://members.3322.org/dyndns/getip","members.3322.org(HTTP)")
o:value("http://ns1.dnspod.net:6666","ns1.dnspod.net(HTTP)")
o.default="https://ddns.oray.com/checkip"
o.description="所选地址失败时自动尝试其他地址;旁路由/代理环境会绕过代理直连获取真实公网 IP。"
o:depends("ip_source","url")
-- IPv6 网卡
o=s:option(ListValue,"interface6","IPv6 网络接口"); o.default="wan6"
o.description="用于获取 IPv6 地址的接口(通常为 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"})
@@ -66,45 +91,37 @@ 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:value("http://v6.66666.host:66/ip","v6.66666.host(HTTP)")
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.description="最小 300 秒。定时任务每 5 分钟检查一次,实际间隔按 5 分钟向上取整。"
o=s:option(Value,"check_interval","检查间隔(秒)"); o.datatype="uinteger"; o.default="300"
o:value("300","5 分钟"); o:value("600","10 分钟"); o:value("1800","30 分钟"); o:value("3600","1 小时")
o.description="最少 300 秒。定时任务每 5 分钟检查一次,实际间隔按 5 分钟向上取整。"
o.validate=function(self,value)
local n=tonumber(value)
if n and n>=300 then return value end
return nil,"检查间隔不能少于 300 秒"
end
-- 状态+手动同步
-- 状态+手动同步(状态来自 /var/run,不写入配置文件)
local STATE={ok={"&#10003; 正常","mgx-ok"},partial={"&#9888; 部分成功","mgx-warn"},error={"&#10007; 异常","mgx-err"},stopped={"已停止","mgx-muted"}}
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; '..esc(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: '..esc(ip)..'</span>' end
if ip6~=""then h=h..'<br><span style="font-size:11px;color:#2196f3">AAAA: '..esc(ip6)..'</span>' end
if msg~=""and st~="error"then h=h..'<br><span style="font-size:11px;color:#888">'..esc(msg)..'</span>' end
if lu~=""then h=h..'<br><span style="font-size:11px;color:#888">更新: '..esc(lu)..'</span>' end
if ns~=""then h=h..'<br><span style="font-size:11px;color:#888">下次: '..esc(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>'
local domain=m.uci:get("minigate",section,"domain")or""
local st=ctl.ddns_state(domain)
local label=STATE[st.status or ""] or {"未同步","mgx-muted"}
local h='<span id="ss-'..section..'"><span class="'..label[2]..'">'..label[1]..'</span>'
if st.status=="error" and (st.message or "")~="" then h=h..' <span class="mgx-err">'..esc(st.message)..'</span>' end
if (st.ip or "")~="" then h=h..'<span class="mgx-small mgx-muted">A: '..esc(st.ip)..'</span>' end
if (st.ip6 or "")~="" then h=h..'<span class="mgx-small mgx-info">AAAA: '..esc(st.ip6)..'</span>' end
if (st.message or "")~="" and st.status~="error" then h=h..'<span class="mgx-small mgx-muted">'..esc(st.message)..'</span>' end
if (st.updated or "")~="" then h=h..'<span class="mgx-small mgx-muted">更新: '..esc(st.updated)..'</span>' end
if (st.next or "")~="" then h=h..'<span class="mgx-small mgx-muted">下次: '..esc(st.next)..'</span>' end
h=h..'</span><br><button type="button" class="cbi-button cbi-button-action mgx-sync" onclick="doSync(\''..section..'\',this)">手动同步</button>'
h=h..'<span id="sr-'..section..'" class="mgx-sync-result" role="status"></span>'
return h
end
@@ -117,24 +134,53 @@ 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")
local job=luci.dispatcher.build_url("admin/services/minigate/ddns_job")
return[[<script type="text/javascript">
function doSync(s,b){
var e=document.getElementById("sr-"+s),done=false;
function show(ok,text){var span=document.createElement("span");span.style.color=ok?"#4caf50":"#f44336";span.textContent=text;e.textContent="";e.appendChild(span);}
function finish(x,d){
if(done)return;done=true;
b.disabled=false;b.textContent="手动同步";
if(d&&d.success){
show(true,"\u2713 "+[d.ip,d.ip6].filter(Boolean).join(" / "));
setTimeout(function(){location.reload()},1500);
}else{
show(false,"\u2717 "+((d&&d.message)||(d?"同步失败,查看日志":"请求失败,请检查网络后重试")));
}
}
b.disabled=true;b.textContent="同步中...";e.textContent="";
var p=XHR.post("]]..url..[[",{section:s},finish);
var mgFormDirty=false;
document.addEventListener('change',function(e){if(e.target&&e.target.form&&e.target.className.indexOf('mgx-')<0)mgFormDirty=true;},true);
function mgEsc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
function mgShowState(s,d){
var box=document.getElementById("ss-"+s);if(!box||!d)return;
var labels={ok:['✓ 正常','mgx-ok'],partial:['⚠ 部分成功','mgx-warn'],error:['✗ 异常','mgx-err'],stopped:['已停止','mgx-muted']};
var l=labels[d.status]||['未同步','mgx-muted'];
var h='<span class="'+l[1]+'">'+l[0]+'</span>';
if(d.status=='error'&&d.message)h+=' <span class="mgx-err">'+mgEsc(d.message)+'</span>';
if(d.ip)h+='<span class="mgx-small mgx-muted">A: '+mgEsc(d.ip)+'</span>';
if(d.ip6)h+='<span class="mgx-small mgx-info">AAAA: '+mgEsc(d.ip6)+'</span>';
if(d.message&&d.status!='error')h+='<span class="mgx-small mgx-muted">'+mgEsc(d.message)+'</span>';
if(d.updated)h+='<span class="mgx-small mgx-muted">更新: '+mgEsc(d.updated)+'</span>';
if(d.next)h+='<span class="mgx-small mgx-muted">下次: '+mgEsc(d.next)+'</span>';
box.innerHTML=h;
}
function mgRequest(method,url,args,cb){
var done=false;
function finish(x,d){if(done)return;done=true;cb(d);}
var p=XHR[method](url,args,finish);
if(p&&typeof p.catch==="function")p.catch(function(){finish(null,null)});
}
// 同步在后台进行,完成后原地更新状态,不刷新页面,表单里未保存的修改不会丢
function doSync(s,b){
var e=document.getElementById("sr-"+s),tries=0;
function show(cls,text){e.innerHTML='<span class="'+cls+'">'+mgEsc(text)+'</span>';}
function done(){b.disabled=false;b.textContent="手动同步";}
if(mgFormDirty&&!confirm("表单里有尚未保存的修改。手动同步使用已保存的配置,确定继续吗?"))return;
b.disabled=true;b.textContent="同步中...";e.textContent="";
function poll(){
mgRequest('get',"]]..job..[[",{section:s},function(d){
if(d&&d.running&&tries++<90){setTimeout(poll,2000);return;}
done();
if(!d){show('mgx-err',"✗ 无法读取同步结果,请稍后刷新页面");return;}
if(d.running){show('mgx-warn',"仍在同步,请稍后刷新页面查看");return;}
mgShowState(s,d);
if(d.success)show('mgx-ok',"✓ 同步完成 "+[d.ip,d.ip6].filter(Boolean).join(" / "));
else show('mgx-err',"✗ "+(d.message||"同步失败,查看日志"));
});
}
mgRequest('post',"]]..url..[[",{section:s},function(d){
if(!d||!d.success){done();show('mgx-err',"✗ "+((d&&d.message)||"请求失败,请检查网络后重试"));return;}
setTimeout(poll,1500);
});
}
</script>]]
end
+135 -104
View File
@@ -1,20 +1,19 @@
local m, s, o
local sys = require "luci.sys"
m = Map("minigate", "minigate", "轻量级网关管理:动态域名解析(IPv4/IPv6双栈)、SSL 证书签发、反向代理。")
-- 保存并应用后由 procd 的配置触发器重载服务(见 /etc/init.d/minigate)
m = Map("minigate", "MiniGate", "轻量级网关管理:动态域名解析(IPv4/IPv6双栈)、SSL 证书签发、反向代理和登录防护。")
m.on_after_commit = function(self)
local en = self.uci:get("minigate", "global", "enabled")
sys.call("/bin/sh /usr/lib/minigate/after_commit.sh " .. (en == "1" and "restart" or "stop") .. " >/dev/null 2>&1")
end
-- 总开关放在最上方,新安装时不必翻到页面底部寻找
s = m:section(NamedSection, "global", "global", "全局设置")
s.anonymous = true
o = s:option(Flag, "enabled", "启用 MiniGate")
o.description = "总开关。关闭后停止 DDNS 定时任务、证书续期和反向代理;登录防护单独控制。点击「保存并应用」后生效。"
o.rmempty = false
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 = s:option(DummyValue, "_status")
o.rawhtml = true
o.cfgvalue = function()
local su = luci.dispatcher.build_url("admin/services/minigate/status")
@@ -29,11 +28,14 @@ o.cfgvalue = function()
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-wrap{--mg-danger-hover:#fff5f5;--mg-danger-border:#f1b8b8}
.mg-notice{padding:12px 16px;border-radius:8px;border:1px solid var(--mg-border);border-left:4px solid var(--mg-warning);background:var(--mg-surface);color:var(--mg-text);font-size:13px;line-height:1.6}
.mg-notice[hidden]{display:none}
.mg-row{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
.mg-dot{display:inline-block;flex:0 0 auto;width:8px;height:8px;border-radius:50%;transform:translateY(-1px)}
.mg-sub{color:var(--mg-text-muted)}
.mg-warn-text{color:var(--mg-warning)}
.mg-danger-text{color:var(--mg-danger)}
.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}
@@ -90,13 +92,13 @@ o.cfgvalue = function()
.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)}
:root[data-darkmode="true"] .mg-wrap{--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)}
:root:not([data-darkmode]) .mg-wrap{--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}
@media(max-width:640px){.mg-uninstall-trigger{width:100%}.mg-update-actions{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}
.mg-table{min-width:0}.mg-table thead{display:none}
.mg-table tbody tr{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;padding:12px 14px;border-bottom:1px solid var(--mg-border-soft)}
.mg-table tbody tr:nth-child(even){background:var(--mg-surface-alt)}
@@ -113,9 +115,7 @@ o.cfgvalue = function()
</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-disabled" class="mg-notice" role="status" hidden>MiniGate 尚未启用:勾选上方「启用 MiniGate」,再点击页面底部的「保存并应用」。</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>
@@ -177,15 +177,28 @@ o.cfgvalue = function()
<div id="mg-v-list" class="mg-table-wrap"><div class="mg-empty">加载中...</div></div>
</div>
<div id="mg-danger" 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 class="mg-update-version">卸载 MiniGate</div>
<div class="mg-update-message">停止所有服务并移除程序;卸载时可选择是否保留配置、证书和封禁记录。</div>
</div>
<div class="mg-update-actions">
<button type="button" id="mg-uninstall-open" class="cbi-button mg-uninstall-trigger" onclick="mgOpenUninstallDialog()">卸载</button>
</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>
<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>
<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>
@@ -338,33 +351,6 @@ 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');
@@ -419,7 +405,7 @@ function mgStartUninstall(){
return;
}
confirm.textContent='卸载中...';
status.textContent='正在移除 minigate,完成后将返回服务页面。';
status.textContent='正在移除 MiniGate,完成后将返回服务页面。';
setTimeout(function(){location.href=']] .. services_url .. [[';},3000);
});
}
@@ -564,8 +550,6 @@ document.addEventListener('keydown',function(e){
}
});
mgPreparePageHeader();
var limitSel=document.getElementById('mg-v-limit');
if(limitSel){
limitSel.value=_visitorLimit;
@@ -595,48 +579,106 @@ function mgLoadStatus(){
});
}
function mgRenderStatus(d){
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';
var MG_DDNS_STATE={ok:['\u2713 正常','success'],partial:['\u26a0 部分成功','warning'],error:['\u26a0 异常','danger'],stopped:['已停止','muted'],unknown:['未同步','muted'],disabled:['未启用','muted']};
function mgStateHtml(label,color){
return '<span style="color:'+mgColor(color)+'">'+escHtml(label)+'</span>';
}
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='';}
function mgDot(color){
return '<span class="mg-dot" style="background:'+mgColor(color)+'"></span>';
}
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;
function mgRenderDdns(d){
var d1=document.getElementById('mg-d1'),d2=document.getElementById('mg-d2');
var card=document.getElementById('mg-ddns-card');
var list=mgList(d.ddns_list)||[];
if(!list.length){
d1.innerHTML=mgStateHtml('未配置','muted');d2.textContent='';
card.style.setProperty('--accent',mgColor('muted'));
return;
}
// 卡片颜色取所有已启用记录中最差的状态,任一记录异常都能看到
var overall=MG_DDNS_STATE[d.ddns_status||'disabled']||MG_DDNS_STATE.unknown;
card.style.setProperty('--accent',mgColor(overall[1]));
d1.innerHTML=mgStateHtml(overall[0],overall[1]);
var h='';
for(var i=0;i<list.length;i++){
var e=list[i],st=MG_DDNS_STATE[e.status]||MG_DDNS_STATE.unknown;
var ips=[e.last_ip?'A '+e.last_ip:'',e.last_ip6?'AAAA '+e.last_ip6:''].filter(Boolean).join(' · ');
h+='<div class="mg-row">'+mgDot(st[1])+'<strong>'+escHtml(e.domain||'--')+'</strong>'+(ips?'<span class="mg-sub">'+escHtml(ips)+'</span>':'')+'</div>';
if(e.status_msg&&(e.status=='error'||e.status=='partial'))h+='<div class="mg-'+(e.status=='error'?'danger':'warn')+'-text">'+escHtml(e.status_msg)+'</div>';
if(e.last_update||e.next_sync)h+='<div class="mg-sub">'+escHtml((e.last_update?'更新 '+e.last_update:'')+(e.next_sync?' · 下次 '+e.next_sync:''))+'</div>';
}
d2.innerHTML=h;
}
function mgCertLine(c){
var left=typeof c.days_left==='number'?c.days_left:null;
var color=left===null?'muted':(left<0?'danger':(left<14||c.staging?'warning':'success'));
var when=c.expires_date?('到期 '+c.expires_date+(left===null?'':(left<0?'(已过期)':'(剩 '+left+' 天)'))):'到期时间未知';
return '<div class="mg-row">'+mgDot(color)+'<strong>'+escHtml(c.domain)+'</strong><span class="mg-sub">'+escHtml(when)+'</span>'+(c.staging?'<span class="mg-warn-text">测试证书,浏览器不信任</span>':'')+'</div>';
}
function mgRenderCerts(d){
var a1=document.getElementById('mg-a1'),a2=document.getElementById('mg-a2');
var card=document.getElementById('mg-cert-card');
var certs=mgList(d.certs)||[];
var acme=d.acme||{};
var label,color;
if(certs.length){
var expired=false,soon=false,staging=false;
for(var i=0;i<certs.length;i++){
var left=certs[i].days_left;
if(typeof left==='number'&&left<0)expired=true;
else if(typeof left==='number'&&left<14)soon=true;
if(certs[i].staging)staging=true;
}
if(expired){label='\u26a0 有证书已过期';color='danger';}
else if(soon){label='\u26a0 即将到期';color='warning';}
else if(staging){label='\u26a0 测试证书';color='warning';}
else{label='\u2713 有效';color='success';}
}else if(acme.enabled=='1'){label='暂无证书';color='muted';}
else{label='未启用';color='muted';}
card.style.setProperty('--accent',mgColor(color));
a1.innerHTML=mgStateHtml(label,color);
var h='';
for(var j=0;j<certs.length;j++)h+=mgCertLine(certs[j]);
if(acme.result=='error'||acme.result=='partial')h+='<div class="mg-'+(acme.result=='error'?'danger':'warn')+'-text">最近一次签发:'+escHtml(acme.message)+'</div>';
a2.innerHTML=h;
}
function mgRenderProxy(d){
var p1=document.getElementById('mg-p1'),p2=document.getElementById('mg-p2');
var card=document.getElementById('mg-proxy-card');
var warnings=mgList(d.proxy_warnings)||[];
var label,color;
if(!d.proxy_running){label='已停止';color='muted';}
else if(d.proxy_check=='error'){label='\u26a0 运行中,最近一次配置未生效';color='danger';}
else if(warnings.length){label='\u26a0 运行中,有规则未生效';color='warning';}
else{label='\u2713 运行中';color='success';}
card.style.setProperty('--accent',mgColor(color));
p1.innerHTML=mgStateHtml(label,color);
var h='';
var rules=mgList(d.proxy_rules)||[];
for(var i=0;i<rules.length;i++){
var r=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 class="mg-sub" style="font-size:10px">[IPv6]</span>':'';
h+='<div><a class="mg-link" href="'+escHtml(url)+'" target="_blank" rel="noopener noreferrer">'+escHtml(url)+'</a>'+v6tag+' \u2192 '+escHtml(r.target)+'</div>';
}
if(!rules.length&&d.proxy_running)h='Nginx 运行中,暂无代理规则';
if(d.proxy_check=='error'&&d.proxy_message)h+='<div class="mg-danger-text">'+escHtml(d.proxy_message)+'</div>';
for(var k=0;k<warnings.length;k++)h+='<div class="mg-warn-text">'+escHtml(warnings[k])+'</div>';
p2.innerHTML=h;
}
function mgRenderStatus(d){
var notice=document.getElementById('mg-disabled');
if(notice)notice.hidden=(d.enabled=='1');
mgRenderDdns(d);
mgRenderCerts(d);
mgRenderProxy(d);
}
// 页面在后台时暂停轮询,切回前台立即刷新
@@ -653,15 +695,4 @@ 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
@@ -2,13 +2,10 @@ 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("/bin/sh /usr/lib/minigate/after_commit.sh reload >/dev/null 2>&1")
end
-- 保存并应用后由 procd 按新配置重启登录防护
m = Map("minigate", "MiniGate - 登录防护",
"监控 SSH(dropbear)和 LuCI 网页登录失败,达到阈值自动封禁源 IP,支持 IPv4 和 IPv6。" ..
"适用于 SSH 端口被映射到公网、防爆破。LAN 私有地址自动豁免。")
-- ============== 实时状态 ==============
s = m:section(NamedSection, "login_guard", "login_guard", "实时状态")
@@ -106,6 +103,9 @@ o.cfgvalue = function()
.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-panel-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:12px 16px;border-top:1px solid var(--lg-border-soft)}
.lg-foot-note{color:var(--lg-text-muted);font-size:12px}
.lg-btn-outline-danger{height:30px;border:1px solid var(--lg-danger);border-radius:6px;padding:0 12px;background:transparent;color:var(--lg-danger);font-size:12px;cursor:pointer}
.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)}
@@ -166,12 +166,15 @@ o.cfgvalue = function()
<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>
<input id="lg-ban-input" class="lg-input" type="text" placeholder="输入 IPv4 或 IPv6 手动封禁" />
<button type="button" class="lg-btn lg-btn-primary" onclick="lgManualBan()">封禁</button>
</div>
</div>
<div id="lg-banned-list" class="lg-table-wrap"><div class="lg-empty">加载中...</div></div>
<div class="lg-panel-foot">
<span class="lg-foot-note">清空后所有已封禁 IP 立即恢复访问,无法撤销。</span>
<button type="button" class="lg-btn-outline-danger" onclick="lgFlushAll()">清空全部封禁</button>
</div>
</div>
<div class="lg-panel">
@@ -293,7 +296,7 @@ function lgUnban(ip,btn){
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;}
if(!ip.match(/^\d+\.\d+\.\d+\.\d+$/)&&!(ip.match(/^[0-9a-fA-F:]+$/)&&ip.split(':').length>2)){alert('IP 格式不对');return;}
lgRequest('post',']] .. bu .. [[',{ip:ip},function(x,d){
if(d&&d.success){
document.getElementById('lg-ban-input').value='';
@@ -470,12 +473,12 @@ o.description = "开启后将自动监控 SSH/LuCI 失败登录。LAN IP 永远
o.rmempty = false
o = s:option(Value, "threshold", "失败次数阈值")
o.description = "在「失败窗口」时间内累积达到此次数则封禁。"
o.datatype = "uinteger"
o.description = "在「失败窗口」时间内累积达到此次数则封禁,范围 1-100。"
o.datatype = "range(1,100)"
o.default = "3"
o.placeholder = "3"
o = s:option(ListValue, "window", "失败窗口(秒)")
o = s:option(ListValue, "window", "失败窗口")
o.description = "在多长时间内累计失败次数。超过此时间未再失败则计数清零。"
o:value("300", "5 分钟")
o:value("600", "10 分钟")
@@ -494,8 +497,22 @@ 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"
o.description = "LAN 私有地址(192.168.x、10.x、172.16-31.x、IPv6 fd/fe80)和 LAN 分配的 IPv6 子网默认豁免," ..
"这里再加其他 IP 或子网,支持 IPv4/IPv6 和任意前缀长度。"
o.placeholder = "如 8.8.8.8、203.0.113.0/24 或 2001:db8::/48"
-- 只接受地址或「地址/前缀长度」,与 login_guard.sh 的匹配方式一致
o.validate = function(self, value)
local dt = require "luci.cbi.datatypes"
local values = type(value) == "table" and value or {value}
for _, item in ipairs(values) do
item = tostring(item or "")
local addr, bits = item:match("^([^/]+)/(%d+)$")
addr = addr or item
bits = tonumber(bits)
local ok = (dt.ip4addr(addr) and (not bits or bits <= 32)) or (dt.ip6addr(addr) and (not bits or bits <= 128))
if not ok then return nil, "白名单格式不正确:" .. item end
end
return value
end
return m
+110 -87
View File
@@ -1,23 +1,19 @@
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("/bin/sh /usr/lib/minigate/after_commit.sh proxy >/dev/null 2>&1")
end
-- 保存并应用后由 procd 的配置触发器重新生成并重载反向代理
m = Map("minigate", "MiniGate - 反向代理", "先配置代理规则,页面下方是 HTTP 跳转、IPv6、局域网解析和访问地区等高级设置。保存并应用后自动生效。")
local ddns_domains = {}
local ddns_ok = {}
local wildcard_domains = {}
local normal_domains = {}
local ctl = require "luci.controller.minigate"
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 (ctl.ddns_state(d).status or "") == "ok" then ddns_ok[d] = true end
if d:match("^%*%.") then
wildcard_domains[#wildcard_domains + 1] = d
else
@@ -26,68 +22,15 @@ uc:foreach("minigate", "ddns", function(sec)
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
local lan_ip_auto = uc:get("network", "lan", "ipaddr")
if type(lan_ip_auto) == "table" then lan_ip_auto = lan_ip_auto[1] end
lan_ip_auto = lan_ip_auto and lan_ip_auto:match("^(%d+%.%d+%.%d+%.%d+)")
s = m:section(NamedSection, "global", "global", "局域网直连解析",
"让局域网设备把本页的代理域名直接解析到本机 LAN 地址:访问不经过公网 IP、主路由回流和上游 DNS 缓存,也不进入代理;公网解析不变。" ..
"仅对使用本机 DNS(dnsmasq)的设备生效,且要求公网端口映射没有改端口号。")
s.anonymous = true
o = s:option(Flag, "lan_dns", "启用局域网直连解析")
o.default = "0"
o.rmempty = false
o = s:option(Value, "lan_ip", "解析到的 LAN 地址", "留空自动使用 LAN 口 IPv4 地址。")
o.datatype = "ip4addr"
o.placeholder = lan_ip_auto and ("自动(" .. lan_ip_auto .. ")") or "自动"
o:depends("lan_dns", "1")
s = m:section(NamedSection, "global", "global", "公网访问范围",
"启用后,仅允许所选地区的公网 IP 访问本插件的所有反代站点(含 HTTP 跳转和 WebSocket);其他地区及归属地未知的 IP 直接断开。" ..
"局域网私有地址和回环地址继续可用,不影响 SSH、RustDesk 等其他服务。IPv4/IPv6 均检查,按实际连接来源判断。" ..
"使用随版本附带的离线地址库,归属地可能存在误差;外出或使用外省代理出口也会被拒绝。")
s.anonymous = true
o = s:option(Flag, "access_enabled", "限制公网访问地区")
o.default = "0"
o.rmempty = false
local access_regions = {}
local regions_file = io.open("/usr/lib/minigate/geo/regions.txt", "r")
if regions_file then
for line in regions_file:lines() do
local code, name = line:match("^(%d+) (.+)$")
if code then access_regions[#access_regions + 1] = {code, name} end
end
regions_file:close()
s = m:section(SimpleSection); s.template = "minigate/style"
if #ddns_domains == 0 then
s = m:section(SimpleSection); s.template = "minigate/proxy_empty"
end
o = s:option(DynamicList, "access_regions", "允许的省级地区", "可选择多个地区;只允许四川时,仅保留“四川省”。关闭地区限制即恢复所有地区访问。")
o.default = "510000"
o.rmempty = false
o:depends("access_enabled", "1")
local valid_regions = {}
for _, region in ipairs(access_regions) do
o:value(region[1], region[2])
valid_regions[region[1]] = true
end
o.validate = function(self, value, section)
local values = type(value) == "table" and value or {value}
if #values == 0 then return nil, "请选择至少一个允许的地区" end
for _, code in ipairs(values) do
if not valid_regions[code] then return nil, "请从列表中选择有效的省级地区" end
end
return value
-- 子域名前缀:一段字母、数字或连字符
local function valid_prefix(value)
value = tostring(value or "")
return #value >= 1 and #value <= 63 and value:match("^[%w%-]+$") and not value:match("^%-") and not value:match("%-$")
end
-- ================================
@@ -96,15 +39,12 @@ end
if #wildcard_domains > 0 then
s = m:section(TypedSection, "proxy_wildcard", "通配符域名",
"配置监听端口和HTTPS,具体转发由「子域名」控制。")
s.anonymous = false
"配置监听端口和HTTPS,具体转发由「子域名」控制。禁用后,它下面的子域名一起停用。")
s.anonymous = true
s.addremove = true
s.template = "cbi/tblsection"
s.sectionhead = "名称"
o = s:option(ListValue, "enabled", "启用")
o:value("1", "✓ 启用")
o:value("0", "✗ 禁用")
o = s:option(Flag, "enabled", "启用")
o.default = "1"
o.rmempty = false
@@ -131,12 +71,16 @@ o.rmempty = false
-- ================================
-- 子域名规则
-- ================================
s = m:section(TypedSection, "subproxy", "子域名规则")
s = m:section(TypedSection, "subproxy", "子域名规则", "所有站点都自动支持 WebSocket。")
s.anonymous = true
s.addremove = true
s.sortable = true
s.template = "cbi/tblsection"
o = s:option(Flag, "enabled", "启用")
o.default = "1"
o.rmempty = false
o = s:option(ListValue, "parent_domain", "主域名")
o:value("", "--")
for _, d in ipairs(wildcard_domains) do
@@ -148,8 +92,13 @@ o.rmempty = false
o = s:option(Value, "prefix", "前缀")
o.rmempty = false
o.placeholder = "app"
o.validate = function(self, value)
if valid_prefix(value) then return value end
return nil, "前缀只能包含字母、数字和连字符,例如 app"
end
o = s:option(Value, "target_addr", "目标地址")
o.datatype = "host"
o.rmempty = false
o.placeholder = "192.168.1.100"
@@ -164,9 +113,9 @@ 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>'
return '<strong class="mgx-ok">' .. (prefix .. "." .. base):gsub("[<>&\"']", "") .. '</strong>'
end
return '<span style="color:#999">--</span>'
return '<span class="mgx-muted">--</span>'
end
end -- wildcard_domains
@@ -177,16 +126,13 @@ end -- wildcard_domains
if #normal_domains > 0 or #wildcard_domains == 0 then
s = m:section(TypedSection, "proxy", "代理规则",
"非通配符域名的反向代理。")
s.anonymous = false
"非通配符域名的反向代理。同一端口只能用于一种协议(HTTP 或 HTTPS),冲突的规则不会生效并在总览页提示。所有站点都自动支持 WebSocket。")
s.anonymous = true
s.addremove = true
s.template = "cbi/tblsection"
s.sortable = true
s.sectionhead = "名称"
o = s:option(ListValue, "enabled", "启用")
o:value("1", "✓ 启用")
o:value("0", "✗ 禁用")
o = s:option(Flag, "enabled", "启用")
o.default = "1"
o.rmempty = false
@@ -205,6 +151,7 @@ o.default = "443"
o.rmempty = false
o = s:option(Value, "target_addr", "目标地址")
o.datatype = "host"
o.rmempty = false
o.placeholder = "192.168.1.100"
@@ -219,12 +166,88 @@ o:value("0", "✗ 关闭")
o.default = "1"
o.rmempty = false
o = s:option(ListValue, "websocket", "WS")
o:value("0", "✗ 关闭")
o:value("1", "✓ 开启")
end -- normal_domains
s = m:section(NamedSection, "global", "global", "高级设置:HTTP 跳转与 IPv6",
"HTTP 跳转让每个已启用 HTTPS 的监听端口同时接受 HTTP,明文请求以 308 跳转到同域名、同端口的 HTTPS。")
s.anonymous = true
o = s:option(Flag, "http_redirect", "启用 HTTP 跳转")
o.default = "0"
o.rmempty = false
end -- normal_domains
o = s:option(Flag, "ipv6_listen", "监听 IPv6")
o.description = "开启后,反向代理同时监听 IPv4 和 IPv6 地址(listen [::]:端口)。"
o.default = "0"
o.rmempty = false
local lan_ip_auto = uc:get("network", "lan", "ipaddr")
if type(lan_ip_auto) == "table" then lan_ip_auto = lan_ip_auto[1] end
lan_ip_auto = lan_ip_auto and lan_ip_auto:match("^(%d+%.%d+%.%d+%.%d+)")
s = m:section(NamedSection, "global", "global", "高级设置:局域网直连解析",
"让局域网设备把本页的代理域名直接解析到本机 LAN 地址:访问不经过公网 IP、主路由回流和上游 DNS 缓存,也不进入代理;公网解析不变。" ..
"仅对使用本机 DNS(dnsmasq)的设备生效,且要求公网端口映射没有改端口号。")
s.anonymous = true
o = s:option(Flag, "lan_dns", "启用局域网直连解析")
o.default = "0"
o.rmempty = false
o = s:option(Value, "lan_ip", "解析到的 LAN 地址", "留空自动使用 LAN 口 IPv4 地址。")
o.datatype = "ip4addr"
o.placeholder = lan_ip_auto and ("自动(" .. lan_ip_auto .. ")") or "自动"
o:depends("lan_dns", "1")
s = m:section(NamedSection, "global", "global", "高级设置:公网访问范围",
"启用后,仅允许所选地区的公网 IP 访问本插件的所有反代站点(含 HTTP 跳转和 WebSocket);其他地区及归属地未知的 IP 直接断开。" ..
"局域网私有地址和回环地址继续可用,不影响 SSH、RustDesk 等其他服务。IPv4/IPv6 均检查,按实际连接来源判断。" ..
"使用随版本附带的离线地址库,归属地可能存在误差;外出或使用外省代理出口也会被拒绝。")
s.anonymous = true
o = s:option(Flag, "access_enabled", "限制公网访问地区")
o.default = "0"
o.rmempty = false
local access_regions = {}
local regions_file = io.open("/usr/lib/minigate/geo/regions.txt", "r")
if regions_file then
for line in regions_file:lines() do
local code, name = line:match("^(%d+) (.+)$")
if code then access_regions[#access_regions + 1] = {code, name} end
end
regions_file:close()
end
o = s:option(MultiValue, "access_regions", "允许的省级地区", "勾选允许访问的地区,可多选。关闭地区限制即恢复所有地区访问。")
o.widget = "checkbox"
o.size = 6
o.default = "510000"
o.rmempty = false
o:depends("access_enabled", "1")
-- 旧版本以 list 保存;统一转成以空格分隔的字符串供复选框回显
o.cfgvalue = function(self, section)
local value = m.uci:get("minigate", section, "access_regions")
if type(value) == "table" then return table.concat(value, " ") end
return value
end
local valid_regions = {}
for _, region in ipairs(access_regions) do
o:value(region[1], region[2])
valid_regions[region[1]] = true
end
o.validate = function(self, value, section)
local values = {}
if type(value) == "table" then
values = value
else
for code in tostring(value or ""):gmatch("%S+") do values[#values + 1] = code end
end
if #values == 0 then return nil, "请选择至少一个允许的地区" end
for _, code in ipairs(values) do
if not valid_regions[code] then return nil, "请从列表中选择有效的省级地区" end
end
return value
end
return m
+37 -13
View File
@@ -1,22 +1,46 @@
<%+header%>
<%+minigate/style%>
<style>
.mgx-log-tools{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:12px}
.mgx-log{margin:0;max-height:600px;overflow-y:auto;padding:15px;border-radius:8px;border:1px solid var(--mgx-border);background:var(--mgx-log-bg);color:var(--mgx-log-text);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
</style>
<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>
<div class="mgx-log-tools">
<label for="ls">来源</label>
<select id="ls" class="mgx-select" style="min-width:140px">
<option value="all">全部(按时间合并)</option><option value="ddns">DDNS</option><option value="acme">证书</option><option value="proxy">反向代理</option><option value="login_guard">登录防护</option><option value="update">软件更新</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>
<button type="button" class="cbi-button cbi-button-action" onclick="rl(true)">刷新</button>
<label><input type="checkbox" id="ar" checked> 自动刷新(5 秒)</label>
<span id="lh" class="mgx-small mgx-muted" role="status"></span>
</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>
<pre id="lc" class="mgx-log">加载中...</pre>
<script type="text/javascript">
var lt;
var lt,loading=false;
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&&typeof d.log==='string'){c.textContent=d.log||'(暂无)';c.scrollTop=c.scrollHeight}else c.textContent='(无法读取)'})}
function sa(){if(lt)clearInterval(lt);if(document.getElementById('ar').checked)lt=setInterval(function(){if(!document.hidden)rl()},5000)}
// 只有本来就停在底部时才跟随新日志;往上翻看旧内容时不被拉回
function atBottom(c){return c.scrollHeight-c.scrollTop-c.clientHeight<24}
function rl(jump){
if(loading)return;
loading=true;
var s=document.getElementById('ls').value,c=document.getElementById('lc'),follow=jump||atBottom(c),done=false;
function finish(x,d){
if(done)return;done=true;loading=false;
if(d&&typeof d.log==='string'){
var top=c.scrollTop;
c.textContent=d.log||'(暂无)';
c.scrollTop=follow?c.scrollHeight:top;
document.getElementById('lh').textContent=follow?'':'已暂停跟随最新日志,滚动到底部恢复';
}else c.textContent='(无法读取)';
}
var p=XHR.get(lu,{source:s},finish);
if(p&&typeof p.catch==='function')p.catch(function(){finish(null,null)});
}
function sa(){if(lt)clearInterval(lt);if(document.getElementById('ar').checked)lt=setInterval(function(){if(!document.hidden)rl(false)},5000)}
document.getElementById('ar').addEventListener('change',sa);
document.addEventListener('visibilitychange',function(){if(!document.hidden&&document.getElementById('ar').checked)rl()});
document.getElementById('ls').addEventListener('change',rl);
rl();sa();
document.getElementById('lc').addEventListener('scroll',function(){if(atBottom(this))document.getElementById('lh').textContent=''});
document.addEventListener('visibilitychange',function(){if(!document.hidden&&document.getElementById('ar').checked)rl(false)});
document.getElementById('ls').addEventListener('change',function(){rl(true)});
rl(true);sa();
</script>
<%+footer%>
@@ -0,0 +1 @@
<div class="mgx-note is-info">还没有可用的域名。请先在<a href="<%=luci.dispatcher.build_url("admin", "services", "minigate", "ddns")%>">「动态DNS」</a>添加域名,再回到这里配置反向代理。</div>
+26
View File
@@ -0,0 +1,26 @@
<style>
/* MiniGate 普通配置页共用的配色:跟随 LuCI 深浅色主题,不再写死浅色 */
:root{--mgx-success:#2f9e44;--mgx-warning:#d9480f;--mgx-danger:#e03131;--mgx-info:#1971c2;--mgx-muted:#6b7280;--mgx-surface:#f6f8fb;--mgx-border:#d7dce3;--mgx-text:#1f2937;--mgx-control:#fff;--mgx-log-bg:#f6f8fb;--mgx-log-text:#1f2937}
:root[data-darkmode="true"]{--mgx-success:#78c98d;--mgx-warning:#d7a64a;--mgx-danger:#e58a84;--mgx-info:#7db7df;--mgx-muted:#9aa7b3;--mgx-surface:var(--background-color-high,#131a22);--mgx-border:var(--border-color-medium,rgba(148,163,184,.18));--mgx-text:var(--text-color-high,#d5dbe3);--mgx-control:var(--background-color-high,#111820);--mgx-log-bg:#0f141a;--mgx-log-text:#d5dbe3}
@media(prefers-color-scheme:dark){
:root:not([data-darkmode]){--mgx-success:#78c98d;--mgx-warning:#d7a64a;--mgx-danger:#e58a84;--mgx-info:#7db7df;--mgx-muted:#9aa7b3;--mgx-surface:#131a22;--mgx-border:rgba(148,163,184,.18);--mgx-text:#d5dbe3;--mgx-control:#111820;--mgx-log-bg:#0f141a;--mgx-log-text:#d5dbe3}
}
.mgx-ok{color:var(--mgx-success)}
.mgx-warn{color:var(--mgx-warning)}
.mgx-err{color:var(--mgx-danger)}
.mgx-info{color:var(--mgx-info)}
.mgx-muted{color:var(--mgx-muted)}
.mgx-small{display:block;font-size:12px;line-height:1.7}
.mgx-domain{font-weight:700}
.mgx-note{margin:8px 0 12px;padding:10px 14px;border-radius:6px;border:1px solid var(--mgx-border);border-left:4px solid var(--mgx-warning);background:var(--mgx-surface);color:var(--mgx-text);font-size:13px;line-height:1.6}
.mgx-note.is-ok{border-left-color:var(--mgx-success)}
.mgx-note.is-err{border-left-color:var(--mgx-danger)}
.mgx-note.is-info{border-left-color:var(--mgx-info)}
.mgx-note a{color:var(--mgx-info)}
.mgx-select{min-width:220px;max-width:100%;padding:5px 10px;border-radius:4px;border:1px solid var(--mgx-border);background:var(--mgx-control);color:var(--mgx-text)}
.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}
@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>
+64
View File
@@ -0,0 +1,64 @@
## 审查问题与界面优化
这一版集中修复代码审查中发现的问题,并调整各页面界面。界面名称统一为 **MiniGate**;OpenWrt 包名仍为 `minigate`。
### 修复
- 通配符主域名停用或删除后,其子域名不再退回成 443 端口的明文 HTTP 站点。之前在同一端口还有 HTTPS 规则时,这还会让 Nginx 因两个默认站点而无法加载。现在子域名随主域名一起停用,也可以单独停用。
- 同一端口同时配置了 HTTP 和 HTTPS 规则时,冲突的规则会被跳过,并在总览页说明原因,不再生成 Nginx 无法加载的配置。
- 配置检查失败时,自动恢复上一份可用配置,正在运行的站点不受影响。生成过程加了锁,保存设置、证书续期和软件更新同时触发时不会互相覆盖。
- 登录防护支持 IPv6 来源;白名单支持 IPv4/IPv6 和任意前缀长度。防火墙没有接受的封禁会如实记录为失败,不再误记为已封禁。
- 封禁集合和拦截规则随防火墙每次重载自动加载;重载清空的封禁会在 30 秒内恢复,之前最多要等 5 分钟。
- DDNS 只向 Cloudflare 推送公网地址。软路由在上级路由 NAT 之后时,网卡上的内网地址会被拒绝,并提示改用外部 URL。
- 日志页「全部」改为按时间合并各来源。之前只是把各文件首尾相接后取最后 200 行,实际几乎看不到 DDNS 和证书日志。
### 可靠性与安全
- 服务改由 procd 管理:Nginx 和登录防护异常退出后自动拉起,停止时结束所有子进程。LuCI「保存并应用」后只重载受影响的部分,不再轮询等待配置写入。
- DDNS 与证书的运行状态移到 `/var/run/minigate`,定时检查不再反复改写 `/etc/config/minigate`。升级时会清除旧版写在配置文件里的状态字段。
- acme.sh 固定为 3.1.6,下载后先校验 SHA-256 再安装。证书剩余超过 30 天时,手动签发不会重复申请,避免触发 Let's Encrypt 频率限制。
- 签发证书和手动同步 DDNS 改在后台执行,页面轮询结果,不受网页请求超时影响。
- 外部 IP 查询默认改用 HTTPS 地址,失败时依次回退。
- 所有站点自动支持 WebSocket。
- 访客归属地优先查随包的离线地址库;境外地址才用 HTTPS 接口查询,缓存有数量上限。
- 定时任务只管理本插件的脚本,不会再误删用户自己写的、名字里含 minigate 的任务。
- 移除旧版地区日志扫描。已启用旧版限制的配置,升级时迁移为反代请求前准入,同时移除旧的全局防火墙集合。
### 界面
- 总开关移到总览页顶部,未启用时会显示提示。
- DDNS 卡片列出全部记录,按最差的状态着色。
- 证书卡片和证书页显示到期日期、剩余天数,并标出测试证书。
- 反代地址可以直接点击,配置问题会显示在反代卡片里。
- 卸载移到总览底部的「危险操作」区。
- 日志页新增登录防护和软件更新来源;往上翻看旧日志时,不再被自动拉回底部。
- DDNS 手动同步在原位置更新状态,不再刷新整页,表单里未保存的修改不会丢;有未保存修改时会先提示。
- 反代页改为规则在前、高级设置在后,「监听 IPv6」也移到这里;地区改用复选框,启用开关统一为勾选框;没有域名时会引导到动态 DNS 页。
- 登录防护页的「清空全部封禁」移到列表底部;手动封禁支持 IPv6。
- DDNS、反代、证书和日志页跟随 LuCI 深色主题。
- 表单增加校验:域名、Zone ID、API 令牌、检查间隔、失败阈值和白名单。
## 升级说明
- 升级后服务改由 procd 管理。LuCI 的「保存并应用」会自动重载服务,不需要再手动重启。
- 防火墙下次重载时才会从随包文件加载新的封禁集合;在此之前,登录防护会自行创建同名集合和规则,封禁照常生效。
- 旧版写在配置文件里的 DDNS 和证书状态字段会被清除。升级后首次同步前,状态显示为「未同步」。
- 子域名所属的通配符主域名未启用时,该子域名不再生效。如果之前依赖这种回退的明文访问,请启用主域名,或改为普通代理规则。
## 验证
本地 127 项测试全部通过,无跳过。其中包括 LuaJIT 下的 LuCI 页面加载与表单校验、真实 Nginx 的转发与拦截、子域名跳过、端口冲突、配置回滚、登录防护 IPv6 解析与 procd 停止,以及 DDNS 公网地址校验。
另外两项检查:
- 用 `nft -c` 在 OpenWrt 25.12 上校验了封禁 include 文件,校验结果不写入防火墙。
- 用 `sh -n` 检查了 12 个 Shell 脚本的语法。
本次不自动更新路由器。
## 安装包
- opkg 安装包:`minigate_2026.10.28_all.ipk`。
- 源码安装包:`minigate-v2026.10.28-src.tar.gz`。
- 兼容旧更新器:`luci-app-minigate-v2026.10.28-src.tar.gz`。
- 使用随附的 `SHA256SUMS` 校验文件完整性。
+106 -75
View File
@@ -1,93 +1,124 @@
#!/bin/sh /etc/rc.common
START=99
STOP=10
LOGIN_GUARD=/usr/lib/minigate/login_guard.sh
USE_PROCD=1
# === 启动登录防护 watcher(独立子进程) ===
start_login_guard() {
local lg_en=$(uci -q get minigate.login_guard.enabled)
[ "$lg_en" = "1" ] || return 0
# 已在跑则跳过
pgrep -f "^/bin/sh ${LOGIN_GUARD} run" >/dev/null 2>&1 && return 0
/bin/sh "$LOGIN_GUARD" run >/dev/null 2>&1 &
logger -t minigate "登录防护已启动"
PROG_DIR="${MINIGATE_LIB_DIR:-/usr/lib/minigate}"
PROXY="$PROG_DIR/proxy.sh"
LOGIN_GUARD="$PROG_DIR/login_guard.sh"
NGINX_CONF="${MINIGATE_NGINX_CONF:-/etc/minigate/nginx/minigate.conf}"
STATE_DIR="${MINIGATE_STATE_DIR:-/var/run/minigate}"
CRON_FILE="${MINIGATE_CRON_FILE:-/etc/crontabs/root}"
MG_RELOADING=0
# 只匹配本插件脚本路径,不会删除用户自己写的、名字里恰好含 minigate 的任务
update_cron() {
local enabled="$1" tmp
mkdir -p "$(dirname "$CRON_FILE")"
tmp="${CRON_FILE}.minigate.$$"
if [ -f "$CRON_FILE" ]; then
grep -v '/usr/lib/minigate/' "$CRON_FILE" > "$tmp"
else
: > "$tmp"
fi
if [ "$enabled" = "1" ]; then
cat >> "$tmp" <<'EOF'
*/5 * * * * /bin/sh /usr/lib/minigate/ddns.sh
0 3 * * * /bin/sh /usr/lib/minigate/acme.sh renew
*/10 * * * * /bin/sh /usr/lib/minigate/proxy.sh trim-logs
EOF
fi
if [ -f "$CRON_FILE" ] && [ "$(cat "$tmp")" = "$(cat "$CRON_FILE")" ]; then
rm -f "$tmp"
return 0
fi
cat "$tmp" > "$CRON_FILE"
rm -f "$tmp"
# crond 是常驻进程,不能继承 procd 的服务锁
( close_procd_lock; /etc/init.d/cron restart ) >/dev/null 2>&1 || true
}
# 先结束子进程:logread -f / tail -f 忽略 SIGPIPE,父 shell 退出后不会自行结束
stop_process_tree() {
local child
for child in $(pgrep -P "$1" 2>/dev/null); do
stop_process_tree "$child"
done
kill "$1" >/dev/null 2>&1
# 按配置类型计算摘要:保存某一页时只重载受影响的部分
config_digest() {
uci -q show minigate 2>/dev/null | awk -v types=" $1 " '
{
key = $0; sub(/=.*/, "", key)
n = split(key, part, ".")
}
n == 2 { value = $0; sub(/^[^=]*=/, "", value); type[part[2]] = value }
index(types, " " type[part[2]] " ") { print }
' | md5sum | cut -d' ' -f1
}
# 只匹配实际启动形式;调用方命令行里恰好含有这段文本时(BusyBox pgrep 不排除祖先),不能结束调用方
stop_login_guard() {
local pids pid
pids=$(pgrep -f "^/bin/sh ${LOGIN_GUARD} run" 2>/dev/null)
for pid in $pids; do
stop_process_tree "$pid"
done
sleep 1
pids=$(pgrep -f "^/bin/sh ${LOGIN_GUARD} run" 2>/dev/null)
[ -n "$pids" ] && kill -9 $pids >/dev/null 2>&1
# 移除 nft 中的 drop 规则(保留 set,让现有封禁继续生效到自然过期)
local handle=$(nft -a list chain inet fw4 input 2>/dev/null | grep "minigate-login-guard" | grep -oE 'handle [0-9]+' | awk '{print $2}')
[ -n "$handle" ] && nft delete rule inet fw4 input handle "$handle" 2>/dev/null
logger -t minigate "登录防护已停止"
# procd 在 fd 1000 上持有服务锁;常驻或长时间运行的子进程继承它会挡住下一次服务操作。
# 用 eval 关闭,避免只支持个位数描述符的 shell 在解析本文件时出错。
close_procd_lock() {
[ -e /proc/self/fd/1000 ] && eval 'exec 1000>&-'
return 0
}
start() {
# === 登录防护可独立启用,与 minigate 主开关无关 ===
start_login_guard
local en=$(uci -q get minigate.global.enabled)
[ "$en" = "1" ] || { logger -t minigate "主功能未启用(仅登录防护可能已运行)"; return 0; }
logger -t minigate "启动..."
/bin/sh /usr/lib/minigate/proxy.sh start
/bin/sh /usr/lib/minigate/ddns.sh force &
# 设置 cron
sed -i '/minigate/d' /etc/crontabs/root 2>/dev/null
echo "*/5 * * * * /bin/sh /usr/lib/minigate/ddns.sh" >> /etc/crontabs/root
echo "0 3 * * * /bin/sh /usr/lib/minigate/acme.sh renew" >> /etc/crontabs/root
echo "*/2 * * * * /bin/sh /usr/lib/minigate/geofence.sh scan" >> /etc/crontabs/root
echo "*/10 * * * * /bin/sh /usr/lib/minigate/proxy.sh trim-logs" >> /etc/crontabs/root
/etc/init.d/cron restart 2>/dev/null
logger -t minigate "已启动"
run_background() {
( close_procd_lock; "$@" ) </dev/null >/dev/null 2>&1 &
}
stop() {
logger -t minigate "停止所有服务..."
# 停止反向代理
/bin/sh /usr/lib/minigate/proxy.sh stop
sleep 1
# 移除所有 cron 定时任务(DDNS + ACME)
sed -i '/minigate/d' /etc/crontabs/root 2>/dev/null
/etc/init.d/cron restart 2>/dev/null
# 清除 DDNS 运行状态
local sections=$(uci -q show minigate | grep '=ddns$' | cut -d. -f2 | cut -d= -f1)
for sec in $sections; do
uci -q set minigate.${sec}.status="stopped"
uci -q set minigate.${sec}.status_msg="已停止"
uci -q set minigate.${sec}.next_sync=""
done
uci commit minigate
# 停止登录防护
stop_login_guard
logger -t minigate "已停止(DDNS定时/ACME续期/反代/登录防护 全部关闭)"
start_service() {
mkdir -p "$STATE_DIR"
# 登录防护可独立启用,与 MiniGate 主开关无关;配置摘要变化时 procd 自动重启它
if [ "$(uci -q get minigate.login_guard.enabled)" = "1" ]; then
procd_open_instance login_guard
procd_set_param command /bin/sh "$LOGIN_GUARD" run
procd_set_param env MINIGATE_LG_DIGEST="$(config_digest login_guard)"
procd_set_param respawn
procd_close_instance
fi
if [ "$(uci -q get minigate.global.enabled)" != "1" ]; then
update_cron 0
/bin/sh "$PROXY" stop
/bin/sh "$PROG_DIR/ddns.sh" mark-stopped
return 0
fi
[ "${MINIGATE_SKIP_PREPARE:-0}" = "1" ] || /bin/sh "$PROXY" prepare
# 生成失败时 proxy.sh 已恢复上一份配置,继续用它运行,不因一次保存错误中断全部站点
if [ -s "$NGINX_CONF" ]; then
procd_open_instance nginx
procd_set_param command /bin/sh "$PROXY" run
procd_set_param env MINIGATE_STREAM="$(grep -c '^stream {' "$NGINX_CONF")"
procd_set_param respawn
procd_close_instance
/bin/sh "$PROXY" lan-dns
fi
update_cron 1
config_digest "global proxy proxy_wildcard subproxy" > "$STATE_DIR/digest.proxy"
config_digest "ddns" > "$STATE_DIR/digest.ddns"
[ "$MG_RELOADING" = "1" ] || run_background /bin/sh "$PROG_DIR/ddns.sh" force
}
restart() {
stop
sleep 1
# LuCI「保存并应用」后由 procd 调用:重新声明进程,只重载配置有变化的部分
reload_service() {
local old_proxy old_ddns
old_proxy=$(cat "$STATE_DIR/digest.proxy" 2>/dev/null)
old_ddns=$(cat "$STATE_DIR/digest.ddns" 2>/dev/null)
MG_RELOADING=1
start
MG_RELOADING=0
[ "$(uci -q get minigate.global.enabled)" = "1" ] || return 0
[ "$old_proxy" = "$(cat "$STATE_DIR/digest.proxy" 2>/dev/null)" ] || /bin/sh "$PROXY" hup
[ "$old_ddns" = "$(cat "$STATE_DIR/digest.ddns" 2>/dev/null)" ] || run_background /bin/sh "$PROG_DIR/ddns.sh" force
}
reload() {
/bin/sh /usr/lib/minigate/proxy.sh reload
/bin/sh /usr/lib/minigate/ddns.sh force
# 登录防护配置改了也需要重启 watcher
stop_login_guard
start_login_guard
stop_service() {
update_cron 0
/bin/sh "$PROXY" stop
/bin/sh "$PROG_DIR/ddns.sh" mark-stopped
rm -f "$STATE_DIR/digest.proxy" "$STATE_DIR/digest.ddns"
}
service_triggers() {
procd_add_reload_trigger "minigate"
}
+91 -50
View File
@@ -4,9 +4,30 @@ ACME_BIN="${ACME_HOME}/acme.sh"
CERT_DIR="${MINIGATE_CERT_DIR:-/etc/minigate/certs}"
LOGFILE="${MINIGATE_ACME_LOG:-/var/log/minigate-acme.log}"
PROXY_SCRIPT="${MINIGATE_PROXY_SCRIPT:-/usr/lib/minigate/proxy.sh}"
# 最近一次签发结果只放内存;证书本身的有效期由页面直接读取证书文件
STATE_FILE="${MINIGATE_ACME_STATE:-/var/run/minigate/acme.state}"
# 固定 acme.sh 版本并校验 GitHub 标签归档的 SHA-256,不再以 root 执行未校验的 master 分支
ACME_VERSION="3.1.6"
ACME_SHA256="0d3f9000ac44a6331314742a88c475f79134e24fc991997883652adc59efc486"
ACME_URL="https://github.com/acmesh-official/acme.sh/archive/refs/tags/${ACME_VERSION}.tar.gz"
# 证书剩余天数少于此值才强制重签,避免反复点击触发 Let's Encrypt 频率限制
RENEW_DAYS=30
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [ACME] $*" >> "$LOGFILE"; }
# write_state 结果 域名 说明
write_state() {
local tmp
mkdir -p "$(dirname "$STATE_FILE")" 2>/dev/null || return 0
tmp="${STATE_FILE}.$$"
{
printf 'result=%s\n' "$1"
printf 'domain=%s\n' "$2"
printf 'message=%s\n' "$3"
printf 'time=%s\n' "$(date '+%Y-%m-%d %H:%M:%S')"
} > "$tmp" && mv -f "$tmp" "$STATE_FILE"
}
safe_dir() { echo "$1" | sed 's/\*/_wildcard_/g'; }
cert_path_for_domain() {
@@ -22,23 +43,24 @@ cert_is_valid() {
[ -f "$cert" ] && openssl x509 -checkend 0 -noout -in "$cert" >/dev/null 2>&1
}
cert_valid_for_days() {
local cert="$1" days="$2"
[ -f "$cert" ] && openssl x509 -checkend $((days * 86400)) -noout -in "$cert" >/dev/null 2>&1
}
keep_existing_cert_ok() {
local domain="$1" reason="$2"
local cert="$(cert_path_for_domain "$domain")"
if cert_is_valid "$cert"; then
local expiry=$(cert_expiry "$cert")
log "失败: $reason;现有证书仍有效: $domain (过期: $expiry)"
uci -q set minigate.acme.status="ok"
uci -q set minigate.acme.last_domain="$domain"
uci -q set minigate.acme.cert_expiry="$expiry"
uci -q set minigate.acme.last_error="$reason"
uci commit minigate
log "失败: $reason;现有证书仍有效: $domain (过期: $(cert_expiry "$cert"))"
write_state partial "$domain" "$reason;现有证书仍有效,已继续使用"
[ -f "$PROXY_SCRIPT" ] && /bin/sh "$PROXY_SCRIPT" reload 2>/dev/null
return 0
fi
return 1
}
# 只使用同一域名或其上级域名所在 DDNS 记录的令牌,不拿其他区域的令牌碰运气
find_token_for_domain() {
local target="$1"
local sections=$(uci -q show minigate | grep '=ddns$' | cut -d. -f2 | cut -d= -f1)
@@ -49,43 +71,50 @@ find_token_for_domain() {
return 0
fi
done
local parent=$(echo "$target" | sed 's/^[^.]*\.//')
local parent=$(echo "$target" | sed 's/^\*\.//; s/^[^.]*\.//')
local base=$(echo "$target" | sed 's/^\*\.//')
for sec in $sections; do
local d=$(uci -q get minigate.${sec}.domain)
local db=$(echo "$d" | sed 's/^\*\.//')
if [ "$db" = "$parent" ] || [ "$d" = "*.$parent" ]; then
if [ "$db" = "$parent" ] || [ "$db" = "$base" ] || [ "$d" = "*.$parent" ]; then
echo "$(uci -q get minigate.${sec}.cf_api_token)|$(uci -q get minigate.${sec}.cf_zone_id)"
return 0
fi
done
for sec in $sections; do
local t=$(uci -q get minigate.${sec}.cf_api_token)
local z=$(uci -q get minigate.${sec}.cf_zone_id)
[ -n "$t" ] && echo "${t}|${z}" && return 0
done
return 1
}
download_acme() {
local output="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 10 --max-time 120 -o "$output" "$ACME_URL" 2>>"$LOGFILE" && return 0
fi
command -v wget >/dev/null 2>&1 && wget -qO "$output" "$ACME_URL" 2>>"$LOGFILE"
}
do_install() {
if [ -x "$ACME_BIN" ] && [ -d "${ACME_HOME}/dnsapi" ]; then
log "acme.sh 已安装"; return 0
fi
log "下载 acme.sh ..."
log "下载 acme.sh ${ACME_VERSION} ..."
mkdir -p "$ACME_HOME" "$CERT_DIR"
cd /tmp; rm -rf acme.sh-master acme-master.tar.gz
local ok=0
for url in "https://github.com/acmesh-official/acme.sh/archive/master.tar.gz"; do
wget -qO /tmp/acme-master.tar.gz "$url" 2>/dev/null && ok=1 && break
curl -sLo /tmp/acme-master.tar.gz "$url" 2>/dev/null && ok=1 && break
done
[ "$ok" != "1" ] && { log "下载失败"; return 1; }
tar xzf /tmp/acme-master.tar.gz -C /tmp/ 2>/dev/null
cd /tmp/acme.sh-master
./acme.sh --install --home "$ACME_HOME" --no-cron --no-profile 2>>"$LOGFILE"
cd /tmp; rm -rf /tmp/acme-master.tar.gz /tmp/acme.sh-master
local work archive actual
work=$(mktemp -d /tmp/minigate-acme.XXXXXX) || { log "无法创建临时目录"; return 1; }
archive="${work}/acme.tar.gz"
if ! download_acme "$archive"; then
log "下载失败"; rm -rf "$work"; return 1
fi
actual=$(sha256sum "$archive" | awk '{print $1}')
if [ "$actual" != "$ACME_SHA256" ]; then
log "acme.sh 校验失败(SHA-256 不一致),已拒绝安装"
rm -rf "$work"; return 1
fi
tar xzf "$archive" -C "$work" 2>>"$LOGFILE" || { log "解压失败"; rm -rf "$work"; return 1; }
( cd "${work}/acme.sh-${ACME_VERSION}" && ./acme.sh --install --home "$ACME_HOME" --no-cron --no-profile ) >> "$LOGFILE" 2>&1
rm -rf "$work"
if [ -x "$ACME_BIN" ] && [ -d "${ACME_HOME}/dnsapi" ]; then
"$ACME_BIN" --home "$ACME_HOME" --set-default-ca --server letsencrypt 2>>"$LOGFILE"
log "安装成功"; uci -q set minigate.acme.status="installed"; uci commit minigate; return 0
"$ACME_BIN" --home "$ACME_HOME" --set-default-ca --server letsencrypt >> "$LOGFILE" 2>&1
log "安装成功"; return 0
else
log "安装失败"; return 1
fi
@@ -101,20 +130,30 @@ do_issue() {
local fs=$(uci -q show minigate | grep '=ddns$' | head -1 | cut -d. -f2 | cut -d= -f1)
[ -n "$fs" ] && domain=$(uci -q get minigate.${fs}.domain)
fi
[ -z "$domain" ] && { log "未指定域名"; uci -q set minigate.acme.status="error"; uci commit minigate; return 1; }
[ -z "$domain" ] && { log "未指定域名"; write_state error "" "未指定域名"; return 1; }
local ti=$(find_token_for_domain "$domain")
local token=$(echo "$ti" | cut -d'|' -f1)
local zone_id=$(echo "$ti" | cut -d'|' -f2)
[ -z "$token" ] && { log "无API令牌"; uci -q set minigate.acme.status="error"; uci commit minigate; return 1; }
[ -z "$token" ] && { log "无API令牌"; write_state error "$domain" "没有找到该域名对应的 Cloudflare 令牌"; return 1; }
local safe=$(safe_dir "$domain")
local cert="${CERT_DIR}/${safe}/fullchain.pem"
mkdir -p "${CERT_DIR}/${safe}"
if ! do_install; then
keep_existing_cert_ok "$domain" "acme.sh 安装失败" && return 0
uci -q set minigate.acme.status="error"; uci commit minigate
write_state error "$domain" "acme.sh 安装失败"
return 1
fi
local force=""
if cert_valid_for_days "$cert" "$RENEW_DAYS" && [ "$staging" != "1" ] && ! cert_is_staging "$cert"; then
log "跳过: $domain 证书剩余超过 ${RENEW_DAYS} 天 (过期: $(cert_expiry "$cert"))"
write_state ok "$domain" "证书剩余超过 ${RENEW_DAYS} 天,未重复签发"
return 0
fi
[ -f "$cert" ] && force="--force"
export CF_Token="$token"
[ -n "$zone_id" ] && export CF_Zone_ID="$zone_id"
@@ -124,23 +163,21 @@ do_issue() {
[ "$staging" = "1" ] && sf="--staging --server letsencrypt"
log "签发: $domain (staging=$staging)"
uci -q set minigate.acme.status="issuing"; uci commit minigate
write_state issuing "$domain" "正在签发"
"$ACME_BIN" --home "$ACME_HOME" --issue --dns dns_cf $sf -d "$domain" --keylength "$kl" \
${email:+--accountemail "$email"} \
--cert-file "${CERT_DIR}/${safe}/cert.pem" --key-file "${CERT_DIR}/${safe}/key.pem" \
--fullchain-file "${CERT_DIR}/${safe}/fullchain.pem" --force >> "$LOGFILE" 2>&1
--fullchain-file "$cert" $force >> "$LOGFILE" 2>&1
local ret=$?
if [ $ret -eq 0 ] && [ -f "${CERT_DIR}/${safe}/fullchain.pem" ]; then
local expiry=$(cert_expiry "${CERT_DIR}/${safe}/fullchain.pem")
log "成功: $domain (过期: $expiry)"
uci -q set minigate.acme.status="ok"
uci -q set minigate.acme.last_domain="$domain"
uci -q set minigate.acme.last_issue="$(date '+%Y-%m-%d %H:%M:%S')"
uci -q set minigate.acme.cert_expiry="$expiry"
uci -q delete minigate.acme.last_error
uci commit minigate
if [ $ret -eq 0 ] && [ -f "$cert" ]; then
log "成功: $domain (过期: $(cert_expiry "$cert"))"
if [ "$staging" = "1" ]; then
write_state ok "$domain" "已签发测试证书(浏览器不信任),正式使用请关闭测试模式后重新签发"
else
write_state ok "$domain" "签发成功"
fi
if echo "$domain" | grep -q '^\*\.'; then
local base=$(echo "$domain" | sed 's/^\*\.//')
[ ! -e "${CERT_DIR}/${base}" ] && ln -sf "${safe}" "${CERT_DIR}/${base}"
@@ -149,16 +186,22 @@ do_issue() {
return 0
else
keep_existing_cert_ok "$domain" "签发失败 (exit=$ret)" && return 0
log "失败 (exit=$ret)"; uci -q set minigate.acme.status="error"; uci -q set minigate.acme.last_error="签发失败 (exit=$ret)"; uci commit minigate; return 1
log "失败 (exit=$ret)"
write_state error "$domain" "签发失败 (exit=$ret),请查看 ACME 日志"
return 1
fi
}
cert_is_staging() {
openssl x509 -in "$1" -noout -issuer 2>/dev/null | grep -qi 'staging\|fake'
}
certs_fingerprint() {
cat "$CERT_DIR"/*/fullchain.pem 2>/dev/null | md5sum | cut -d' ' -f1
}
do_renew() {
local before domain cert
local before
do_install || return 1
before=$(certs_fingerprint)
log "续期..."
@@ -167,14 +210,12 @@ do_renew() {
# acme.sh 只替换证书文件,运行中的 Nginx 必须重载才会使用新证书。
[ "$(certs_fingerprint)" = "$before" ] && return 0
log "证书已更新,重载反向代理"
domain=$(uci -q get minigate.acme.last_domain)
cert="$(cert_path_for_domain "$domain")"
if [ -n "$domain" ] && [ -f "$cert" ]; then
uci -q set minigate.acme.cert_expiry="$(cert_expiry "$cert")"
uci commit minigate
fi
[ -f "$PROXY_SCRIPT" ] && /bin/sh "$PROXY_SCRIPT" reload 2>/dev/null
return 0
}
if [ "${MINIGATE_ACME_LIBRARY_ONLY:-0}" = "1" ]; then
return 0 2>/dev/null || exit 0
fi
case "${1:-issue}" in install) do_install;; issue) do_issue "$2";; renew) do_renew;; *) echo "用法: $0 {install|issue [域名]|renew}";; esac
@@ -1,33 +0,0 @@
#!/bin/sh
# LuCI 的 on_after_commit 在浏览器提交配置之前执行。这里先记录当前配置摘要,
# 后台等配置文件变化(最多 WAIT 秒)后再应用,避免后台脚本读到旧配置。
CONFIG="${MINIGATE_CONFIG_FILE:-/etc/config/minigate}"
WAIT="${MINIGATE_COMMIT_WAIT:-15}"
INIT="${MINIGATE_INIT:-/etc/init.d/minigate}"
LIB_DIR="${MINIGATE_LIB_DIR:-/usr/lib/minigate}"
config_digest() {
md5sum "$CONFIG" 2>/dev/null | cut -d' ' -f1
}
case "$1" in
restart|stop|reload|ddns|proxy) ;;
*)
echo "用法: $0 {restart|stop|reload|ddns|proxy}" >&2
exit 2
;;
esac
before=$(config_digest)
(
i=0
while [ "$i" -lt "$WAIT" ] && [ "$(config_digest)" = "$before" ]; do
sleep 1
i=$((i + 1))
done
case "$1" in
restart|stop|reload) exec "$INIT" "$1" ;;
ddns) exec /bin/sh "$LIB_DIR/ddns.sh" force ;;
proxy) exec /bin/sh "$LIB_DIR/proxy.sh" reload ;;
esac
) </dev/null >/dev/null 2>&1 &
+125 -44
View File
@@ -6,6 +6,8 @@ CACHE_DIR="${MINIGATE_CACHE_DIR:-/tmp}"
SINGBOX_CONFIG="${MINIGATE_SINGBOX_CONFIG:-/opt/open-box/etc/config.json}"
CLASH_API_URL="${MINIGATE_CLASH_API_URL:-http://127.0.0.1:9095}"
LOCK_FILE="${MINIGATE_DDNS_LOCK:-/var/lock/minigate-ddns.lock}"
# 运行状态只放内存,不写 /etc/config:避免每次检查都提交配置、备份里混入运行数据
STATE_DIR="${MINIGATE_DDNS_STATE_DIR:-/var/run/minigate/ddns}"
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [DDNS] $*" >> "$LOGFILE"; }
trim_log() { [ -f "$LOGFILE" ] && [ "$(wc -l < "$LOGFILE")" -gt 300 ] && { tail -n 150 "$LOGFILE" > "${LOGFILE}.tmp"; mv "${LOGFILE}.tmp" "$LOGFILE"; }; }
@@ -24,6 +26,71 @@ lock_ddns() {
fi
}
# 状态文件与 IP 历史一样按域名命名;LuCI 侧用相同规则读取
state_key() {
printf '%s' "$1" | tr -c 'A-Za-z0-9_-' '_'
}
state_value() {
sed -n "s/^$2=//p" "$1" 2>/dev/null | head -1
}
# write_state 域名 状态 说明 IPv4 IPv6 更新时间 下次检查;IP 为空时保留上次结果
write_state() {
local key file tmp ip ip6
key=$(state_key "$1")
[ -n "$key" ] || return 0
mkdir -p "$STATE_DIR" 2>/dev/null || return 0
file="${STATE_DIR}/${key}.state"
tmp="${file}.$$"
ip="$4"; ip6="$5"
[ -n "$ip" ] || ip=$(state_value "$file" ip)
[ -n "$ip6" ] || ip6=$(state_value "$file" ip6)
{
printf 'status=%s\n' "$2"
printf 'message=%s\n' "$3"
printf 'ip=%s\n' "$ip"
printf 'ip6=%s\n' "$ip6"
printf 'updated=%s\n' "$6"
printf 'next=%s\n' "$7"
} > "$tmp" && mv -f "$tmp" "$file"
}
mark_stopped() {
local sec domain file
for sec in $(uci -q -X show minigate | grep '=ddns$' | cut -d. -f2 | cut -d= -f1); do
domain=$(uci -q get minigate.${sec}.domain)
[ -n "$domain" ] || continue
file="${STATE_DIR}/$(state_key "$domain").state"
write_state "$domain" stopped "已停止" "" "" "$(state_value "$file" updated)" ""
done
}
# 只接受公网地址:软路由在上级路由 NAT 之后时,网卡上是内网地址,不能推到 Cloudflare
is_public_ipv4() {
printf '%s\n' "$1" | awk -F. '
NF != 4 { exit 1 }
{
for (i = 1; i <= 4; i++) if ($i !~ /^[0-9]+$/ || $i + 0 > 255) exit 1
a = $1 + 0; b = $2 + 0
if (a == 0 || a == 10 || a == 127 || a >= 224) exit 1
if (a == 100 && b >= 64 && b <= 127) exit 1
if (a == 169 && b == 254) exit 1
if (a == 172 && b >= 16 && b <= 31) exit 1
if (a == 192 && b == 168) exit 1
if (a == 198 && (b == 18 || b == 19)) exit 1
}'
}
# 全球单播 2000::/3;排除 ULA、链路本地等
is_public_ipv6() {
case "$1" in
*[!0-9a-fA-F:]*) return 1 ;;
[23][0-9a-fA-F]*:*) return 0 ;;
esac
return 1
}
history_now() {
if [ -n "${MINIGATE_HISTORY_NOW:-}" ]; then
printf '%s\n' "$MINIGATE_HISTORY_NOW"
@@ -158,49 +225,60 @@ get_ip4_iface() {
echo "$ip" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'
}
# 依次请求查询地址,取第一个公网 IPv4;HTTPS 优先,HTTP 仅作兜底
IP4_FALLBACK_URLS="https://ddns.oray.com/checkip http://ip.3322.net http://members.3322.org/dyndns/getip http://ns1.dnspod.net:6666"
get_ip4_url() {
local ip=""
# 备用 URL 列表(主选 + 自动回退)
local fallback_urls="http://ip.3322.net http://members.3322.org/dyndns/getip http://ns1.dnspod.net:6666 http://ip.tool.chinaz.com/getip"
local urls="$1 $fallback_urls"
for url in $urls; do
local host=$(echo "$url" | sed -E 's|https?://||;s|/.*||;s|:.*||')
local port=$(echo "$url" | grep -oE ':[0-9]+' | head -1 | tr -d ':')
local path=$(echo "$url" | sed -E 's|https?://[^/]*||')
[ -z "$path" ] && path="/"
[ -z "$port" ] && port="80"
# 方法1: 直连
ip=$(curl -4 -s --connect-timeout 3 --max-time 8 "$url" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
[ -n "$ip" ] && { echo "$ip"; return; }
# 方法2: DNS 解析后用 Host 头直连(绕过代理)
local resolved=$(nslookup "$host" 223.5.5.5 2>/dev/null | grep -A1 'Name:' | grep 'Address:' | head -1 | awk '{print $2}')
if [ -n "$resolved" ]; then
ip=$(curl -4 -s --connect-timeout 3 --max-time 8 -H "Host: ${host}" "http://${resolved}:${port}${path}" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
[ -n "$ip" ] && { echo "$ip"; return; }
local ip="" url
for url in $1 $IP4_FALLBACK_URLS; do
ip=$(fetch_ip4 "$url")
if [ -n "$ip" ]; then
echo "$ip"
return 0
fi
done
echo ""
}
fetch_ip4() {
local url="$1" ip candidate host port scheme resolved
host=$(echo "$url" | sed -E 's|https?://||;s|/.*||;s|:.*||')
port=$(echo "$url" | sed -E 's|https?://[^/:]*||' | grep -oE '^:[0-9]+' | tr -d ':')
scheme="http"
echo "$url" | grep -q '^https' && scheme="https"
[ -z "$port" ] && { [ "$scheme" = "https" ] && port="443" || port="80"; }
# 方法1: 直连
for candidate in $(curl -4 -s --connect-timeout 3 --max-time 8 "$url" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -3); do
is_public_ipv4 "$candidate" && { echo "$candidate"; return 0; }
done
# 方法2: 用公共 DNS 解析后直连(绕过透明代理的 fake-ip 解析)
resolved=$(nslookup "$host" 223.5.5.5 2>/dev/null | grep -A1 'Name:' | grep 'Address:' | head -1 | awk '{print $2}')
is_public_ipv4 "$resolved" || return 0
for candidate in $(curl -4 -s --connect-timeout 3 --max-time 8 --resolve "${host}:${port}:${resolved}" "$url" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -3); do
is_public_ipv4 "$candidate" && { echo "$candidate"; return 0; }
done
return 0
}
# ====== IPv6 获取 ======
get_ip6_iface() {
local ip="" iface="${1:-wan6}"
# 优先取全局 IPv6 地址(排除 link-local fe80::)
ip=$(ubus call network.interface.${iface} status 2>/dev/null | jsonfilter -e '@["ipv6-address"][0].address' 2>/dev/null)
[ -z "$ip" ] && ip=$(ifstatus "${iface}" 2>/dev/null | jsonfilter -e '@["ipv6-address"][0].address' 2>/dev/null)
# 也尝试从 ipv6-prefix-assignment 获取
[ -z "$ip" ] && ip=$(ubus call network.interface.${iface} status 2>/dev/null | jsonfilter -e '@["ipv6-prefix-assignment"][0]["local-address"].address' 2>/dev/null)
# 验证是有效的 IPv6(排除 link-local)
echo "$ip" | grep -v '^fe80' | grep -oE '^[0-9a-fA-F:]+$' | head -1
local status ip iface="${1:-wan6}"
status=$(ubus call network.interface.${iface} status 2>/dev/null)
[ -n "$status" ] || status=$(ifstatus "${iface}" 2>/dev/null)
# 取第一个全球单播地址(跳过 ULA、链路本地),没有时再看前缀分配给本机的地址
for ip in $(printf '%s' "$status" | jsonfilter -e '@["ipv6-address"][*].address' 2>/dev/null) \
$(printf '%s' "$status" | jsonfilter -e '@["ipv6-prefix-assignment"][*]["local-address"].address' 2>/dev/null); do
is_public_ipv6 "$ip" && { echo "$ip"; return 0; }
done
echo ""
}
get_ip6_url() {
local url="$1" ip=""
local host=$(echo "$url" | sed -E 's|https?://||;s|/.*||;s|:.*||')
local port=$(echo "$url" | grep -oE ':[0-9]+' | head -1 | tr -d ':')
local port=$(echo "$url" | sed -E 's|https?://[^/:]*||' | grep -oE '^:[0-9]+' | tr -d ':')
# 先尝试直连
ip=$(curl -6 -s --connect-timeout 5 --max-time 10 "$url" 2>/dev/null | grep -oE '([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}' | head -1)
@@ -215,7 +293,7 @@ get_ip6_url() {
ip=$(curl -6 -s --connect-timeout 5 --max-time 10 --resolve "${host}:${port}:${resolved}" "$url" 2>/dev/null | grep -oE '([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}' | head -1)
fi
fi
echo "$ip"
is_public_ipv6 "$ip" && echo "$ip"
}
# Token 经标准输入交给 curl,不出现在进程参数中。
@@ -315,7 +393,7 @@ update_one() {
[ -z "$domain" ] || [ -z "$zone_id" ] || [ -z "$token" ] && {
log "$domain: 配置不完整"
uci -q set minigate.${sec}.status="error"; uci -q set minigate.${sec}.status_msg="配置不完整"
write_state "$domain" error "配置不完整" "" "" "$(date '+%Y-%m-%d %H:%M:%S')" ""
return 1
}
@@ -333,7 +411,12 @@ update_one() {
local ip4=""
case "$ip_source" in url) ip4=$(get_ip4_url "$ip_url");; *) ip4=$(get_ip4_iface "$iface");; esac
if [ -z "$ip4" ]; then
if [ -n "$ip4" ] && ! is_public_ipv4 "$ip4"; then
log "$domain: 网卡地址 $ip4 不是公网地址(可能在上级路由 NAT 之后),请改用「从外部URL获取」"
msgs="${msgs}IPv4 ${ip4} 不是公网地址; "
any_fail=1
ip4=""
elif [ -z "$ip4" ]; then
log "$domain: 获取IPv4失败"
msgs="${msgs}IPv4获取失败; "
any_fail=1
@@ -406,18 +489,13 @@ update_one() {
fi
# ====== 更新状态 ======
local status="error"
if [ "$any_ok" = "1" ] && [ "$any_fail" = "0" ]; then
uci -q set minigate.${sec}.status="ok"
status="ok"
elif [ "$any_ok" = "1" ] && [ "$any_fail" = "1" ]; then
uci -q set minigate.${sec}.status="partial"
else
uci -q set minigate.${sec}.status="error"
status="partial"
fi
uci -q set minigate.${sec}.status_msg="$msgs"
[ -n "$result_ip" ] && uci -q set minigate.${sec}.last_ip="$result_ip"
[ -n "$result_ip6" ] && uci -q set minigate.${sec}.last_ip6="$result_ip6"
uci -q set minigate.${sec}.last_update="$(date '+%Y-%m-%d %H:%M:%S')"
uci -q set minigate.${sec}.next_sync="$next_time"
write_state "$domain" "$status" "$msgs" "$result_ip" "$result_ip6" "$(date '+%Y-%m-%d %H:%M:%S')" "$next_time"
[ "$refresh_dns" = "1" ] && refresh_local_dns_caches "$domain" || true
}
@@ -448,7 +526,6 @@ main() {
local c=0
for sec in $due; do update_one "$sec" "$force"; c=$((c + 1)); done
[ "$c" = "0" ] && log "无 DDNS 记录"
uci commit minigate
log "--- 同步完成 ($c 条) ---"
trim_log
}
@@ -462,9 +539,13 @@ if [ "$1" = "single" ] && [ -n "$2" ]; then
lock_ddns wait
log "手动同步: $2"
update_one "$2" "1"
uci commit minigate
trim_log
exit 0
fi
if [ "$1" = "mark-stopped" ]; then
mark_stopped
exit 0
fi
main "$@"
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
# 用随包的离线地址库查询省级归属地,命中时输出地区名;未命中输出空。
GEO_DIR="${MINIGATE_GEO_DIR:-/usr/lib/minigate/geo}"
ip="$1"
case "$ip" in
''|*[!0-9a-fA-F:.]*) exit 1 ;;
esac
code=$(awk -v ip="$ip" '
function v4(a, p, n, i, v) {
n = split(a, p, ".")
if (n != 4) return -1
v = 0
for (i = 1; i <= 4; i++) {
if (p[i] !~ /^[0-9]+$/ || p[i] + 0 > 255) return -1
v = v * 256 + p[i]
}
return v
}
function pad(g) { while (length(g) < 4) g = "0" g; return g }
function v6(a, halves, left, right, l, r, i, out, missing) {
a = tolower(a)
if (a !~ /^[0-9a-f:]+$/) return ""
if (split(a, halves, "::") > 2) return ""
l = (halves[1] == "") ? 0 : split(halves[1], left, ":")
r = (index(a, "::") && halves[2] != "") ? split(halves[2], right, ":") : 0
if (!index(a, "::") && l != 8) return ""
missing = 8 - l - r
if (missing < 0) return ""
out = ""
for (i = 1; i <= l; i++) { if (length(left[i]) > 4 || left[i] == "") return ""; out = out pad(left[i]) }
for (i = 0; i < missing; i++) out = out "0000"
for (i = 1; i <= r; i++) { if (length(right[i]) > 4 || right[i] == "") return ""; out = out pad(right[i]) }
return out
}
function hexval(c) { return index("0123456789abcdef", c) - 1 }
BEGIN {
family = index(ip, ":") ? 6 : 4
if (family == 4) { want4 = v4(ip); if (want4 < 0) exit }
else { want6 = v6(ip); if (want6 == "") exit }
}
/^[0-9a-fA-F.:]+\/[0-9]+ 1;$/ {
net = $1; sub(/ .*/, "", net)
addr = net; sub(/\/.*/, "", addr)
bits = net; sub(/^[^\/]*\//, "", bits); bits = bits + 0
if (family == 4) {
if (index(addr, ":")) next
d = 2 ^ (32 - bits)
if (int(want4 / d) == int(v4(addr) / d)) { found = FILENAME; exit }
} else {
if (!index(addr, ":")) next
b = v6(addr)
full = int(bits / 4)
if (substr(want6, 1, full) != substr(b, 1, full)) next
rest = bits - full * 4
if (rest == 0) { found = FILENAME; exit }
d = 2 ^ (4 - rest)
if (int(hexval(substr(want6, full + 1, 1)) / d) == int(hexval(substr(b, full + 1, 1)) / d)) { found = FILENAME; exit }
}
}
END {
if (found != "") { sub(/^.*\//, "", found); sub(/\.conf$/, "", found); print found }
}
' "$GEO_DIR"/[0-9]*.conf 2>/dev/null)
[ -n "$code" ] || exit 0
awk -v code="$code" '$1 == code { $1 = ""; sub(/^ /, ""); print; exit }' "$GEO_DIR/regions.txt"
-198
View File
@@ -1,198 +0,0 @@
#!/bin/sh
# MiniGate GeoFence - 地区访问控制
# 检查反代访问日志,非允许地区的 IP 用防火墙封禁
LOGFILE="${MINIGATE_GEOFENCE_LOG:-/var/log/minigate-geofence.log}"
CACHE_DIR="${MINIGATE_GEO_CACHE_DIR:-/tmp/minigate_geo}"
BLOCK_SET="minigate_blocked"
ACCESS_LOG="${MINIGATE_ACCESS_LOG:-/var/log/minigate-access.log}"
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [GEOFENCE] $*" >> "$LOGFILE"; }
# 查询 IP 归属地,返回省份名
lookup_geo() {
local ip="$1"
local cache="$CACHE_DIR/${ip}"
# 缓存命中
[ -f "$cache" ] && cat "$cache" && return 0
local geo=""
# API 1: pconline
if [ -z "$geo" ]; then
local raw=$(curl -s --connect-timeout 3 --max-time 5 "https://whois.pconline.com.cn/ipJson.jsp?ip=${ip}&json=true" 2>/dev/null)
geo=$(echo "$raw" | jsonfilter -e '$.pro' 2>/dev/null)
fi
# API 2: ip9.com.cn
if [ -z "$geo" ]; then
local raw=$(curl -s --connect-timeout 3 --max-time 5 "https://ip9.com.cn/get?ip=${ip}" 2>/dev/null)
geo=$(echo "$raw" | jsonfilter -e '$.data.prov' 2>/dev/null)
fi
# API 3: ip-api.com
if [ -z "$geo" ]; then
local raw=$(curl -s --connect-timeout 3 --max-time 5 "http://ip-api.com/json/${ip}?fields=regionName" 2>/dev/null)
geo=$(echo "$raw" | jsonfilter -e '$.regionName' 2>/dev/null)
fi
[ -z "$geo" ] && geo="unknown"
# 写入缓存
mkdir -p "$CACHE_DIR"
echo "$geo" > "$cache"
echo "$geo"
}
# 检查 IP 是否在允许的地区列表中
is_allowed() {
local geo="$1"
local allowed=$(uci -q get minigate.global.geo_allowed)
[ -z "$allowed" ] && return 0 # 未配置地区限制,全部放行
# allowed 是逗号分隔的省份列表,如 "四川,重庆,贵州"
echo "$allowed" | tr ',' '\n' | while read prov; do
[ -z "$prov" ] && continue
echo "$geo" | grep -q "$prov" && echo "yes" && return 0
done | grep -q "yes"
}
# 初始化 ipset(nftables 或 iptables)
init_block_set() {
if command -v nft >/dev/null 2>&1; then
# nftables (OpenWrt 22+)
nft list set inet fw4 "$BLOCK_SET" >/dev/null 2>&1 || {
nft add set inet fw4 "$BLOCK_SET" '{ type ipv4_addr; flags timeout; }'
nft add rule inet fw4 input ip saddr @"$BLOCK_SET" drop 2>/dev/null
log "nftables 封禁集合已创建"
}
else
# iptables fallback
ipset list "$BLOCK_SET" >/dev/null 2>&1 || {
ipset create "$BLOCK_SET" hash:ip timeout 43200 2>/dev/null # 12小时超时
iptables -I INPUT -m set --match-set "$BLOCK_SET" src -j DROP 2>/dev/null
log "ipset 封禁集合已创建"
}
fi
}
# 封禁 IP
block_ip() {
local ip="$1" reason="$2"
if command -v nft >/dev/null 2>&1; then
nft add element inet fw4 "$BLOCK_SET" "{ $ip timeout 12h }" 2>/dev/null
else
ipset add "$BLOCK_SET" "$ip" timeout 43200 2>/dev/null
fi
log "封禁: $ip ($reason) 12小时"
# 记录到 UCI
local ts=$(date '+%Y-%m-%d %H:%M:%S')
uci -q add_list minigate.global.blocked_ips="${ip}|${reason}|${ts}"
uci commit minigate 2>/dev/null
}
# 清理过期缓存(超过24小时的)
clean_cache() {
[ -d "$CACHE_DIR" ] && find "$CACHE_DIR" -type f -mmin +1440 -delete 2>/dev/null
}
# 局域网、回环、链路本地和运营商 NAT 地址没有归属地,局域网直连时访客就是内网 IP,不参与地区判断
is_private_ip() {
case "$1" in
0.*|10.*|127.*|169.254.*|192.168.*) return 0 ;;
172.1[6-9].*|172.2[0-9].*|172.3[01].*) return 0 ;;
100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) return 0 ;;
esac
return 1
}
# BusyBox grep 不支持 -P,用 sed 取 IPv4 访客地址
extract_public_ips() {
local ip
sed -n 's/.*"client":"\([0-9]\{1,3\}\(\.[0-9]\{1,3\}\)\{3\}\)".*/\1/p' "$ACCESS_LOG" 2>/dev/null | \
sort -u | while read -r ip; do
is_private_ip "$ip" || echo "$ip"
done
}
# 主逻辑:扫描 access log 中的新 IP
scan_and_block() {
# 请求前准入已覆盖公网反代,避免旧扫描器再把来源全局封禁到其他服务。
[ "$(uci -q get minigate.global.access_enabled)" = "1" ] && return 0
local enabled=$(uci -q get minigate.global.geo_enabled)
[ "$enabled" != "1" ] && return 0
local allowed=$(uci -q get minigate.global.geo_allowed)
[ -z "$allowed" ] && { log "未配置允许地区,跳过"; return 0; }
init_block_set
local ips=$(extract_public_ips)
local checked=0 blocked=0
for ip in $ips; do
# 已经封禁的跳过
if command -v nft >/dev/null 2>&1; then
nft list set inet fw4 "$BLOCK_SET" 2>/dev/null | grep -q "$ip" && continue
else
ipset test "$BLOCK_SET" "$ip" 2>/dev/null && continue
fi
local geo=$(lookup_geo "$ip")
checked=$((checked + 1))
if [ "$geo" = "unknown" ]; then
log "警告: $ip 归属地未知,暂时放行"
continue
fi
if ! is_allowed "$geo"; then
block_ip "$ip" "$geo"
blocked=$((blocked + 1))
else
log "放行: $ip ($geo)"
fi
# 限速,避免 API 过载
sleep 1
done
[ "$checked" -gt 0 ] && log "扫描完成: 检查 $checked 个IP,封禁 $blocked 个"
clean_cache
}
# 手动解封
unblock_ip() {
local ip="$1"
if command -v nft >/dev/null 2>&1; then
nft delete element inet fw4 "$BLOCK_SET" "{ $ip }" 2>/dev/null
else
ipset del "$BLOCK_SET" "$ip" 2>/dev/null
fi
log "解封: $ip"
}
# 清除所有封禁
flush_blocks() {
if command -v nft >/dev/null 2>&1; then
nft flush set inet fw4 "$BLOCK_SET" 2>/dev/null
else
ipset flush "$BLOCK_SET" 2>/dev/null
fi
uci -q delete minigate.global.blocked_ips
uci commit minigate 2>/dev/null
log "已清除所有封禁"
}
if [ "${MINIGATE_GEOFENCE_LIBRARY_ONLY:-0}" = "1" ]; then
return 0 2>/dev/null || exit 0
fi
case "${1:-scan}" in
scan) scan_and_block ;;
unblock) unblock_ip "$2" ;;
flush) flush_blocks ;;
*) echo "用法: $0 {scan|unblock <ip>|flush}" ;;
esac
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# 后台执行耗时操作(签发证书、手动同步 DDNS),LuCI 轮询状态文件,不再卡住网页请求。
# 用法: job.sh <任务名> <命令...>
JOB_DIR="${MINIGATE_JOB_DIR:-/var/run/minigate/jobs}"
name="$1"
shift
case "$name" in ''|*[!A-Za-z0-9_-]*) echo "无效任务名" >&2; exit 2 ;; esac
[ "$#" -gt 0 ] || { echo "用法: $0 <任务名> <命令...>" >&2; exit 2; }
mkdir -p "$JOB_DIR" || exit 1
file="${JOB_DIR}/${name}.state"
write_job() {
printf 'status=%s\nstarted=%s\nfinished=%s\ncode=%s\n' "$1" "$2" "$3" "$4" > "${file}.$$" &&
mv -f "${file}.$$" "$file"
}
started=$(date +%s)
write_job running "$started" "" ""
"$@" </dev/null >/dev/null 2>&1
code=$?
write_job done "$started" "$(date +%s)" "$code"
exit "$code"
+302 -110
View File
@@ -1,9 +1,9 @@
#!/bin/sh
# MiniGate · 登录防护(Login Guard)
# 监控 SSH (dropbear) 和 LuCI 网页登录失败,达到阈值自动封禁源 IP
# 监控 SSH (dropbear) 和 LuCI 网页登录失败,达到阈值自动封禁源 IP(IPv4 / IPv6)
#
# 用法:
# login_guard.sh run 后台监控(init.d 调用)
# login_guard.sh run 后台监控(由 procd 启动)
# login_guard.sh list 列出当前已封禁的 IP
# login_guard.sh status 状态总览
# login_guard.sh ban <IP> 手动封禁
@@ -16,15 +16,21 @@ ENABLED=$(uci -q get minigate.login_guard.enabled || echo "0")
THRESHOLD=$(uci -q get minigate.login_guard.threshold || echo "3")
BANTIME=$(uci -q get minigate.login_guard.bantime || echo "43200")
WINDOW=$(uci -q get minigate.login_guard.window || echo "600")
case "$THRESHOLD" in ''|*[!0-9]*|0) THRESHOLD=3 ;; esac
case "$BANTIME" in ''|*[!0-9]*|0) BANTIME=43200 ;; esac
case "$WINDOW" in ''|*[!0-9]*|0) WINDOW=600 ;; esac
DATA_DIR="${MINIGATE_LG_DATA_DIR:-/etc/minigate/login-guard}"
RUN_DIR="${MINIGATE_LG_RUN_DIR:-/var/run/minigate/login-guard}"
BANS_FILE="$DATA_DIR/bans.txt"
COUNTER_DIR="$RUN_DIR/counters"
LOG_FILE="${MINIGATE_LG_LOG:-/var/log/minigate-login-guard.log}"
ACCESS_LOG=/var/log/minigate-access.log
SET="login_banned_v4"
ACCESS_LOG="${MINIGATE_ACCESS_LOG:-/var/log/minigate-access.log}"
SET4="login_banned_v4"
SET6="login_banned_v6"
TABLE="inet fw4"
WATCHDOG_INTERVAL="${MINIGATE_LG_WATCHDOG:-30}"
LAN6_NETS=""
mkdir -p "$DATA_DIR" "$COUNTER_DIR"
touch "$BANS_FILE"
@@ -34,62 +40,212 @@ log() {
logger -t minigate-lg "$*"
}
# === 白名单:从 uci 读 list whitelist + 默认私有段 ===
fmt_duration() {
local s=$1
if [ "$s" -ge 86400 ] && [ $((s % 86400)) -eq 0 ]; then
printf "%d天" $((s / 86400))
elif [ "$s" -ge 3600 ]; then
if [ $((s % 3600)) -eq 0 ]; then
printf "%d小时" $((s / 3600))
else
printf "%d小时%d分" $((s / 3600)) $(((s % 3600) / 60))
fi
elif [ "$s" -ge 60 ]; then
printf "%d分%d秒" $((s / 60)) $((s % 60))
else
printf "%d秒" "$s"
fi
}
# 输出 4 / 6,无效地址输出空。IPv6 只接受纯十六进制写法,地址会原样进入 nft 命令。
ip_family() {
case "$1" in
*[!0-9.]*) ;;
*.*.*.*)
printf '%s\n' "$1" | awk -F. 'NF == 4 { for (i = 1; i <= 4; i++) if ($i !~ /^[0-9]+$/ || $i > 255) exit 1; ok = 1 } END { exit !ok }' && echo 4
return 0
;;
esac
case "$1" in
*:*:*)
case "$1" in *[!0-9a-fA-F:]*) return 0 ;; esac
[ "${#1}" -le 39 ] && echo 6
;;
esac
return 0
}
set_for_ip() {
case "$(ip_family "$1")" in
4) echo "$SET4" ;;
6) echo "$SET6" ;;
esac
}
# 判断 IP 是否属于 CIDR(IPv4 任意前缀;IPv6 展开后逐位比较)
cidr_match() {
awk -v ip="$1" -v net="$2" '
function v4(a, p, n, i, v) {
n = split(a, p, ".")
if (n != 4) return -1
v = 0
for (i = 1; i <= 4; i++) {
if (p[i] !~ /^[0-9]+$/ || p[i] + 0 > 255) return -1
v = v * 256 + p[i]
}
return v
}
function pad(g) { while (length(g) < 4) g = "0" g; return g }
function v6(a, halves, left, right, l, r, i, out, missing, g) {
a = tolower(a)
if (a !~ /^[0-9a-f:]+$/) return ""
if (split(a, halves, "::") > 2) return ""
l = (halves[1] == "") ? 0 : split(halves[1], left, ":")
r = (index(a, "::") && halves[2] != "") ? split(halves[2], right, ":") : 0
if (!index(a, "::") && l != 8) return ""
missing = 8 - l - r
if (missing < 0) return ""
out = ""
for (i = 1; i <= l; i++) { if (length(left[i]) > 4 || left[i] == "") return ""; out = out pad(left[i]) }
for (i = 0; i < missing; i++) out = out "0000"
for (i = 1; i <= r; i++) { if (length(right[i]) > 4 || right[i] == "") return ""; out = out pad(right[i]) }
return out
}
function hexval(c) { return index("0123456789abcdef", c) - 1 }
BEGIN {
addr = net; bits = -1
if (index(net, "/")) { addr = substr(net, 1, index(net, "/") - 1); bits = substr(net, index(net, "/") + 1) }
if (bits != -1 && bits !~ /^[0-9]+$/) exit 1
bits = bits + 0
if (index(ip, ":")) {
a = v6(ip); b = v6(addr)
if (a == "" || b == "") exit 1
if (bits == -1) bits = 128
if (bits > 128) exit 1
full = int(bits / 4)
if (substr(a, 1, full) != substr(b, 1, full)) exit 1
rest = bits - full * 4
if (rest == 0) exit 0
d = 2 ^ (4 - rest)
exit !(int(hexval(substr(a, full + 1, 1)) / d) == int(hexval(substr(b, full + 1, 1)) / d))
}
a = v4(ip); b = v4(addr)
if (a < 0 || b < 0) exit 1
if (bits == -1) bits = 32
if (bits > 32) exit 1
d = 2 ^ (32 - bits)
exit !(int(a / d) == int(b / d))
}'
}
# LAN 接口实际分配到的全球 IPv6 子网也属于内网
load_lan6_nets() {
local status address mask i=0 nets=""
status=$(ubus call network.interface.lan status 2>/dev/null)
if [ -n "$status" ]; then
while [ "$i" -lt 32 ]; do
address=$(printf '%s' "$status" | jsonfilter -e "@[\"ipv6-prefix-assignment\"][${i}].address" 2>/dev/null)
mask=$(printf '%s' "$status" | jsonfilter -e "@[\"ipv6-prefix-assignment\"][${i}].mask" 2>/dev/null)
[ -n "$address" ] || break
i=$((i + 1))
case "$mask" in ''|*[!0-9]*) continue ;; esac
[ "$mask" -ge 48 ] && [ "$mask" -le 128 ] || continue
nets="$nets ${address}/${mask}"
done
fi
LAN6_NETS="$nets"
}
# === 白名单:默认私有段 + LAN 的 IPv6 子网 + uci list whitelist ===
is_whitelisted() {
local ip="$1"
local ip="$1" net
case "$ip" in
192.168.*|10.*|127.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*) return 0 ;;
::1|[fF][cCdD]*:*|[fF][eE][89aAbB]*:*) return 0 ;;
esac
local wl
wl=$(uci -q get minigate.login_guard.whitelist 2>/dev/null)
[ -z "$wl" ] && return 1
for net in $wl; do
case "$net" in
"$ip") return 0 ;;
*/[0-9]*)
# 简单 CIDR 匹配(前缀 byte 比较)
local prefix="${net%/*}"
local mask="${net#*/}"
# 只比 /8 /16 /24 /32 这种整字节边界(够用)
case "$mask" in
8) [ "${ip%%.*}" = "${prefix%%.*}" ] && return 0 ;;
16) [ "${ip%.*.*}" = "${prefix%.*.*}" ] && return 0 ;;
24) [ "${ip%.*}" = "${prefix%.*}" ] && return 0 ;;
32) [ "$ip" = "$prefix" ] && return 0 ;;
esac
;;
esac
for net in $LAN6_NETS $(uci -q get minigate.login_guard.whitelist 2>/dev/null); do
cidr_match "$ip" "$net" && return 0
done
return 1
}
# === 确保 nft set + drop rule 存在(fw4 reload 后自动重建) ===
# 返回 0 表示刚重建过,调用方需要从持久化文件恢复封禁。
nft_set_spec() {
case "$1" in
"$SET6") echo '{ type ipv6_addr; flags timeout; }' ;;
*) echo '{ type ipv4_addr; flags timeout; }' ;;
esac
}
nft_rule_match() {
case "$1" in
"$SET6") echo "ip6 saddr" ;;
*) echo "ip saddr" ;;
esac
}
# === 确保 nft set + drop rule 存在 ===
# 规则和集合随包放在 /usr/share/nftables.d,防火墙每次重载都会带上;这里兜底处理
# 安装后防火墙尚未重载的情况。返回 0 表示刚重建过,调用方需要从持久化文件恢复封禁。
ensure_nft() {
local recreated=0
if ! nft list set $TABLE $SET >/dev/null 2>&1; then
nft "add set $TABLE $SET { type ipv4_addr; flags timeout; timeout ${BANTIME}s; }" 2>/dev/null
recreated=1
fi
if ! nft list chain $TABLE input 2>/dev/null | grep -q "@$SET"; then
nft "insert rule $TABLE input ip saddr @$SET counter drop comment \"minigate-login-guard\"" 2>/dev/null
recreated=1
fi
local recreated=0 set chain
for set in "$SET4" "$SET6"; do
if ! nft list set $TABLE $set >/dev/null 2>&1; then
nft "add set $TABLE $set $(nft_set_spec "$set")" 2>/dev/null
recreated=1
fi
done
chain=$(nft list chain $TABLE input 2>/dev/null)
for set in "$SET4" "$SET6"; do
if ! printf '%s\n' "$chain" | grep -q "@$set"; then
nft "insert rule $TABLE input $(nft_rule_match "$set") @$set counter drop comment \"minigate-login-guard\"" 2>/dev/null
recreated=1
fi
done
[ $recreated -eq 1 ] || return 1
log "已创建 nft set + drop 规则"
return 0
}
# 防火墙重载后,随包的集合会重新出现但内容为空;持久化文件里仍有未过期封禁时恢复
sets_lost_bans() {
local now set
now=$(date +%s)
for set in "$SET4" "$SET6"; do
nft list set $TABLE $set 2>/dev/null | grep -q 'elements' && continue
while read -r ip exp; do
[ -n "$ip" ] || continue
case "$exp" in ''|*[!0-9]*) continue ;; esac
[ "$exp" -gt "$now" ] || continue
[ "$(set_for_ip "$ip")" = "$set" ] && return 0
done < "$BANS_FILE"
done
return 1
}
add_ban_element() {
local ip="$1" seconds="$2" set
set=$(set_for_ip "$ip")
[ -n "$set" ] || return 1
nft add element $TABLE $set "{ $ip timeout ${seconds}s }" 2>/dev/null
}
persist_ban() {
local ip="$1" exp="$2"
grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true
echo "$ip $exp" >> "${BANS_FILE}.tmp"
mv "${BANS_FILE}.tmp" "$BANS_FILE"
}
# === 把 IP 加入封禁集合 + 持久化 ===
ban_ip() {
local ip="$1"
local exp=$(($(date +%s) + BANTIME))
nft add element $TABLE $SET "{ $ip timeout ${BANTIME}s }" 2>/dev/null
# 去重写入持久化文件
grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true
echo "$ip $exp" >> "${BANS_FILE}.tmp"
mv "${BANS_FILE}.tmp" "$BANS_FILE"
log "BAN $ip 12小时(达到 $THRESHOLD 次失败)"
if ! add_ban_element "$ip" "$BANTIME"; then
log "封禁 $ip 失败:防火墙未接受该地址"
return 1
fi
persist_ban "$ip" "$exp"
log "BAN $ip $(fmt_duration "$BANTIME")(达到 $THRESHOLD 次失败)"
rm -f "$COUNTER_DIR/$ip"
}
@@ -102,9 +258,9 @@ restore_bans() {
local count=0
while read -r ip exp; do
[ -z "$ip" ] && continue
if [ "$exp" -gt "$now" ]; then
local remaining=$((exp - now))
nft add element $TABLE $SET "{ $ip timeout ${remaining}s }" 2>/dev/null
case "$exp" in ''|*[!0-9]*) continue ;; esac
if [ "$exp" -gt "$now" ] && [ -n "$(set_for_ip "$ip")" ]; then
add_ban_element "$ip" $((exp - now))
echo "$ip $exp" >> "$tmp"
count=$((count + 1))
fi
@@ -117,6 +273,7 @@ restore_bans() {
handle_failure() {
local ip="$1"
[ -z "$ip" ] && return
[ -n "$(ip_family "$ip")" ] || return
is_whitelisted "$ip" && return
# 滑动窗口:超过 window 秒的旧失败丢弃
@@ -139,14 +296,24 @@ handle_failure() {
if [ "$count" -ge "$THRESHOLD" ]; then
# 加 ban 前再确认 nft 还在(fw4 可能重载)
local ban_target="$ip"
repair_nft
ban_ip "$ban_target"
ban_ip "$ip"
fi
}
# 取日志行里最后一个 " from " 后的地址:用户名由访客提供,可能伪造前面的 from。
# dropbear 以 "地址:端口" 记录(IPv6 不加方括号),LuCI 只记录地址。
extract_ip() {
echo "$1" | grep -oE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" | head -1
local line="$1" with_port="$2" addr
addr=${line##* from }
addr=${addr%% *}
case "$addr" in
\[*\]:*) addr=${addr#\[}; addr=${addr%%\]*} ;;
*)
[ "$with_port" = "1" ] && case "$addr" in *:[0-9]*) addr=${addr%:*} ;; esac
;;
esac
[ -n "$(ip_family "$addr")" ] && echo "$addr"
}
extract_json_field() {
@@ -171,8 +338,24 @@ access_log_loop() {
done
}
syslog_loop() {
logread -f 2>/dev/null | while read -r line; do
case "$line" in
*"Bad password attempt"*"from"*|\
*"Login attempt for nonexistent user"*"from"*)
handle_failure "$(extract_ip "$line" 1)"
;;
*"luci: failed login"*"from"*)
handle_failure "$(extract_ip "$line" 0)"
;;
esac
done
}
repair_nft() {
ensure_nft && restore_bans
if ensure_nft || sets_lost_bans; then
restore_bans
fi
return 0
}
@@ -183,15 +366,42 @@ trim_log() {
tail -n 500 "$LOG_FILE" > "${LOG_FILE}.tmp" && mv -f "${LOG_FILE}.tmp" "$LOG_FILE"
}
# === 后台 watchdog:每 5 分钟检查 nft 资源 ===
# === 后台 watchdog:防火墙重载后尽快恢复封禁 ===
watchdog_loop() {
local tick=0
while true; do
sleep 300
sleep "$WATCHDOG_INTERVAL"
repair_nft
trim_log
tick=$((tick + 1))
if [ $((tick % 10)) -eq 0 ]; then
load_lan6_nets
trim_log
fi
done
}
stop_process_tree() {
local child
for child in $(pgrep -P "$1" 2>/dev/null); do
stop_process_tree "$child"
done
kill "$1" 2>/dev/null
}
# procd 停止时只向本进程发 TERM;logread -f / tail -f 忽略 SIGPIPE 不会自行退出,需逐个结束。
# 停止后不再拦截(与旧版移除规则一致),持久化文件保留,下次启动恢复。
stop_watcher() {
local child
trap - TERM INT
for child in $(pgrep -P $$ 2>/dev/null); do
stop_process_tree "$child"
done
nft flush set $TABLE $SET4 2>/dev/null
nft flush set $TABLE $SET6 2>/dev/null
log "登录防护已停止"
exit 0
}
# === 主监控循环 ===
run_watcher() {
[ "$ENABLED" = "1" ] || {
@@ -199,28 +409,19 @@ run_watcher() {
exit 0
}
load_lan6_nets
ensure_nft
restore_bans
log "登录防护启动 阈值=$THRESHOLD 窗口=${WINDOW}s 封禁=${BANTIME}s"
log "登录防护启动 阈值=$THRESHOLD 窗口=${WINDOW}s 封禁=$(fmt_duration "$BANTIME")"
# 后台 watchdog
trap stop_watcher TERM INT
watchdog_loop &
# MiniGate 反代访问 LuCI 时,LuCI 日志只会看到反代内网 IP。
# 这里补充监听 nginx access log,用 JSON client 字段记录真实公网 IP。
access_log_loop &
# 主循环:监控系统日志
logread -f 2>/dev/null | while read -r line; do
case "$line" in
*"Bad password attempt"*"from"*|\
*"Login attempt for nonexistent user"*"from"*|\
*"luci: failed login"*"from"*)
ip=$(extract_ip "$line")
handle_failure "$ip"
;;
esac
done
syslog_loop &
# 在 wait 中才能及时响应 TERM;任一循环全部退出后进程结束,由 procd 重新拉起
wait
}
# === CLI 命令 ===
@@ -228,37 +429,26 @@ run_watcher() {
# 用 nft -j 拿封禁列表,输出每行: <IP> <剩余秒数>
# 注意 nft -j 输出可能有空格、字段顺序也不固定(有的 elem 多个 timeout 字段)
parse_set_json() {
nft -j list set $TABLE $SET 2>/dev/null | awk '
{
# 在每条 elem 块({...})内匹配 val + expires
# [^{}]* 限制不跨越 brace 边界
while (match($0, /"val":[ ]*"[0-9.]+"[^{}]*"expires":[ ]*[0-9]+/)) {
block = substr($0, RSTART, RLENGTH)
$0 = substr($0, RSTART + RLENGTH)
# 提取 IP
if (match(block, /[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/)) {
ip = substr(block, RSTART, RLENGTH)
local set
for set in "$SET4" "$SET6"; do
nft -j list set $TABLE $set 2>/dev/null | awk '
{
# 在每条 elem 块({...})内匹配 val + expires;[^{}]* 限制不跨越 brace 边界
while (match($0, /"val":[ ]*"[0-9a-fA-F:.]+"[^{}]*"expires":[ ]*[0-9]+/)) {
block = substr($0, RSTART, RLENGTH)
$0 = substr($0, RSTART + RLENGTH)
if (match(block, /"val":[ ]*"[0-9a-fA-F:.]+"/)) {
ip = substr(block, RSTART, RLENGTH)
sub(/^"val":[ ]*"/, "", ip); sub(/"$/, "", ip)
}
if (match(block, /"expires":[ ]*[0-9]+/)) {
exp_str = substr(block, RSTART, RLENGTH)
gsub(/[^0-9]/, "", exp_str)
print ip, exp_str
}
}
# 提取 expires
if (match(block, /"expires":[ ]*[0-9]+/)) {
exp_str = substr(block, RSTART, RLENGTH)
gsub(/[^0-9]/, "", exp_str)
print ip, exp_str
}
}
}'
}
# 把秒数转人类可读
fmt_duration() {
local s=$1
if [ "$s" -ge 3600 ]; then
printf "%dh%dm" $((s/3600)) $(((s%3600)/60))
elif [ "$s" -ge 60 ]; then
printf "%dm%ds" $((s/60)) $((s%60))
else
printf "%ds" "$s"
fi
}'
done
}
cmd_list() {
@@ -270,7 +460,7 @@ cmd_list() {
return
fi
echo "$out" | while read -r ip secs; do
printf " %-18s 剩余 %s\n" "$ip" "$(fmt_duration "$secs")"
printf " %-40s 剩余 %s\n" "$ip" "$(fmt_duration "$secs")"
done
}
@@ -293,15 +483,16 @@ cmd_status() {
read -r first count < "$f"
now=$(date +%s)
age=$((now - first))
printf " %-18s %d/%d (距首次失败 %ds)\n" "$ip" "$count" "$THRESHOLD" "$age"
printf " %-40s %d/%d (距首次失败 %ds)\n" "$ip" "$count" "$THRESHOLD" "$age"
done
fi
}
cmd_unban() {
local ip="$1"
[ -z "$ip" ] && { echo "用法: $0 unban <IP>"; exit 1; }
nft delete element $TABLE $SET "{ $ip }" 2>&1
local ip="$1" set
set=$(set_for_ip "$ip")
[ -n "$set" ] || { echo "用法: $0 unban <IP>"; exit 1; }
nft delete element $TABLE $set "{ $ip }" 2>&1
grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true
mv "${BANS_FILE}.tmp" "$BANS_FILE"
rm -f "$COUNTER_DIR/$ip"
@@ -311,19 +502,20 @@ cmd_unban() {
cmd_ban() {
local ip="$1"
[ -z "$ip" ] && { echo "用法: $0 ban <IP>"; exit 1; }
[ -n "$(ip_family "$ip")" ] || { echo "用法: $0 ban <IP>"; exit 1; }
ensure_nft
local exp=$(($(date +%s) + BANTIME))
nft add element $TABLE $SET "{ $ip timeout ${BANTIME}s }" 2>&1
grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true
echo "$ip $exp" >> "${BANS_FILE}.tmp"
mv "${BANS_FILE}.tmp" "$BANS_FILE"
if ! add_ban_element "$ip" "$BANTIME"; then
echo "✗ 封禁 $ip 失败"
exit 1
fi
persist_ban "$ip" $(($(date +%s) + BANTIME))
echo "✓ 已封禁 $ip"
log "MANUAL ban $ip"
}
cmd_flush() {
nft flush set $TABLE $SET 2>&1 || true
nft flush set $TABLE $SET4 2>&1 || true
nft flush set $TABLE $SET6 2>&1 || true
> "$BANS_FILE"
rm -rf "$COUNTER_DIR"/*
echo "✓ 已清空所有封禁"
@@ -356,7 +548,7 @@ case "${1:-help}" in
cat <<EOH
MiniGate 登录防护
$0 run 后台监控(init.d 用)
$0 run 后台监控(procd 启动)
$0 list 列出已封禁 IP
$0 status 状态总览
$0 ban <IP> 手动封禁
+225 -92
View File
@@ -15,9 +15,40 @@ ACCESS_LOG_MAX="${MINIGATE_ACCESS_LOG_MAX:-1048576}"
LAN_HOSTS_FILE="${MINIGATE_LAN_HOSTS_FILE:-/tmp/hosts/minigate}"
GEO_DIR="${MINIGATE_GEO_DIR:-/usr/lib/minigate/geo}"
MIME_TYPES="${MINIGATE_MIME_TYPES:-/etc/nginx/mime.types}"
LOCK_FILE="${MINIGATE_PROXY_LOCK:-/var/lock/minigate-proxy.lock}"
STATUS_FILE="${MINIGATE_PROXY_STATUS:-/var/run/minigate/proxy.status}"
INIT_SCRIPT="${MINIGATE_INIT:-/etc/init.d/minigate}"
# 每次生成使用独立临时目录记录端口、默认站点和通配符设置,结束后删除
GEN_TMP=""
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [PROXY] $*" >> "$LOGFILE"; }
# 生成配置时的提示同时写日志,并在总览页显示
warn() {
log "$*"
[ -n "$GEN_TMP" ] && printf '%s\n' "$*" >> "$GEN_TMP/warnings"
}
# LuCI 保存、证书续期和更新可能同时触发生成;串行化,避免互相删除刚生成的文件。
lock_proxy() {
command -v flock >/dev/null 2>&1 || return 0
mkdir -p "$(dirname "$LOCK_FILE")" 2>/dev/null
exec 6>"$LOCK_FILE" || return 0
flock -x 6
}
write_status() {
local result="$1" message="$2" tmp
mkdir -p "$(dirname "$STATUS_FILE")" 2>/dev/null || return 0
tmp="${STATUS_FILE}.$$"
{
printf 'result=%s\n' "$result"
printf 'time=%s\n' "$(date '+%Y-%m-%d %H:%M:%S')"
printf 'message=%s\n' "$message"
[ -n "$GEN_TMP" ] && [ -f "$GEN_TMP/warnings" ] && sed 's/^/warning=/' "$GEN_TMP/warnings"
} > "$tmp" && mv -f "$tmp" "$STATUS_FILE"
}
generate_main_conf() {
local http_redirect=$(get_http_redirect)
local use_stream=0
@@ -27,7 +58,7 @@ generate_main_conf() {
use_stream=1
fi
if [ "$use_stream" = "1" ] && [ ! -f "$STREAM_MODULE" ]; then
log "同端口HTTP跳转需要 nginx-mod-stream"
warn "同端口HTTP跳转需要 nginx-mod-stream,请先安装"
return 1
fi
@@ -62,6 +93,11 @@ EOF
default $proxy_protocol_addr;
"" $remote_addr;
}
# 所有站点都支持 WebSocket;普通请求仍按 nginx 默认关闭上游连接
map $http_upgrade $minigate_connection_upgrade {
default upgrade;
"" close;
}
log_format minigate_json escape=json
'{"time":"$time_iso8601",'
'"domain":"$server_name",'
@@ -97,9 +133,19 @@ find_cert() {
return 1
}
# nginx 1.25 起用独立的 http2 指令;同时兼容以后的主版本号
check_h2() {
local m=$(nginx -v 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1 | cut -d. -f2)
[ "${m:-0}" -ge 25 ] && echo "new" || echo "old"
local v major minor
v=$(nginx -v 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1)
major=${v%%.*}
minor=${v#*.}
case "$major" in ''|*[!0-9]*) major=0 ;; esac
case "$minor" in ''|*[!0-9]*) minor=0 ;; esac
if [ "$major" -gt 1 ] || { [ "$major" = "1" ] && [ "$minor" -ge 25 ]; }; then
echo "new"
else
echo "old"
fi
}
# 读取全局 IPv6 监听设置
@@ -229,7 +275,7 @@ SEOF
ensure_default_server() {
local lport="$1" ssl="$2" h2s="$3" ipv6_listen="$4" mux="$5"
local key=$(echo "${lport}_${ssl}_${mux}" | tr -c 'A-Za-z0-9_' '_')
local mark="/tmp/minigate_default_${key}.tmp"
local mark="${GEN_TMP}/default_${key}"
[ -f "$mark" ] && return 0
: > "$mark"
write_default_server "${SITES_DIR}/000_default_${key}.conf" "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux"
@@ -254,7 +300,7 @@ SEOF
ensure_stream_server() {
local lport="$1" ipv6_listen="$2"
local mark="/tmp/minigate_stream_${lport}.tmp"
local mark="${GEN_TMP}/stream_${lport}"
local conf="${STREAMS_DIR}/stream_${lport}.conf"
[ -f "$mark" ] && return 0
: > "$mark"
@@ -320,11 +366,15 @@ SEOF
write_geo_guard "$conf"
if [ "$ssl" = "1" ]; then
local cp=$(find_cert "$domain")
[ -n "$cp" ] && cat >> "$conf" <<SEOF
if [ -n "$cp" ]; then
cat >> "$conf" <<SEOF
ssl_certificate ${cp}/fullchain.pem;
ssl_certificate_key ${cp}/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
SEOF
else
warn "${domain} 没有可用证书,HTTPS 访问会失败;请先在「SSL 证书」页签发"
fi
fi
# 判断目标地址是否为 IPv6
@@ -345,22 +395,77 @@ SEOF
proxy_http_version 1.1;
proxy_read_timeout 300s;
proxy_buffering off;
SEOF
[ "$ws" = "1" ] && cat >> "$conf" <<SEOF
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Connection \$minigate_connection_upgrade;
}
}
SEOF
echo " }" >> "$conf"; echo "}" >> "$conf"
}
# 同一端口只能用于一种协议:明文 HTTP、HTTPS,或 HTTPS + HTTP 跳转(stream 分流)。
# 先占用的规则生效,冲突的规则跳过并提示,避免生成 nginx 无法加载的配置。
claim_port() {
local lport="$1" mode="$2" domain="$3" file used
file="${GEN_TMP}/port_${lport}"
if [ -f "$file" ]; then
used=$(cat "$file")
[ "$used" = "$mode" ] && return 0
warn "跳过 ${domain}:端口 ${lport} 已用于$(port_mode_label "$used"),不能同时用于$(port_mode_label "$mode")"
return 1
fi
echo "$mode" > "$file"
}
port_mode_label() {
case "$1" in
http) echo "HTTP" ;;
https) echo "HTTPS" ;;
*) echo "HTTPS(含 HTTP 跳转)" ;;
esac
}
# 生成一条站点规则;返回非 0 表示因端口冲突被跳过
add_site() {
local domain="$1" lport="$2" taddr="$3" tport="$4" ssl="$5" h2s="$6" ipv6_listen="$7" http_redirect="$8"
local mux=0 mode=http conf
if [ "$ssl" = "1" ]; then
mode=https
if [ "$http_redirect" = "1" ]; then
mux=1
mode=mux
fi
fi
claim_port "$lport" "$mode" "$domain" || return 1
if [ "$mux" = "1" ]; then
ensure_stream_server "$lport" "$ipv6_listen"
ensure_default_server "$lport" "0" "$h2s" "$ipv6_listen" "1"
fi
ensure_default_server "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux"
SITE_INDEX=$((SITE_INDEX + 1))
conf="${SITES_DIR}/site_${SITE_INDEX}.conf"
: > "$conf"
write_server "$conf" "$domain" "$lport" "$taddr" "$tport" "$ssl" "1" "1" "$h2s" "$ipv6_listen" "$mux"
[ "$mux" = "1" ] && write_redirect_server "$conf" "$domain" "$lport"
return 0
}
wildcard_key() {
printf '%s' "$1" | tr -c 'A-Za-z0-9.-' '_'
}
generate_sites() {
local own_tmp=0
if [ -z "$GEN_TMP" ]; then
GEN_TMP=$(mktemp -d "${TMPDIR:-/tmp}/minigate-gen.XXXXXX") || return 1
own_tmp=1
fi
mkdir -p "$SITES_DIR" "$STREAMS_DIR" "$SOCKET_DIR"
rm -f "$SITES_DIR"/*.conf
rm -f "$STREAMS_DIR"/*.conf
local h2s=$(check_h2)
local idx=0
local ipv6_listen=$(get_ipv6_listen)
local http_redirect=$(get_http_redirect)
SITE_INDEX=0
# === 通配符域名设置 (proxy_wildcard sections) ===
local wc_sections=$(uci -q show minigate | grep '=proxy_wildcard$' | cut -d. -f2 | cut -d= -f1)
@@ -372,7 +477,7 @@ generate_sites() {
local lport=$(uci -q get minigate.${sec}.listen_port); lport=${lport:-443}
local ssl=$(uci -q get minigate.${sec}.ssl); ssl=${ssl:-0}
local base=$(echo "$domain" | sed 's/^\*\.//')
echo "${lport}|${ssl}" > "/tmp/minigate_proxy_${base}.tmp"
echo "${lport}|${ssl}" > "${GEN_TMP}/wildcard_$(wildcard_key "$base")"
log "通配符: $domain (端口:$lport https:$ssl ipv6:$ipv6_listen)"
done
@@ -386,58 +491,38 @@ generate_sites() {
local taddr=$(uci -q get minigate.${sec}.target_addr)
local tport=$(uci -q get minigate.${sec}.target_port); tport=${tport:-80}
local ssl=$(uci -q get minigate.${sec}.ssl); ssl=${ssl:-1}
local h2=$(uci -q get minigate.${sec}.http2); h2=${h2:-1}
local ws=$(uci -q get minigate.${sec}.websocket); ws=${ws:-0}
[ -z "$domain" ] || [ -z "$taddr" ] && continue
local mux=0
if [ "$http_redirect" = "1" ] && [ "$ssl" = "1" ]; then
mux=1
ensure_stream_server "$lport" "$ipv6_listen"
ensure_default_server "$lport" "0" "$h2s" "$ipv6_listen" "1"
fi
ensure_default_server "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux"
idx=$((idx + 1))
local conf="${SITES_DIR}/site_${idx}.conf"; > "$conf"
write_server "$conf" "$domain" "$lport" "$taddr" "$tport" "$ssl" "$h2" "$ws" "$h2s" "$ipv6_listen" "$mux"
[ "$mux" = "1" ] && write_redirect_server "$conf" "$domain" "$lport"
add_site "$domain" "$lport" "$taddr" "$tport" "$ssl" "$h2s" "$ipv6_listen" "$http_redirect"
done
# === 子域名规则:继承通配符主域名设置 ===
# === 子域名规则:继承通配符主域名设置;主域名未启用时整组不生效 ===
local subs=$(uci -q show minigate | grep '=subproxy$' | cut -d. -f2 | cut -d= -f1)
for sec in $subs; do
local enabled=$(uci -q get minigate.${sec}.enabled)
[ "${enabled:-1}" = "1" ] || continue
local parent=$(uci -q get minigate.${sec}.parent_domain)
local prefix=$(uci -q get minigate.${sec}.prefix)
local taddr=$(uci -q get minigate.${sec}.target_addr)
local tport=$(uci -q get minigate.${sec}.target_port); tport=${tport:-80}
local ws=$(uci -q get minigate.${sec}.websocket); ws=${ws:-0}
[ -z "$parent" ] || [ -z "$prefix" ] || [ -z "$taddr" ] && continue
local domain="${prefix}.${parent}"
local parent_conf="/tmp/minigate_proxy_${parent}.tmp"
local lport="443" ssl="0"
if [ -f "$parent_conf" ]; then
local settings=$(cat "$parent_conf")
lport=$(echo "$settings" | cut -d'|' -f1)
ssl=$(echo "$settings" | cut -d'|' -f2)
local parent_conf="${GEN_TMP}/wildcard_$(wildcard_key "$parent")"
if [ ! -f "$parent_conf" ]; then
warn "跳过 ${domain}:通配符主域名 *.${parent} 未启用或不存在"
continue
fi
idx=$((idx + 1))
local mux=0
if [ "$http_redirect" = "1" ] && [ "$ssl" = "1" ]; then
mux=1
ensure_stream_server "$lport" "$ipv6_listen"
ensure_default_server "$lport" "0" "$h2s" "$ipv6_listen" "1"
fi
ensure_default_server "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux"
local conf="${SITES_DIR}/site_${idx}.conf"; > "$conf"
write_server "$conf" "$domain" "$lport" "$taddr" "$tport" "$ssl" "1" "$ws" "$h2s" "$ipv6_listen" "$mux"
[ "$mux" = "1" ] && write_redirect_server "$conf" "$domain" "$lport"
local settings=$(cat "$parent_conf")
local lport=${settings%%|*}
local ssl=${settings#*|}
add_site "$domain" "$lport" "$taddr" "$tport" "$ssl" "$h2s" "$ipv6_listen" "$http_redirect"
done
rm -f /tmp/minigate_proxy_*.tmp
rm -f /tmp/minigate_default_*.tmp
rm -f /tmp/minigate_stream_*.tmp
if [ "$own_tmp" = "1" ]; then
rm -rf "$GEN_TMP"
GEN_TMP=""
fi
return 0
}
# === 局域网直连解析 ===
@@ -523,62 +608,109 @@ ${ip} ${domain}"
log "局域网直连解析 -> ${ip}: $(echo $domains)"
}
do_stop() {
if [ -f "$PID_FILE" ]; then
local pid=$(cat "$PID_FILE" 2>/dev/null)
if [ -n "$pid" ]; then
kill "$pid" 2>/dev/null
local i=0
while [ $i -lt 10 ] && kill -0 "$pid" 2>/dev/null; do
sleep 1; i=$((i + 1))
done
kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null
fi
rm -f "$PID_FILE"
rm -f "$SOCKET_DIR"/*.sock
log "已停止"
# 保存当前配置,生成或检查失败时恢复,磁盘上始终保留最后一份可用配置
snapshot_config() {
local dir="$1"
mkdir -p "$dir/sites" "$dir/streams" || return 1
cp -p "$SITES_DIR"/*.conf "$dir/sites/" 2>/dev/null
cp -p "$STREAMS_DIR"/*.conf "$dir/streams/" 2>/dev/null
[ -f "$NGINX_CONF" ] && cp -p "$NGINX_CONF" "$dir/main.conf"
return 0
}
restore_config() {
local dir="$1"
rm -f "$SITES_DIR"/*.conf "$STREAMS_DIR"/*.conf
cp -p "$dir/sites/"*.conf "$SITES_DIR/" 2>/dev/null
cp -p "$dir/streams/"*.conf "$STREAMS_DIR/" 2>/dev/null
if [ -f "$dir/main.conf" ]; then
cp -p "$dir/main.conf" "$NGINX_CONF"
else
rm -f "$NGINX_CONF"
fi
}
# 生成并检查配置;失败时恢复上一份配置,不影响正在运行的 nginx
prepare_config() {
generate_sites || return 1
generate_main_conf || return 1
nginx -t -c "$NGINX_CONF" >> "$LOGFILE" 2>&1 || { log "配置错误"; return 1; }
local backup output rc=0 message=""
GEN_TMP=$(mktemp -d "${TMPDIR:-/tmp}/minigate-gen.XXXXXX") || { log "无法创建临时目录"; return 1; }
backup="${GEN_TMP}/backup"
output="${GEN_TMP}/nginx-test.log"
snapshot_config "$backup"
if ! generate_sites || ! generate_main_conf; then
rc=1
message="生成配置失败,已保留上一份配置"
elif ! nginx -t -c "$NGINX_CONF" > "$output" 2>&1; then
rc=1
cat "$output" >> "$LOGFILE"
message="配置检查失败,已保留上一份配置:$(grep -m1 -E 'emerg|error' "$output" | sed 's/^nginx: //')"
log "配置错误,已恢复上一份配置"
fi
if [ "$rc" = "0" ]; then
write_status ok "配置检查通过"
else
restore_config "$backup"
write_status error "$message"
fi
rm -rf "$GEN_TMP"
GEN_TMP=""
return "$rc"
}
start_prepared() {
do_stop 2>/dev/null
sleep 1
nginx_pid() {
local pid
pid=$(cat "$PID_FILE" 2>/dev/null)
case "$pid" in ''|*[!0-9]*) return 1 ;; esac
kill -0 "$pid" 2>/dev/null || return 1
echo "$pid"
}
# 由 procd 启动(前台运行,崩溃后自动拉起)。启动前清掉上次异常退出留下的 socket。
do_run() {
mkdir -p "$SOCKET_DIR"
rm -f "$SOCKET_DIR"/*.sock
nginx -c "$NGINX_CONF" >> "$LOGFILE" 2>&1 && log "已启动" || { log "启动失败"; return 1; }
exec nginx -c "$NGINX_CONF" -g 'daemon off;'
}
do_start() {
[ -z "$(which nginx 2>/dev/null)" ] && { log "nginx未安装"; return 1; }
prepare_config || return 1
start_prepared || return 1
sync_lan_hosts
# 让运行中的 nginx 加载新配置。stream 模块增减需要重启进程,交给 procd 处理。
apply_config() {
local pid old_stream="$1" new_stream=0
grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && new_stream=1
pid=$(nginx_pid)
if [ -n "$pid" ] && [ "$old_stream" = "$new_stream" ]; then
kill -HUP "$pid" || return 1
log "已重载"
else
MINIGATE_SKIP_PREPARE=1 "$INIT_SCRIPT" start 6>&- >/dev/null 2>&1 || return 1
log "已交由 procd 启动"
fi
}
do_reload() {
local old_mux=0 new_mux=0
grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && old_mux=1
local old_stream=0
[ "$(uci -q get minigate.global.enabled)" = "1" ] || return 0
lock_proxy
grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && old_stream=1
prepare_config || return 1
grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && new_mux=1
if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE" 2>/dev/null) 2>/dev/null; then
if [ "$old_mux" != "$new_mux" ]; then
start_prepared || return 1
else
kill -HUP $(cat "$PID_FILE") || return 1
log "已重载"
fi
else
start_prepared || return 1
fi
apply_config "$old_stream" || return 1
sync_lan_hosts
}
# 仅向已加载新配置的 nginx 发送重载信号(init 已完成生成和检查)
do_hup() {
local pid
pid=$(nginx_pid) || return 0
kill -HUP "$pid" && log "已重载"
}
do_stop() {
remove_lan_hosts
rm -f "$SOCKET_DIR"/*.sock 2>/dev/null
write_status stopped "反向代理已停止"
}
# /var/log 在内存中且 nginx 不会自行轮转。访问日志保留最近记录到 .1 后原地清空:
# nginx 以追加方式继续写入,登录防护的 tail -f 从头读取新内容,不会重放旧记录。
trim_logs() {
@@ -599,11 +731,12 @@ if [ "${MINIGATE_PROXY_LIBRARY_ONLY:-0}" = "1" ]; then
fi
case "$1" in
start) do_start ;;
stop) do_stop; remove_lan_hosts ;;
prepare|test) lock_proxy; prepare_config ;;
run) do_run ;;
reload) do_reload ;;
test) prepare_config ;;
hup) do_hup ;;
stop) do_stop ;;
trim-logs) trim_logs ;;
lan-dns) sync_lan_hosts ;;
*) echo "用法: $0 {start|stop|reload|test|trim-logs|lan-dns}" ;;
*) echo "用法: $0 {prepare|run|reload|hup|stop|trim-logs|lan-dns}" ;;
esac
+6 -2
View File
@@ -158,6 +158,7 @@ remove_firewall_state() {
[ -z "$ROOT" ] || return 0
remove_nft_rule_for_set login_banned_v4
remove_nft_rule_for_set login_banned_v6
remove_nft_rule_for_set minigate_blocked
if command -v iptables >/dev/null 2>&1; then
while iptables -D INPUT -m set --match-set minigate_blocked src -j DROP >/dev/null 2>&1; do :; done
@@ -170,7 +171,8 @@ remove_cron() {
cron_file="$(root_path /etc/crontabs/root)"
if [ -f "$cron_file" ]; then
cron_tmp="${cron_file}.minigate.$$"
if sed '/minigate/d' "$cron_file" > "$cron_tmp"; then
# 只删除本插件脚本的任务,保留用户自己写的其他任务
if sed '\#/usr/lib/minigate/#d' "$cron_file" > "$cron_tmp"; then
cat "$cron_tmp" > "$cron_file"
fi
rm -f "$cron_tmp"
@@ -205,7 +207,9 @@ remove_program_files() {
rm -f \
"$(root_path /etc/init.d/minigate)" \
"$(root_path /usr/lib/lua/luci/controller/minigate.lua)" \
"$(root_path /usr/lib/lua/luci/i18n/minigate.zh-cn.lmo)"
"$(root_path /usr/lib/lua/luci/i18n/minigate.zh-cn.lmo)" \
"$(root_path /usr/share/nftables.d/table-pre/20-minigate-login-guard.nft)" \
"$(root_path /usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft)"
rm -rf \
"$(root_path /usr/lib/minigate)" \
"$(root_path /usr/lib/lua/luci/model/cbi/minigate)" \
+4 -2
View File
@@ -3,7 +3,7 @@
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
CURRENT_VERSION="2026.10.26"
CURRENT_VERSION="2026.10.28"
REPOSITORY="tpxcer/minigate"
API_URL="https://api.github.com/repos/${REPOSITORY}/releases/latest"
DOWNLOAD_ROOT="https://github.com/${REPOSITORY}/releases/download"
@@ -159,7 +159,9 @@ backup_program() {
usr/lib/minigate \
usr/lib/lua/luci/controller/minigate.lua \
usr/lib/lua/luci/model/cbi/minigate \
usr/lib/lua/luci/view/minigate; do
usr/lib/lua/luci/view/minigate \
usr/share/nftables.d/table-pre/20-minigate-login-guard.nft \
usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft; do
[ -e "/$path" ] && paths="$paths $path"
done
@@ -0,0 +1,3 @@
# MiniGate 登录防护:放在 input 链最前,已建立的连接也会被断开
ip saddr @login_banned_v4 counter drop comment "minigate-login-guard"
ip6 saddr @login_banned_v6 counter drop comment "minigate-login-guard"
@@ -0,0 +1,10 @@
# MiniGate 登录防护:封禁集合随防火墙每次重载重建,封禁记录由 login_guard.sh 恢复
set login_banned_v4 {
type ipv4_addr
flags timeout
}
set login_banned_v6 {
type ipv6_addr
flags timeout
}
+6 -2
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.26"
PKG_VERSION="2026.10.28"
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"
@@ -29,7 +29,9 @@ mkdir -p \
"$DATA_DIR/etc/minigate/certs" \
"$DATA_DIR/etc/minigate/nginx/sites" \
"$DATA_DIR/etc/minigate/nginx/streams" \
"$DATA_DIR/etc/minigate/login-guard"
"$DATA_DIR/etc/minigate/login-guard" \
"$DATA_DIR/usr/share/nftables.d/table-pre" \
"$DATA_DIR/usr/share/nftables.d/chain-pre/input"
install -m 0644 "$ROOT_DIR/luasrc/controller/minigate.lua" "$DATA_DIR/usr/lib/lua/luci/controller/minigate.lua"
install -m 0644 "$ROOT_DIR"/luasrc/model/cbi/minigate/*.lua "$DATA_DIR/usr/lib/lua/luci/model/cbi/minigate/"
@@ -38,6 +40,8 @@ install -m 0755 "$ROOT_DIR"/root/usr/lib/minigate/*.sh "$DATA_DIR/usr/lib/miniga
cp -R "$ROOT_DIR/root/usr/lib/minigate/geo" "$DATA_DIR/usr/lib/minigate/"
install -m 0644 "$ROOT_DIR/root/etc/config/minigate" "$DATA_DIR/etc/config/minigate"
install -m 0755 "$ROOT_DIR/root/etc/init.d/minigate" "$DATA_DIR/etc/init.d/minigate"
install -m 0644 "$ROOT_DIR/root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft" "$DATA_DIR/usr/share/nftables.d/table-pre/"
install -m 0644 "$ROOT_DIR/root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft" "$DATA_DIR/usr/share/nftables.d/chain-pre/input/"
if [ -f "$ROOT_DIR/po/zh-cn/minigate.po" ] && command -v po2lmo >/dev/null 2>&1; then
mkdir -p "$DATA_DIR/usr/lib/lua/luci/i18n"
+6 -3
View File
@@ -10,11 +10,12 @@ test('LuCI access settings offer all provinces, default off, validate multiple c
const result = spawnSync(lua, ['-'], { encoding: 'utf8', input: `
local options = {}
package.preload['luci.sys'] = function() return {} end
package.preload['luci.controller.minigate'] = function() return {ddns_state=function() return {} end} end
package.preload['luci.model.uci'] = function()
return {cursor=function() return {foreach=function() end, get=function() end} end}
end
Flag, Value, DynamicList, ListValue, DummyValue = 'Flag', 'Value', 'DynamicList', 'ListValue', 'DummyValue'
NamedSection, TypedSection = 'NamedSection', 'TypedSection'
Flag, Value, DynamicList, ListValue, DummyValue, MultiValue = 'Flag', 'Value', 'DynamicList', 'ListValue', 'DummyValue', 'MultiValue'
NamedSection, TypedSection, SimpleSection = 'NamedSection', 'TypedSection', 'SimpleSection'
function Map()
return {section=function()
return {option=function(self, kind, name)
@@ -34,7 +35,7 @@ test('LuCI access settings offer all provinces, default off, validate multiple c
assert(dofile(${JSON.stringify(path.join(root, 'luasrc/model/cbi/minigate/proxy.lua'))}))
assert(options.access_enabled.default == '0')
local regions = options.access_regions
assert(regions.kind == 'DynamicList')
assert(regions.kind == 'MultiValue' and regions.widget == 'checkbox')
assert(regions.default == '510000')
assert(regions.dependency[1] == 'access_enabled' and regions.dependency[2] == '1')
assert(regions.choices['510000'] == '四川省')
@@ -42,6 +43,8 @@ test('LuCI access settings offer all provinces, default off, validate multiple c
assert(count == 34)
assert(regions:validate({'510000', '500000'}, 'global'))
assert(regions:validate('510000', 'global'))
assert(regions:validate('510000 500000', 'global'))
assert(not regions:validate('510000 ../x', 'global'))
assert(not regions:validate({}, 'global'))
assert(not regions:validate({''}, 'global'))
assert(not regions:validate({'990000'}, 'global'))
+27 -8
View File
@@ -64,17 +64,36 @@ test('HTTP redirect rejects unapproved source before sending a redirect', () =>
assert.ok(result.conf.indexOf('return 444;') < result.conf.indexOf('return 308'));
});
test('the legacy scanner cannot add global firewall bans in request access mode', () => {
const scanner = path.join(root, 'root/usr/lib/minigate/geofence.sh');
test('the installer removes the legacy scanner and its global firewall set', () => {
const installer = fs.readFileSync(path.join(root, 'install.sh'), 'utf8');
const init = fs.readFileSync(path.join(root, 'root/etc/init.d/minigate'), 'utf8');
assert.equal(fs.existsSync(path.join(root, 'root/usr/lib/minigate/geofence.sh')), false);
assert.doesNotMatch(init, /geofence/);
assert.match(installer, /rm -f \/usr\/lib\/minigate\/after_commit\.sh \/usr\/lib\/minigate\/geofence\.sh/);
assert.match(installer, /nft delete set inet fw4 minigate_blocked/);
assert.match(installer, /uci -q delete minigate\.global\.blocked_ips/);
});
test('legacy province names migrate to request access region codes', () => {
const installer = fs.readFileSync(path.join(root, 'install.sh'), 'utf8');
const body = installer.slice(installer.indexOf('migrate_legacy_geofence() {'), installer.indexOf('\nmigrate_legacy_geofence\n'));
const result = spawnSync('/bin/sh', ['-c', `
export MINIGATE_GEOFENCE_LIBRARY_ONLY=1
. '${scanner}'
uci() { [ "$3" = minigate.global.access_enabled ] && echo 1; }
init_block_set() { echo unexpected >&2; return 1; }
scan_and_block
D=${JSON.stringify(root)}
uci() {
case "$*" in
'-q get minigate.global.geo_enabled') echo 1 ;;
'-q get minigate.global.access_enabled') echo 0 ;;
'-q get minigate.global.geo_allowed') echo '四川,重庆' ;;
*) echo "uci $*" ;;
esac
}
${body}
migrate_legacy_geofence
`], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, '');
assert.match(result.stdout, /add_list minigate\.global\.access_regions=510000/);
assert.match(result.stdout, /add_list minigate\.global\.access_regions=500000/);
assert.match(result.stdout, /set minigate\.global\.access_enabled=1/);
});
test('bundled regions all cover IPv4 and IPv6 and data follows every install path', () => {
-92
View File
@@ -1,92 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const script = path.join(root, 'root/usr/lib/minigate/after_commit.sh');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
function fixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-after-commit-test-'));
const config = path.join(dir, 'minigate');
const marker = path.join(dir, 'applied.log');
fs.writeFileSync(config, "option enabled '0'\n");
fs.mkdirSync(path.join(dir, 'lib'));
fs.writeFileSync(path.join(dir, 'lib/ddns.sh'), `printf 'ddns %s %s\\n' "$1" "$(cat ${JSON.stringify(config)})" >> ${JSON.stringify(marker)}\n`);
fs.writeFileSync(path.join(dir, 'lib/proxy.sh'), `printf 'proxy %s\\n' "$1" >> ${JSON.stringify(marker)}\n`);
fs.writeFileSync(path.join(dir, 'init'), `#!/bin/sh\nprintf 'init %s\\n' "$1" >> ${JSON.stringify(marker)}\n`);
fs.chmodSync(path.join(dir, 'init'), 0o755);
return { dir, config, marker };
}
function run(fx, action, wait) {
return spawnSync('/bin/sh', [script, action], {
encoding: 'utf8',
timeout: 5000,
env: {
...process.env,
MINIGATE_CONFIG_FILE: fx.config,
MINIGATE_COMMIT_WAIT: String(wait),
MINIGATE_INIT: path.join(fx.dir, 'init'),
MINIGATE_LIB_DIR: path.join(fx.dir, 'lib')
}
});
}
const applied = fx => (fs.existsSync(fx.marker) ? fs.readFileSync(fx.marker, 'utf8').trim() : '');
async function waitFor(check, timeout = 4000) {
const started = Date.now();
while (Date.now() - started < timeout) {
if (check()) return true;
await sleep(100);
}
return check();
}
test('saved settings are applied only after LuCI commits the new configuration', async () => {
const fx = fixture();
const started = Date.now();
const result = run(fx, 'ddns', 10);
assert.equal(result.status, 0, result.stderr);
assert.ok(Date.now() - started < 1000, 'the LuCI hook must return immediately');
await sleep(1300);
assert.equal(applied(fx), '', 'must not run against the old configuration');
fs.writeFileSync(fx.config, "option enabled '1'\n");
assert.ok(await waitFor(() => applied(fx) !== ''));
assert.equal(applied(fx), "ddns force option enabled '1'");
});
test('an unchanged save still applies after the wait limit', async () => {
const fx = fixture();
assert.equal(run(fx, 'proxy', 1).status, 0);
assert.ok(await waitFor(() => applied(fx) !== ''));
assert.equal(applied(fx), 'proxy reload');
});
test('service actions go through the init script and unknown actions are rejected', async () => {
const fx = fixture();
assert.equal(run(fx, 'restart', 1).status, 0);
assert.ok(await waitFor(() => applied(fx) !== ''));
assert.equal(applied(fx), 'init restart');
const bad = fixture();
assert.equal(run(bad, 'reboot', 1).status, 2);
await sleep(1500);
assert.equal(applied(bad), '');
});
test('LuCI pages defer their actions and saving DDNS no longer restarts the proxy', () => {
const page = name => fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate', name), 'utf8');
assert.match(page('general.lua'), /after_commit\.sh " \.\. \(en == "1" and "restart" or "stop"\)/);
assert.match(page('ddns.lua'), /after_commit\.sh ddns/);
assert.match(page('proxy.lua'), /after_commit\.sh proxy/);
assert.match(page('login_guard.lua'), /after_commit\.sh reload/);
assert.doesNotMatch(page('ddns.lua'), /minigate restart/);
const makefile = fs.readFileSync(path.join(root, 'Makefile'), 'utf8');
assert.match(makefile, /root\/usr\/lib\/minigate\/after_commit\.sh/);
});
+47 -9
View File
@@ -31,11 +31,12 @@ function readRows(file) {
return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean);
}
function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) {
function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0, currentIp = '198.51.100.20' }) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-ddns-update-'));
const cacheFile = path.join(tempDir, 'minigate_ddns_cfg01.cache');
const refreshLog = path.join(tempDir, 'refresh.log');
const uciLog = path.join(tempDir, 'uci.log');
const stateDir = path.join(tempDir, 'state');
if (cachedIp) fs.writeFileSync(cacheFile, `${cachedIp}\n`);
const command = `
@@ -44,6 +45,7 @@ function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) {
export MINIGATE_CACHE_DIR=${JSON.stringify(tempDir)}
export MINIGATE_HISTORY_DIR=${JSON.stringify(path.join(tempDir, 'history'))}
export MINIGATE_DDNS_LOG=${JSON.stringify(path.join(tempDir, 'ddns.log'))}
export MINIGATE_DDNS_STATE_DIR=${JSON.stringify(stateDir)}
. ${JSON.stringify(ddnsScript)}
uci() {
[ "\${1:-}" = "-q" ] && shift
@@ -57,11 +59,11 @@ function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) {
get:minigate.cfg01.ip_url) printf 'http://ip.example/\\n' ;;
get:minigate.cfg01.check_interval) printf '300\\n' ;;
get:minigate.cfg01.ip_version) printf 'ipv4\\n' ;;
set:*) printf '%s\\n' "\$2" >> ${JSON.stringify(uciLog)} ;;
set:*|commit:*) printf '%s\\n' "\$*" >> ${JSON.stringify(uciLog)} ;;
esac
return 0
}
get_ip4_url() { printf '198.51.100.20\\n'; }
get_ip4_url() { printf '%s\\n' ${JSON.stringify(currentIp)}; }
record_ip_history() { return 0; }
calc_next() { printf '12:00:00\\n'; }
cf_update() {
@@ -78,7 +80,15 @@ function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) {
const refreshes = fs.existsSync(refreshLog) ? readRows(refreshLog) : [];
const uciWrites = fs.existsSync(uciLog) ? readRows(uciLog) : [];
const cache = fs.existsSync(cacheFile) ? fs.readFileSync(cacheFile, 'utf8').trim() : '';
return { result, refreshes, uciWrites, cache };
const stateFile = path.join(stateDir, '__example_com.state');
const state = {};
if (fs.existsSync(stateFile)) {
for (const line of readRows(stateFile)) {
const at = line.indexOf('=');
state[line.slice(0, at)] = line.slice(at + 1);
}
}
return { result, refreshes, uciWrites, cache, state };
}
function runCloudflareScenario(mode) {
@@ -251,14 +261,14 @@ function runScheduled({ force = false, checked }) {
test('scheduled DDNS checks honour each record interval and skip disabled records', () => {
const due = runScheduled({ checked: { cfg01: 1000000 - 280, cfg02: 1000000 - 600 } });
assert.deepEqual(due.calls, ['cfg01 0', 'commit']);
assert.deepEqual(due.calls, ['cfg01 0'], 'scheduled runs never commit /etc/config');
const idle = runScheduled({ checked: { cfg01: 1000000 - 100, cfg02: 1000000 - 600 } });
assert.deepEqual(idle.calls, [], 'nothing due: no update and no config commit');
assert.equal(idle.logged, false, 'nothing due: no log noise every five minutes');
const forced = runScheduled({ force: true, checked: { cfg01: 1000000 - 10, cfg02: 1000000 - 10 } });
assert.deepEqual(forced.calls, ['cfg01 1', 'cfg02 1', 'commit']);
assert.deepEqual(forced.calls, ['cfg01 1', 'cfg02 1']);
});
test('DDNS interval is at least five minutes and the next run matches the cron grid', () => {
@@ -280,7 +290,9 @@ test('successful Cloudflare changes refresh local DNS caches once', () => {
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
assert.deepEqual(scenario.refreshes, ['*.example.com']);
assert.equal(scenario.cache, '198.51.100.20');
assert.ok(scenario.uciWrites.includes('minigate.cfg01.status=ok'));
assert.equal(scenario.state.status, 'ok');
assert.equal(scenario.state.ip, '198.51.100.20');
assert.deepEqual(scenario.uciWrites, [], 'runtime state must not be written to /etc/config');
});
test('unchanged forced sync does not flush caches, but a stale local IP does', () => {
@@ -299,14 +311,40 @@ test('Cloudflare failure never flushes local caches', () => {
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
assert.deepEqual(scenario.refreshes, []);
assert.equal(scenario.cache, '198.51.100.19');
assert.ok(scenario.uciWrites.includes('minigate.cfg01.status=error'));
assert.equal(scenario.state.status, 'error');
assert.deepEqual(scenario.uciWrites, []);
});
test('cache refresh failure does not turn a successful DDNS update into an error', () => {
const scenario = runUpdateScenario({ cfResult: 'changed', refreshResult: 1 });
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
assert.deepEqual(scenario.refreshes, ['*.example.com']);
assert.ok(scenario.uciWrites.includes('minigate.cfg01.status=ok'));
assert.equal(scenario.state.status, 'ok');
});
test('a private interface address is never pushed to Cloudflare', () => {
for (const ip of ['192.168.2.2', '10.0.0.5', '100.64.1.1', '172.20.0.1']) {
const scenario = runUpdateScenario({ cfResult: 'changed', currentIp: ip });
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
assert.equal(scenario.state.status, 'error', ip);
assert.match(scenario.state.message, /不是公网地址/);
assert.equal(scenario.cache, '', 'cf_update must not run for ' + ip);
assert.deepEqual(scenario.refreshes, []);
}
});
test('public address checks reject private, carrier NAT, ULA and link-local ranges', () => {
const { output } = runHistory(`
for ip in 1.2.3.4 182.147.210.9 10.1.1.1 172.16.0.1 172.32.0.1 192.168.1.1 100.64.0.1 100.128.0.1 127.0.0.1 169.254.1.1 0.1.2.3 224.0.0.1 256.1.1.1 1.2.3; do
is_public_ipv4 "$ip" && printf '%s ' "$ip"
done
printf '| '
for ip in 2001:db8::1 240e:1::5 fd00::1 fe80::1 ::1 'x:y'; do
is_public_ipv6 "$ip" && printf '%s ' "$ip"
done
true
`);
assert.equal(output, '1.2.3.4 182.147.210.9 172.32.0.1 100.128.0.1 | 2001:db8::1 240e:1::5');
});
test('local cache refresh uses sing-box API and dnsmasq SIGHUP without service restart', () => {
-81
View File
@@ -1,81 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const script = path.join(root, 'root/usr/lib/minigate/geofence.sh');
function runLibrary(body, accessLog = '') {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-geofence-test-'));
const log = path.join(dir, 'access.log');
fs.writeFileSync(log, accessLog);
const command = `
set -u
export MINIGATE_GEOFENCE_LIBRARY_ONLY=1
export MINIGATE_ACCESS_LOG=${JSON.stringify(log)}
export MINIGATE_GEOFENCE_LOG=${JSON.stringify(path.join(dir, 'geofence.log'))}
export MINIGATE_GEO_CACHE_DIR=${JSON.stringify(path.join(dir, 'cache'))}
. ${JSON.stringify(script)}
${body}
`;
return spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
}
const entry = client => JSON.stringify({ time: '2026-10-17T07:22:40+08:00', domain: 'box.example.com', client, status: 200 });
const ACCESS_LOG = [
entry('192.168.2.1'),
entry('198.51.100.7'),
entry('172.20.0.5'),
entry('203.0.113.9'),
entry('198.51.100.7'),
entry('2001:db8::1'),
entry('100.100.1.1'),
entry('192.168.2.15')
].join('\n') + '\n';
test('private, loopback, link-local and carrier NAT addresses are recognised', () => {
const privateIps = ['0.0.0.0', '10.1.2.3', '127.0.0.1', '169.254.1.1', '172.16.0.1', '172.31.255.255',
'192.168.2.15', '100.64.0.1', '100.99.1.1', '100.127.255.255'];
const publicIps = ['172.15.0.1', '172.32.0.1', '100.63.255.255', '100.128.0.1', '198.51.100.7', '1.10.0.1', '192.169.0.1'];
const check = ips => ips.map(ip => `is_private_ip ${ip} && echo "${ip} private" || echo "${ip} public"`).join('\n');
const result = runLibrary(check([...privateIps, ...publicIps]));
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.stdout.trim().split('\n'), [
...privateIps.map(ip => `${ip} private`),
...publicIps.map(ip => `${ip} public`)
]);
});
test('client extraction works without grep -P and keeps only unique public IPv4 visitors', () => {
const result = runLibrary(`
grep() { [ "\${1:-}" = "-oP" ] && { echo "grep: unrecognized option: P" >&2; return 2; }; command grep "$@"; }
extract_public_ips
`, ACCESS_LOG);
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.stdout.trim().split('\n'), ['198.51.100.7', '203.0.113.9']);
});
test('scan never looks up or blocks LAN visitors', () => {
const result = runLibrary(`
uci() {
[ "\${1:-}" = "-q" ] && shift
case "\${2:-}" in
minigate.global.geo_enabled) echo 1 ;;
minigate.global.geo_allowed) echo 四川 ;;
esac
}
init_block_set() { :; }
nft() { :; }
sleep() { :; }
lookup_geo() { echo "lookup $1" >&2; echo 广东; }
block_ip() { echo "block $1"; }
mkdir -p "$CACHE_DIR"
scan_and_block
`, ACCESS_LOG);
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.stderr.trim().split('\n'), ['lookup 198.51.100.7', 'lookup 203.0.113.9']);
assert.deepEqual(result.stdout.trim().split('\n'), ['block 198.51.100.7', 'block 203.0.113.9']);
});
+77
View File
@@ -0,0 +1,77 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const init = path.join(root, 'root/etc/init.d/minigate');
function runInit(body, env = {}) {
return spawnSync('/bin/sh', ['-c', `. ${JSON.stringify(init)}\n${body}`], { encoding: 'utf8', env: { ...process.env, ...env } });
}
test('cron entries are managed by script path and user jobs mentioning minigate survive', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-cron-'));
const cron = path.join(dir, 'root');
fs.writeFileSync(cron, '0 1 * * * /root/backup-minigate-certs.sh\n*/2 * * * * /bin/sh /usr/lib/minigate/geofence.sh scan\n');
let result = runInit('update_cron 1', { MINIGATE_CRON_FILE: cron });
assert.equal(result.status, 0, result.stderr);
const enabled = fs.readFileSync(cron, 'utf8');
assert.match(enabled, /^0 1 \* \* \* \/root\/backup-minigate-certs\.sh$/m);
assert.doesNotMatch(enabled, /geofence/);
assert.match(enabled, /\/usr\/lib\/minigate\/ddns\.sh$/m);
assert.match(enabled, /\/usr\/lib\/minigate\/acme\.sh renew$/m);
result = runInit('update_cron 0', { MINIGATE_CRON_FILE: cron });
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(cron, 'utf8'), '0 1 * * * /root/backup-minigate-certs.sh\n');
});
test('reload digests only include the settings of the page that changed', () => {
const result = runInit(`
md5sum() { cat; }
uci() {
cat <<'UCI'
minigate.global=global
minigate.global.enabled='1'
minigate.@ddns[0]=ddns
minigate.@ddns[0].domain='a.example.com'
minigate.web=proxy
minigate.web.domain='b.example.com'
minigate.login_guard=login_guard
minigate.login_guard.threshold='3'
UCI
}
echo "== ddns"; config_digest ddns
echo "== proxy"; config_digest "global proxy proxy_wildcard subproxy"
`);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, [
'== ddns',
'minigate.@ddns[0]=ddns',
"minigate.@ddns[0].domain='a.example.com'",
'== proxy',
'minigate.global=global',
"minigate.global.enabled='1'",
'minigate.web=proxy',
"minigate.web.domain='b.example.com'",
''
].join('\n'));
});
test('services run under procd, background work drops the procd lock and LuCI pages no longer poll for commits', () => {
const source = fs.readFileSync(init, 'utf8');
assert.match(source, /procd_open_instance login_guard/);
assert.match(source, /procd_set_param env MINIGATE_LG_DIGEST=/);
assert.match(source, /run_background\(\) \{\n \( close_procd_lock; "\$@" \) <\/dev\/null >\/dev\/null 2>&1 &/);
assert.match(source, /\( close_procd_lock; \/etc\/init\.d\/cron restart \)/);
// 只有 eval 字符串里出现多位数描述符,dash 等 shell 才能解析本文件
assert.deepEqual(source.split('\n').filter(line => /\d{2,}>&/.test(line)), [" [ -e /proc/self/fd/1000 ] && eval 'exec 1000>&-'"]);
for (const page of ['general', 'ddns', 'proxy', 'login_guard']) {
const lua = fs.readFileSync(path.join(root, `luasrc/model/cbi/minigate/${page}.lua`), 'utf8');
assert.doesNotMatch(lua, /on_after_commit|after_commit\.sh/, page);
}
assert.equal(fs.existsSync(path.join(root, 'root/usr/lib/minigate/after_commit.sh')), false);
});
+4 -3
View File
@@ -127,9 +127,10 @@ test('no generated sites means no hosts file', () => {
test('reload syncs LAN hosts only after nginx accepted the new configuration', () => {
const box = sandbox();
const body = ok => `
PID_FILE=${JSON.stringify(path.join(box.dir, 'missing.pid'))}
prepare_config() { return 0; }
start_prepared() { return ${ok ? 0 : 1}; }
uci() { echo 1; }
lock_proxy() { :; }
prepare_config() { return ${ok ? 0 : 1}; }
apply_config() { return 0; }
sync_lan_hosts() { echo synced; }
do_reload
`;
+106 -39
View File
@@ -8,24 +8,29 @@ const { spawn, spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const script = path.join(root, 'root/usr/lib/minigate/login_guard.sh');
function runRepair({ firewallReady }) {
// firewallReady: 集合和规则都在;setsEmpty: 防火墙重载后随包集合存在但内容为空
function runRepair({ firewallReady, setsEmpty = false }) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-login-guard-test-'));
const dataDir = path.join(dir, 'data');
const bans = path.join(dataDir, 'bans.txt');
const calls = path.join(dir, 'nft.log');
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(bans, '198.51.100.4 9999999999\n198.51.100.5 1\n');
fs.writeFileSync(bans, '198.51.100.4 9999999999\n198.51.100.5 1\n2001:db8::9 9999999999\n');
const before = fs.statSync(bans).ino;
const command = `
set -u
logger() { :; }
uci() { return 1; }
ubus() { return 1; }
nft() {
printf '%s\\n' "$*" >> ${JSON.stringify(calls)}
case "$*" in
'list set inet fw4 login_banned_v4') [ ${firewallReady ? 1 : 0} = 1 ]; return ;;
'list chain inet fw4 input') [ ${firewallReady ? 1 : 0} = 1 ] && printf 'ip saddr @login_banned_v4 counter drop\\n'; return 0 ;;
'list set inet fw4 login_banned_v4'|'list set inet fw4 login_banned_v6')
[ ${firewallReady ? 1 : 0} = 1 ] || return 1
[ ${setsEmpty ? 1 : 0} = 1 ] || printf 'elements = { 198.51.100.4 }\\n'
return 0 ;;
'list chain inet fw4 input') [ ${firewallReady ? 1 : 0} = 1 ] && printf 'ip saddr @login_banned_v4 counter drop\\nip6 saddr @login_banned_v6 counter drop\\n'; return 0 ;;
esac
return 0
}
@@ -52,56 +57,118 @@ test('periodic nft check does not rewrite the persisted ban list when rules are
assert.equal(scenario.rewritten, false, 'bans.txt must not be rewritten on every watchdog tick');
});
test('a firewall reload that drops the set restores persisted bans immediately', () => {
test('a firewall reload that drops the set restores persisted IPv4 and IPv6 bans immediately', () => {
const scenario = runRepair({ firewallReady: false });
assert.equal(scenario.result.status, 0, scenario.result.stderr);
assert.match(scenario.calls, /add set inet fw4 login_banned_v4/);
assert.match(scenario.calls, /add set inet fw4 login_banned_v4 \{ type ipv4_addr; flags timeout; \}/);
assert.match(scenario.calls, /add set inet fw4 login_banned_v6 \{ type ipv6_addr; flags timeout; \}/);
assert.match(scenario.calls, /insert rule inet fw4 input ip saddr @login_banned_v4/);
assert.match(scenario.calls, /insert rule inet fw4 input ip6 saddr @login_banned_v6/);
assert.match(scenario.calls, /add element inet fw4 login_banned_v4 \{ 198\.51\.100\.4 timeout \d+s \}/);
assert.match(scenario.calls, /add element inet fw4 login_banned_v6 \{ 2001:db8::9 timeout \d+s \}/);
assert.doesNotMatch(scenario.calls, /198\.51\.100\.5/, 'expired bans must not be restored');
assert.equal(scenario.bans, '198.51.100.4 9999999999\n');
assert.equal(scenario.bans, '198.51.100.4 9999999999\n2001:db8::9 9999999999\n');
});
// BusyBox pgrep 只排除自身,不像 macOS 那样默认排除祖先进程。
function busyboxPgrep(dir) {
const shim = path.join(dir, 'bin/pgrep');
fs.mkdirSync(path.dirname(shim), { recursive: true });
fs.writeFileSync(shim, `#!/bin/sh
self=$$
case "$1" in
-f) ps -A -o pid= -o ppid= -o args= | awk -v self="$self" -v re="$2" '
{ pid=$1; ppid=$2; if (pid == self || ppid == self) next; $1=""; $2=""; sub(/^ +/, ""); if ($0 ~ re) print pid }' ;;
-P) ps -A -o pid= -o ppid= | awk -v self="$self" -v parent="$2" '$2 == parent && $1 != self { print $1 }' ;;
esac
`);
fs.chmodSync(shim, 0o755);
return path.dirname(shim);
test('packaged firewall sets that come back empty after a reload are refilled', () => {
const scenario = runRepair({ firewallReady: true, setsEmpty: true });
assert.equal(scenario.result.status, 0, scenario.result.stderr);
assert.doesNotMatch(scenario.calls, /add set|insert rule/);
assert.match(scenario.calls, /add element inet fw4 login_banned_v4 \{ 198\.51\.100\.4 timeout \d+s \}/);
});
test('fw4 includes declare both ban sets before the input rules that use them', () => {
const tablePre = fs.readFileSync(path.join(root, 'root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft'), 'utf8');
const chainPre = fs.readFileSync(path.join(root, 'root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft'), 'utf8');
assert.match(tablePre, /set login_banned_v4 \{\n\ttype ipv4_addr\n\tflags timeout\n\}/);
assert.match(tablePre, /set login_banned_v6 \{\n\ttype ipv6_addr\n\tflags timeout\n\}/);
assert.match(chainPre, /^ip saddr @login_banned_v4 counter drop comment "minigate-login-guard"$/m);
assert.match(chainPre, /^ip6 saddr @login_banned_v6 counter drop comment "minigate-login-guard"$/m);
for (const file of ['Makefile', 'install.sh', 'scripts/build-ipk.sh', 'root/usr/lib/minigate/uninstall.sh']) {
assert.match(fs.readFileSync(path.join(root, file), 'utf8'), /nftables\.d\/chain-pre\/input\/20-minigate-login-guard\.nft/, file);
}
});
function library(body) {
return spawnSync('/bin/sh', ['-c', `
export MINIGATE_LG_LIBRARY_ONLY=1
export MINIGATE_LG_DATA_DIR=${JSON.stringify(fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-lg-lib-')))}
export MINIGATE_LG_RUN_DIR=${JSON.stringify(fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-lg-run-')))}
export MINIGATE_LG_LOG=/dev/null
logger() { :; }
uci() { [ "$3" = minigate.login_guard.whitelist ] && echo "203.0.113.0/20 2001:db8:abcd::/48"; return 0; }
. ${JSON.stringify(script)}
${body}
`], { encoding: 'utf8' });
}
test('stopping the login guard ends its children but not a caller that mentions it', async () => {
const init = path.join(root, 'root/etc/init.d/minigate');
test('addresses from dropbear and LuCI logs are parsed for both IP families and spoofed usernames', () => {
const result = library(`
extract_ip "authpriv.warn dropbear[1]: Bad password attempt for 'root' from 198.51.100.7:52144" 1
extract_ip "dropbear[1]: Login attempt for nonexistent user 'x from 9.9.9.9:1' from 2001:db8::7:52144" 1
extract_ip "luci: failed login on / for root from 2001:db8::8" 0
extract_ip "luci: failed login on / for root from bad;rm" 0 || echo rejected
`);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), '198.51.100.7\n2001:db8::7\n2001:db8::8\nrejected');
});
test('whitelist matches any IPv4 prefix length and IPv6 subnets, private ranges stay exempt', () => {
const result = library(`
for ip in 203.0.120.9 203.0.128.1 2001:db8:abcd:1::5 2001:db8:abce::5 192.168.2.5 fd00::5 fe80::1 198.51.100.7 2400:da00::1; do
is_whitelisted "$ip" && printf 'white:%s ' "$ip" || printf 'watch:%s ' "$ip"
done
`);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), 'white:203.0.120.9 watch:203.0.128.1 white:2001:db8:abcd:1::5 watch:2001:db8:abce::5 white:192.168.2.5 white:fd00::5 white:fe80::1 watch:198.51.100.7 watch:2400:da00::1');
});
test('a ban the firewall rejects is reported instead of being logged as banned', () => {
const result = library(`
nft() { return 1; }
ban_ip 198.51.100.9 && echo banned || echo failed
cat "$BANS_FILE"
`);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), 'failed');
});
test('procd stopping the login guard ends logread and tail children and lifts enforcement', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-login-guard-stop-'));
const bin = path.join(dir, 'bin');
const followed = path.join(dir, 'access.log');
const guardScript = path.join(dir, 'login_guard.sh');
const marker = path.join(dir, 'caller-survived');
const nftLog = path.join(dir, 'nft.log');
fs.mkdirSync(bin);
fs.writeFileSync(followed, '');
// 与 login_guard.sh run 相同的结构:后台子 shell + tail -f 管道;忽略 SIGPIPE 的 tail 失去读端后不会自行退出
fs.writeFileSync(guardScript, `trap "" PIPE\n( while :; do sleep 1; done ) &\ntail -n 0 -f ${JSON.stringify(followed)} | while read -r line; do :; done\n`);
const guard = spawn('/bin/sh', [guardScript, 'run'], { detached: true, stdio: 'ignore' });
guard.unref();
const shim = (name, body) => fs.writeFileSync(path.join(bin, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 });
shim('uci', 'case "$3" in minigate.login_guard.enabled) echo 1 ;; *) exit 1 ;; esac');
shim('nft', `printf '%s\\n' "$*" >> ${JSON.stringify(nftLog)}; case "$*" in 'list chain'*) echo '@login_banned_v4 @login_banned_v6' ;; 'list set'*) echo elements ;; esac; exit 0`);
shim('logger', 'exit 0');
shim('ubus', 'exit 1');
// logread -f 与 tail -f 一样会一直阻塞,用 tail 模拟
shim('logread', `exec tail -n 0 -f ${JSON.stringify(followed)}`);
const guard = spawn('/bin/sh', [script, 'run'], {
stdio: 'ignore',
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
MINIGATE_LG_DATA_DIR: path.join(dir, 'data'),
MINIGATE_LG_RUN_DIR: path.join(dir, 'run'),
MINIGATE_LG_LOG: path.join(dir, 'lg.log'),
MINIGATE_ACCESS_LOG: followed
}
});
const pgrep = pattern => spawnSync('pgrep', ['-f', pattern], { encoding: 'utf8' }).stdout.trim();
const started = Date.now();
while (!pgrep(`tail -n 0 -f ${followed}`) && Date.now() - started < 3000) await new Promise(resolve => setTimeout(resolve, 100));
assert.ok(pgrep(`tail -n 0 -f ${followed}`), 'fake login guard did not start');
// 调用方的命令行里含有登录防护的命令文本(例如 SSH 一次性执行的脚本),不能被当成登录防护结束
const caller = `logger() { :; }; nft() { return 1; }; . ${JSON.stringify(init)}; LOGIN_GUARD=${JSON.stringify(guardScript)}; echo "checking ${guardScript} run" >/dev/null; stop_login_guard; echo ok > ${JSON.stringify(marker)}`;
const result = spawnSync('/bin/sh', ['-c', caller], { encoding: 'utf8', env: { ...process.env, PATH: `${busyboxPgrep(dir)}:${process.env.PATH}` } });
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.existsSync(marker), true, 'stop_login_guard killed its own caller');
while (pgrep(`tail -n 0 -f ${followed}`).split('\n').filter(Boolean).length < 2 && Date.now() - started < 4000) {
await new Promise(resolve => setTimeout(resolve, 100));
}
assert.equal(pgrep(`tail -n 0 -f ${followed}`).split('\n').filter(Boolean).length, 2, 'both followers should run');
guard.kill('SIGTERM');
await new Promise(resolve => guard.once('exit', resolve));
await new Promise(resolve => setTimeout(resolve, 300));
assert.equal(pgrep(`tail -n 0 -f ${followed}`), '', 'tail -f must not be left behind');
assert.equal(pgrep(`${guardScript} run`), '');
assert.equal(pgrep(`tail -n 0 -f ${followed}`), '', 'tail -f / logread -f must not be left behind');
assert.match(fs.readFileSync(nftLog, 'utf8'), /flush set inet fw4 login_banned_v4[\s\S]*flush set inet fw4 login_banned_v6/);
});
test('upgrades stop the old login guard tree before running the previous init script', () => {
+210
View File
@@ -0,0 +1,210 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const lua = process.env.MINIGATE_TEST_LUA;
const root = path.resolve(__dirname, '..');
const controller = path.join(root, 'luasrc/controller/minigate.lua');
function runLua(source) {
const result = spawnSync(lua, ['-'], { encoding: 'utf8', input: source });
assert.equal(result.status, 0, result.stderr || result.stdout);
return result.stdout.trim();
}
function luaFiles(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const full = path.join(dir, entry.name);
return entry.isDirectory() ? luaFiles(full) : (entry.name.endsWith('.lua') ? [full] : []);
});
}
test('every LuCI Lua file compiles', { skip: !lua }, () => {
const files = luaFiles(path.join(root, 'luasrc'));
assert.ok(files.length >= 6);
const checks = files.map(file => `assert(loadfile(${JSON.stringify(file)}))`).join('\n');
runLua(checks + '\nprint("ok")');
});
// 加载控制器时只需要 luci.http;其他模块在函数内部按需 require
const controllerPrelude = `
local response, status_code, calls, form = nil, nil, {}, {}
luci = {http={
formvalue=function(name) return form[name] end,
prepare_content=function() end,
write_json=function(v) response=v end,
status=function(code) status_code=code end
}}
package.preload['luci.sys'] = function() return {
call=function(cmd) calls[#calls + 1] = cmd; return 0 end,
exec=function() return '' end
} end
package.preload['luci.model.uci'] = function() return {cursor=function() return {
get=function(_, _, sec) if sec == 'cfg01' then return 'ddns' end end,
foreach=function() end
} end} end
dofile(${JSON.stringify(controller)})
local c = luci.controller.minigate
`;
test('certificate expiry parsing is timezone independent', { skip: !lua }, () => {
const expected = Date.UTC(2026, 11, 16, 12, 0, 0) / 1000;
const out = runLua(controllerPrelude + `
print(c.parse_cert_time('Dec 16 12:00:00 2026 GMT'))
print(c.parse_cert_time('Feb 1 00:00:00 2028 GMT'))
print(tostring(c.parse_cert_time('garbage')))
`);
assert.deepEqual(out.split('\n'), [String(expected), String(Date.UTC(2028, 1, 1) / 1000), 'nil']);
});
test('log view merges sources by time and keeps untimed lines with their entry', { skip: !lua }, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-logs-'));
fs.writeFileSync(path.join(dir, 'ddns.log'), '2026-10-28 10:00:01 [DDNS] a\n2026-10-28 10:00:05 [DDNS] c\n');
fs.writeFileSync(path.join(dir, 'nginx.log'), '2026/10/28 10:00:03 [emerg] b\n');
fs.writeFileSync(path.join(dir, 'update.log'), '2026-10-28 10:00:04 start\ncurl output without time\n');
const out = runLua(controllerPrelude + `
print(c.merge_logs({{'DDNS', ${JSON.stringify(path.join(dir, 'ddns.log'))}}, {'Nginx', ${JSON.stringify(path.join(dir, 'nginx.log'))}},
{'更新', ${JSON.stringify(path.join(dir, 'update.log'))}}, {'缺失', '/missing.log'}}, 300))
print('--')
print(c.merge_logs({{'DDNS', ${JSON.stringify(path.join(dir, 'ddns.log'))}}}, 1))
`);
assert.equal(out, [
'[DDNS] 2026-10-28 10:00:01 [DDNS] a',
'[Nginx] 2026/10/28 10:00:03 [emerg] b',
'[更新] 2026-10-28 10:00:04 start',
'[更新] curl output without time',
'[DDNS] 2026-10-28 10:00:05 [DDNS] c',
'--',
'[DDNS] 2026-10-28 10:00:05 [DDNS] c'
].join('\n'));
});
test('state files are read by the same domain key the shell scripts write', { skip: !lua }, () => {
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-state-')), 'proxy.status');
fs.writeFileSync(file, 'result=ok\nmessage=配置检查通过\nwarning=one\nwarning=two=2\n');
const out = runLua(controllerPrelude + `
local st = c.read_state(${JSON.stringify(file)})
print(st.result, st.message, #st.warnings, st.warnings[2])
print(c.state_key('*.example.com'), c.state_key('home.example.com'))
print(tostring(c.read_state('/missing/state')))
`);
assert.equal(out, 'ok\t配置检查通过\t2\ttwo=2\n__example_com\thome_example_com\nnil');
});
test('login guard endpoints accept IPv6, quote the address and reject shell text', { skip: !lua }, () => {
const out = runLua(controllerPrelude + `
for _, ip in ipairs({'2001:db8::1', '198.51.100.7', "1.2.3.4'; reboot; '", 'abc'}) do
form.ip = ip; calls = {}; response = nil
c.action_lg_ban()
print(response.success and calls[1] or 'rejected')
end
`);
assert.equal(out, [
"/bin/sh /usr/lib/minigate/login_guard.sh ban '2001:db8::1' >/dev/null 2>&1",
"/bin/sh /usr/lib/minigate/login_guard.sh ban '198.51.100.7' >/dev/null 2>&1",
'rejected',
'rejected'
].join('\n'));
});
test('manual DDNS sync starts a background job only for a real DDNS section', { skip: !lua }, () => {
const out = runLua(controllerPrelude + `
form.section = 'cfg01'; c.action_ddns_sync()
print(response.success, calls[1])
form.section = 'cfg01;reboot'; calls = {}; c.action_ddns_sync()
print(response.success, status_code, #calls)
`);
const [started, rejected] = out.split('\n');
assert.match(started, /^true\t\( \/bin\/sh \/usr\/lib\/minigate\/job\.sh ddns-cfg01 \/bin\/sh \/usr\/lib\/minigate\/ddns\.sh single 'cfg01' \) <\/dev\/null >\/dev\/null 2>&1 &$/);
assert.equal(rejected, 'false\t400\t0');
});
// 用最小 CBI 桩加载每个页面,并执行表单渲染时会调用的 cfgvalue
const cbiPrelude = `
local options = {}
local function node()
local n = {}
function n:option(kind, name, title)
local o = {kind=kind, name=name, choices={}}
function o:value(k, v) self.choices[k] = v or k end
function o:depends() end
options[name] = o
return o
end
function n:tab() end
function n:taboption(_, ...) return n:option(...) end
return n
end
function Map(config, title)
local m = node()
m.title = title
m.uci = {get=function(_, _, sec, opt) local v; if opt == 'domain' then v = 'home.example.com' end; return v 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
luci = {dispatcher={build_url=function(...) return '/cgi-bin/luci/' .. table.concat({...}, '/') end}}
package.preload['luci.sys'] = function() return {exec=function() return '' end, call=function() return 0 end} end
package.preload['nixio.fs'] = function() return {access=function() return false end, dir=function() return nil end, stat=function() return nil end} end
package.preload['luci.model.uci'] = function() return {cursor=function() return {
foreach=function(_, config, kind, fn) if kind == 'ddns' then fn({domain='home.example.com'}); fn({domain='*.example.com'}) end end,
get=function(_, _, _, opt) local v; if opt == 'staging' then v = '1' end; return v end
} end} end
package.preload['luci.controller.minigate'] = function() return {
ddns_state=function() return {status='error', message='IPv4 192.168.2.2 不是公网地址', ip='198.51.100.1'} end,
cert_list=function() return {{domain='*.example.com', expires_date='2026-11-02', days_left=5, staging=true}} end
} end
package.preload['luci.cbi.datatypes'] = function() return {
ip4addr=function(v) return v:match('^%d+%.%d+%.%d+%.%d+$') ~= nil end,
ip6addr=function(v) return v:match('^[%x:]+$') ~= nil and v:find(':') ~= nil end
} end
local function page(name) return assert(dofile(${JSON.stringify(path.join(root, 'luasrc/model/cbi/minigate'))} .. '/' .. name .. '.lua')) end
`;
test('every LuCI page loads and renders its status blocks', { skip: !lua }, () => {
const out = runLua(cbiPrelude + `
for _, name in ipairs({'general', 'ddns', 'acme', 'proxy', 'login_guard'}) do
options = {}
local m = page(name)
assert(m.title:match('^MiniGate'), name .. ' title')
for key, o in pairs(options) do
if o.kind == 'DummyValue' and type(o.cfgvalue) == 'function' then
assert(type(o.cfgvalue(o, 'cfg01')) == 'string', name .. '.' .. key)
end
end
end
options = {}; page('ddns')
print(options._info.cfgvalue(options._info, 'cfg01'):match('不是公网地址') ~= nil)
options = {}; page('acme')
local certs = options._certs.cfgvalue(options._certs)
print(certs:match('剩 5 天') ~= nil, certs:match('测试证书') ~= nil, certs:match('测试模式已开启') ~= nil)
options = {}; page('general')
print(options.enabled ~= nil, options.ipv6_listen == nil)
`);
assert.equal(out, 'true\ntrue\ttrue\ttrue\ntrue\ttrue');
});
test('LuCI forms validate domains, Cloudflare IDs, intervals, thresholds and whitelists', { skip: !lua }, () => {
const out = runLua(cbiPrelude + `
local function ok(o, v) return (o.validate(o, v, 'cfg01')) ~= nil end
options = {}; page('ddns')
print(ok(options.domain, 'home.example.com'), ok(options.domain, '*.example.com'), ok(options.domain, 'bad domain.com'),
ok(options.domain, '-a.example.com'), ok(options.domain, 'localhost'))
print(ok(options.cf_zone_id, string.rep('a1', 16)), ok(options.cf_zone_id, 'zone'), ok(options.cf_api_token, 'abc_DEF-123'), ok(options.cf_api_token, 'abc def'))
print(ok(options.check_interval, '300'), ok(options.check_interval, '60'))
options = {}; page('proxy')
print(ok(options.prefix, 'app'), ok(options.prefix, 'a.b'), ok(options.prefix, '-x'), options.target_addr.datatype)
options = {}; page('login_guard')
print(options.threshold.datatype, ok(options.whitelist, {'203.0.113.0/20', '2001:db8::/48', '8.8.8.8'}), ok(options.whitelist, {'1.2.3.0/255.255.255.0'}), ok(options.whitelist, '10.0.0.0/33'))
`);
assert.equal(out, [
'true\ttrue\tfalse\tfalse\tfalse',
'true\tfalse\ttrue\tfalse',
'true\tfalse',
'true\tfalse\tfalse\thost',
'range(1,100)\ttrue\tfalse\tfalse'
].join('\n'));
});
+127
View File
@@ -65,3 +65,130 @@ test('log trimming is scheduled and the visitor list reads the rotated access lo
assert.match(init, /\/bin\/sh \/usr\/lib\/minigate\/proxy\.sh trim-logs/);
assert.match(controller, /cat \/var\/log\/minigate-access\.log\.1 \/var\/log\/minigate-access\.log 2>\/dev\/null \| tail -n 500/);
});
// 用假的 uci / nginx 生成完整配置,检查跳过规则、回滚和状态文件
function generate(db, { nginxFails = false } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-proxy-gen-'));
const bin = path.join(dir, 'bin');
fs.mkdirSync(bin);
fs.mkdirSync(path.join(dir, 'sites'));
fs.writeFileSync(path.join(dir, 'uci.db'), db.trim() + '\n');
fs.writeFileSync(path.join(bin, 'uci'), `#!/bin/sh
DB=${JSON.stringify(path.join(dir, 'uci.db'))}
[ "$1" = "-q" ] && shift
case "$1" in
show) grep "^$2" "$DB" ;;
get) v=$(grep "^$2=" "$DB" | head -1 | cut -d= -f2-); [ -n "$v" ] && echo "$v" || exit 1 ;;
esac
`, { mode: 0o755 });
fs.writeFileSync(path.join(bin, 'nginx'), `#!/bin/sh
[ "$1" = "-v" ] && { echo "nginx version: nginx/1.26.3" >&2; exit 0; }
${nginxFails ? 'echo "nginx: [emerg] unknown directive in site_1.conf:3" >&2; exit 1' : 'exit 0'}
`, { mode: 0o755 });
fs.writeFileSync(path.join(bin, 'openssl'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
fs.writeFileSync(path.join(dir, 'sites/site_1.conf'), '# previous good site\n');
fs.writeFileSync(path.join(dir, 'main.conf'), '# previous good main\n');
const result = spawnSync('/bin/sh', [script, 'prepare'], {
encoding: 'utf8',
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
TMPDIR: dir,
MINIGATE_PROXY_LOCK: path.join(dir, 'proxy.lock'),
MINIGATE_PROXY_STATUS: path.join(dir, 'proxy.status'),
MINIGATE_NGINX_CONF: path.join(dir, 'main.conf'),
MINIGATE_MIME_TYPES: '/dev/null',
MINIGATE_SITES_DIR: path.join(dir, 'sites'),
MINIGATE_STREAMS_DIR: path.join(dir, 'streams'),
MINIGATE_CERT_DIR: path.join(dir, 'certs'),
MINIGATE_LOGFILE: path.join(dir, 'proxy.log'),
MINIGATE_SOCKET_DIR: path.join(dir, 'sock')
}
});
const sites = fs.readdirSync(path.join(dir, 'sites')).sort();
const conf = sites.map(name => fs.readFileSync(path.join(dir, 'sites', name), 'utf8')).join('\n');
const status = fs.existsSync(path.join(dir, 'proxy.status')) ? fs.readFileSync(path.join(dir, 'proxy.status'), 'utf8') : '';
const leftovers = fs.readdirSync(dir).filter(name => name.startsWith('minigate-gen.'));
return { result, dir, sites, conf, status, leftovers, main: fs.readFileSync(path.join(dir, 'main.conf'), 'utf8') };
}
const BASE = `
minigate.global=global
minigate.global.enabled=1
minigate.global.http_redirect=0
minigate.wc=proxy_wildcard
minigate.wc.enabled=WC_ENABLED
minigate.wc.domain=*.example.com
minigate.wc.listen_port=443
minigate.wc.ssl=1
minigate.sub1=subproxy
minigate.sub1.parent_domain=example.com
minigate.sub1.prefix=nas
minigate.sub1.target_addr=192.168.2.10
minigate.sub1.target_port=5000
`;
test('subdomains are skipped when their wildcard parent is disabled instead of falling back to plain HTTP', () => {
const disabled = generate(BASE.replace('WC_ENABLED', '0'));
assert.equal(disabled.result.status, 0, disabled.result.stderr);
assert.doesNotMatch(disabled.conf, /nas\.example\.com/);
assert.match(disabled.status, /warning=跳过 nas\.example\.com:通配符主域名 \*\.example\.com 未启用或不存在/);
const enabled = generate(BASE.replace('WC_ENABLED', '1'));
assert.equal(enabled.result.status, 0, enabled.result.stderr);
assert.match(enabled.conf, /listen 443 ssl;\n server_name nas\.example\.com;/);
assert.doesNotMatch(enabled.conf, /listen 443;\n server_name nas/);
});
test('a disabled subdomain rule is not generated', () => {
const result = generate(BASE.replace('WC_ENABLED', '1') + 'minigate.sub1.enabled=0\n');
assert.equal(result.result.status, 0, result.result.stderr);
assert.doesNotMatch(result.conf, /nas\.example\.com/);
});
test('HTTP and HTTPS rules on one port never produce two default servers', () => {
const result = generate(BASE.replace('WC_ENABLED', '1') + `
minigate.plain=proxy
minigate.plain.enabled=1
minigate.plain.domain=plain.example.org
minigate.plain.listen_port=443
minigate.plain.target_addr=192.168.2.30
minigate.plain.ssl=0
`);
assert.equal(result.result.status, 0, result.result.stderr);
// 普通规则先生成并占用端口,之后的子域名 HTTPS 规则被跳过
assert.equal((result.conf.match(/default_server/g) || []).length, 1);
assert.match(result.conf, /server_name plain\.example\.org;/);
assert.doesNotMatch(result.conf, /nas\.example\.com/);
assert.match(result.status, /warning=跳过 nas\.example\.com:端口 443 已用于HTTP,不能同时用于HTTPS/);
});
test('missing certificates and every site get WebSocket support and a visible warning', () => {
const result = generate(BASE.replace('WC_ENABLED', '1'));
assert.match(result.status, /warning=nas\.example\.com 没有可用证书/);
assert.match(result.conf, /proxy_set_header Connection \$minigate_connection_upgrade;/);
assert.match(result.main, /map \$http_upgrade \$minigate_connection_upgrade/);
assert.match(result.status, /^result=ok$/m);
assert.deepEqual(result.leftovers, [], 'temporary generation directory is removed');
});
test('a configuration nginx rejects is rolled back so the last working files stay on disk', () => {
const result = generate(BASE.replace('WC_ENABLED', '1'), { nginxFails: true });
assert.equal(result.result.status, 1);
assert.deepEqual(result.sites, ['site_1.conf']);
assert.equal(fs.readFileSync(path.join(result.dir, 'sites/site_1.conf'), 'utf8'), '# previous good site\n');
assert.equal(result.main, '# previous good main\n');
assert.match(result.status, /^result=error$/m);
assert.match(result.status, /配置检查失败,已保留上一份配置:\[emerg\] unknown directive/);
assert.deepEqual(result.leftovers, []);
});
test('nginx runs in the foreground under procd and clears stale sockets first', () => {
const source = fs.readFileSync(script, 'utf8');
assert.match(source, /do_run\(\) \{\n mkdir -p "\$SOCKET_DIR"\n rm -f "\$SOCKET_DIR"\/\*\.sock\n exec nginx -c "\$NGINX_CONF" -g 'daemon off;'/);
const init = fs.readFileSync(path.join(root, 'root/etc/init.d/minigate'), 'utf8');
assert.match(init, /USE_PROCD=1/);
assert.match(init, /procd_set_param command \/bin\/sh "\$PROXY" run/);
assert.match(init, /procd_add_reload_trigger "minigate"/);
assert.match(source, /MINIGATE_SKIP_PREPARE=1 "\$INIT_SCRIPT" start 6>&-/);
});
+95 -2
View File
@@ -131,6 +131,95 @@ test('overview renders legacy visitor responses as unknown and escapes log conte
assert.doesNotMatch(html, /<img|已转发|在线|离线|拦截 undefined/);
});
const statusData = extra => ({
enabled: '1', ddns_list: [], proxy_rules: [], certs: [], acme: {}, proxy_warnings: [], ...extra
});
test('overview shows a notice only while MiniGate is disabled', () => {
const page = fakePage(overviewScript);
take(page, 'su').callback(null, statusData({ enabled: '0' }));
assert.equal(page.element('mg-disabled').hidden, false);
page.context.mgLoadStatus();
take(page, 'su').callback(null, statusData({ enabled: '1' }));
assert.equal(page.element('mg-disabled').hidden, true);
});
test('overview DDNS card lists every record and takes the worst status', () => {
const page = fakePage(overviewScript);
take(page, 'su').callback(null, statusData({
ddns_status: 'error',
ddns_list: [
{ domain: 'a.example.com', enabled: '1', status: 'ok', last_ip: '198.51.100.1', last_update: '2026-10-28 10:00:00' },
{ domain: 'b.example.com', enabled: '1', status: 'error', status_msg: 'IPv4 192.168.2.2 不是公网地址<b>' }
]
}));
assert.match(page.element('mg-d1').innerHTML, /异常/);
const body = page.element('mg-d2').innerHTML;
assert.match(body, /a\.example\.com[\s\S]*A 198\.51\.100\.1/);
assert.match(body, /b\.example\.com/);
assert.match(body, /不是公网地址&lt;b&gt;/);
assert.doesNotMatch(body, /<b>/);
});
test('overview certificate card shows expiry dates, days left and test certificates', () => {
const page = fakePage(overviewScript);
take(page, 'su').callback(null, statusData({
acme: { enabled: '1', result: 'error', message: '签发失败 (exit=1)' },
certs: [
{ domain: '*.example.com', expires_date: '2026-11-02', days_left: 5, staging: false },
{ domain: 'test.example.com', expires_date: '2027-01-20', days_left: 84, staging: true }
]
}));
assert.match(page.element('mg-a1').innerHTML, /即将到期/);
const body = page.element('mg-a2').innerHTML;
assert.match(body, /到期 2026-11-02(剩 5 天)/);
assert.match(body, /测试证书,浏览器不信任/);
assert.match(body, /最近一次签发:签发失败/);
const staging = fakePage(overviewScript);
take(staging, 'su').callback(null, statusData({ certs: [{ domain: 'x.example.com', expires_date: '2027-01-20', days_left: 84, staging: true }] }));
assert.match(staging.element('mg-a1').innerHTML, /测试证书/);
});
test('overview proxy card links sites safely and surfaces configuration problems', () => {
const page = fakePage(overviewScript);
take(page, 'su').callback(null, statusData({
proxy_running: true,
proxy_check: 'error',
proxy_message: '配置检查失败,已保留上一份配置:[emerg] x',
proxy_warnings: ['跳过 nas.example.com:通配符主域名 *.example.com 未启用或不存在'],
proxy_rules: [{ scheme: 'https', domain: 'box.example.com', listen_port: '2000', target: '192.168.2.2:8080' },
{ scheme: 'https', domain: '"><img>', listen_port: '443', target: 'x' }]
}));
assert.match(page.element('mg-p1').innerHTML, /最近一次配置未生效/);
const body = page.element('mg-p2').innerHTML;
assert.match(body, /<a class="mg-link" href="https:\/\/box\.example\.com:2000" target="_blank" rel="noopener noreferrer">/);
assert.match(body, /已保留上一份配置/);
assert.match(body, /跳过 nas\.example\.com/);
assert.doesNotMatch(body, /"><img>/);
});
function ddnsPageScript() {
const source = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/ddns.lua'), 'utf8');
const start = source.indexOf('<script type="text/javascript">');
const end = source.indexOf('</script>', start);
return source.slice(start + '<script type="text/javascript">'.length, end).replace(/\]\]\.\.(\w+)\.\.\[\[/g, '$1');
}
test('manual DDNS sync runs in the background and updates the row without reloading the page', () => {
const page = fakePage(ddnsPageScript());
let reloaded = false;
page.context.location.reload = () => { reloaded = true; };
const button = page.element('sync-button');
page.context.doSync('cfg01', button);
take(page, 'url').callback(null, { success: true });
take(page, 'job').callback(null, { running: false, done: true, success: true, status: 'ok', ip: '198.51.100.20', updated: '2026-10-28 10:00:00' });
assert.equal(reloaded, false);
assert.equal(button.disabled, false);
assert.match(page.element('sr-cfg01').innerHTML, /同步完成 198\.51\.100\.20/);
assert.match(page.element('ss-cfg01').innerHTML, /A: 198\.51\.100\.20/);
});
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 });
@@ -151,7 +240,11 @@ test('state-changing LuCI endpoints require POST with the CSRF token', () => {
}
const ddns = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/ddns.lua'), 'utf8');
const acme = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/acme.lua'), 'utf8');
assert.match(ddns, /XHR\.post\("\]\]\.\.url\.\.\[\[",\{section:s\}/);
assert.doesNotMatch(acme, /XHR\.get\(/);
assert.match(ddns, /mgRequest\('post',"\]\]\.\.url\.\.\[\[",\{section:s\}/);
// 安装与签发用 POST 启动后台任务,只有读取任务状态用 GET
assert.match(acme, /acmePost\('\]\]\.\.iu\.\.\[\['/);
assert.match(acme, /acmePost\('\]\]\.\.su\.\.\[\['/);
assert.match(acme, /acmeGet\('\]\]\.\.ju\.\.\[\['/);
assert.match(controller, /"acme_job"\}, call\("action_acme_job"\)\)/);
assert.match(controller, /sec:match\("\^\[%w_\]\+\$"\)/);
});
+2 -1
View File
@@ -196,7 +196,8 @@ test('LuCI exposes only a confirmed POST uninstall action and runs a temporary s
test('the package contains the uninstaller and supports both OpenWrt package managers', () => {
const makefile = fs.readFileSync(path.join(root, 'Makefile'), 'utf8');
const source = fs.readFileSync(script, 'utf8');
assert.match(makefile, /root\/usr\/lib\/minigate\/uninstall\.sh/);
assert.match(makefile, /\$\(INSTALL_BIN\) \.\/root\/usr\/lib\/minigate\/\*\.sh /);
assert.ok(fs.existsSync(path.join(root, 'root/usr/lib/minigate/uninstall.sh')));
assert.match(source, /opkg remove "\$package"/);
assert.match(source, /apk del "\$package"/);
assert.match(source, /for package in minigate luci-app-minigate/);