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

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

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Build artifacts
*.ipk
*.apk
*.tar.gz
+53
View File
@@ -0,0 +1,53 @@
# Contributing to MiniGate
Thanks for considering a contribution. MiniGate is a small OpenWrt LuCI
application, so focused and testable changes are easiest to review.
## Useful Areas
- OpenWrt and ImmortalWrt compatibility fixes
- LuCI UI improvements
- DDNS and ACME reliability
- reverse proxy safety and generated Nginx configuration
- login guard detection, nftables recovery, and ban-list handling
- installation, upgrade, and release packaging
- documentation and screenshots
## Development Notes
Please keep changes compatible with `/bin/sh` on OpenWrt. Avoid Bash-only
syntax in router-side scripts.
Before opening a pull request:
1. Run shell scripts with `sh` where possible.
2. Check that generated configuration paths are explicit and conservative.
3. Test installation or upgrade on an OpenWrt/ImmortalWrt device or VM when the
change affects runtime behavior.
4. Update README or release notes when user-facing behavior changes.
## Releases
Use the Asia/Shanghai publication date as `YYYY.M.D`. The first release on a
date has no suffix; further releases use `-1`, `-2`, and so on. Tags start with
`v`. Never replace an existing release with different code.
Update `PKG_VERSION` in `Makefile` and `scripts/build-ipk.sh`, and
`CURRENT_VERSION` in `root/usr/lib/minigate/update.sh` together. Add the actual
release notes in `releases/v<VERSION>.md` and update README download examples.
Run `node --test tests/install.test.cjs tests/update.test.cjs` before pushing a commit containing
`[release]` to `main`. The workflow validates the date, next sequence, and
matching versions before building and publishing.
## Pull Requests
Good pull requests usually include:
- a short problem statement
- what changed
- how it was tested
- screenshots for LuCI UI changes
- any compatibility notes for opkg, apk, or SDK builds
Security-sensitive changes should follow `SECURITY.md` and avoid publishing
exploit details before a fix is ready.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 huang pei
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=minigate
PKG_VERSION:=2026.9.18-2
PKG_RELEASE:=1
PKG_LICENSE:=MIT
PKG_MAINTAINER:=MiniGate
include $(INCLUDE_DIR)/package.mk
define Package/$(PKG_NAME)
SECTION:=luci
CATEGORY:=LuCI
SUBMENU:=3. Applications
TITLE:=LuCI - MiniGate (DDNS + ACME + Reverse Proxy + Login Guard)
DEPENDS:=+luci-base +nginx-ssl +nginx-mod-stream +openssl-util +wget +curl +jsonfilter +coreutils-stat +nftables
PROVIDES:=luci-app-minigate
CONFLICTS:=luci-app-minigate
VERSION:=$(if $(CONFIG_USE_APK),$(subst -,-r,$(PKG_VERSION)),$(PKG_VERSION))
PKGARCH:=all
endef
define Package/$(PKG_NAME)/description
Lightweight gateway management for OpenWrt: Cloudflare DDNS, Let's Encrypt SSL certificates,
Nginx reverse proxy, and SSH/LuCI brute-force ban (Login Guard) with LuCI web interface.
endef
define Package/$(PKG_NAME)/conffiles
/etc/config/minigate
endef
define Build/Compile
endef
define Package/$(PKG_NAME)/install
# LuCI files
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/controller
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/model/cbi/minigate
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/view/minigate
$(CP) ./luasrc/controller/minigate.lua $(1)/usr/lib/lua/luci/controller/
$(CP) ./luasrc/model/cbi/minigate/*.lua $(1)/usr/lib/lua/luci/model/cbi/minigate/
$(CP) ./luasrc/view/minigate/*.htm $(1)/usr/lib/lua/luci/view/minigate/
# 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/
# Config
$(INSTALL_DIR) $(1)/etc/config
$(CP) ./root/etc/config/minigate $(1)/etc/config/
# Init script
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./root/etc/init.d/minigate $(1)/etc/init.d/
# Data directories
$(INSTALL_DIR) $(1)/etc/minigate/acme
$(INSTALL_DIR) $(1)/etc/minigate/certs
$(INSTALL_DIR) $(1)/etc/minigate/nginx
$(INSTALL_DIR) $(1)/etc/minigate/nginx/sites
$(INSTALL_DIR) $(1)/etc/minigate/nginx/streams
$(INSTALL_DIR) $(1)/etc/minigate/login-guard
# i18n / translations
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/i18n
[ ! -f ./po/zh-cn/minigate.po ] || $(STAGING_DIR_HOSTPKG)/bin/po2lmo ./po/zh-cn/minigate.po $(1)/usr/lib/lua/luci/i18n/minigate.zh-cn.lmo
endef
define Package/$(PKG_NAME)/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || {
chmod +x /etc/init.d/minigate
chmod +x /usr/lib/minigate/*.sh
/etc/init.d/minigate enable
echo "MiniGate installed. Configure at: LuCI → Services → MiniGate"
}
endef
define Package/$(PKG_NAME)/prerm
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || {
/etc/init.d/minigate stop
/etc/init.d/minigate disable
}
endef
$(eval $(call BuildPackage,$(PKG_NAME)))
+494
View File
@@ -0,0 +1,494 @@
# minigate - OpenWrt 轻量网关管理
一个类似 Lucky 的轻量级 OpenWrt 应用,提供四大核心功能:DDNS、SSL 证书、反向代理、登录防护。
当前版本:**v2026.9.18-2**。项目名称、仓库名及 OpenWrt 内部包名均为 **`minigate`**。
> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.9.18-2_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。
## 项目定位
minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户,目标是在 LuCI 中集中完成域名解析、证书签发、反向代理和登录防护配置,减少在路由器上手动维护多组脚本、Nginx 配置和防火墙规则的成本。
项目重点关注以下场景:
- 家庭或小型办公网络中的自托管服务入口管理
- IPv4 / IPv6 双栈 DDNS 与反向代理配置
- Cloudflare DNS-01 自动签发和续期证书
- 直接 IP 访问、未知 Host、暴力登录尝试等常见暴露面防护
- OpenWrt / ImmortalWrt opkg、apk、源码安装和 SDK 编译兼容
## 维护状态
- 维护者:[@tpxcer](https://github.com/tpxcer)
- 当前维护分支:`main`
- 发布方式:GitHub Releases 提供源码包和 OpenWrt/ImmortalWrt 兼容安装包
- 版本规则:按北京时间(Asia/Shanghai)发布日期使用 `年.月.日`;同日首版无后缀,后续版本依次追加 `-1`、`-2`,例如 `2026.9.14`、`2026.9.14-1`。标签加 `v` 前缀。
- 安全策略:见 [SECURITY.md](SECURITY.md)
- 贡献说明:见 [CONTRIBUTING.md](CONTRIBUTING.md)
- 许可证:MIT License
## 功能特性
### 🌐 DDNS (动态域名解析)
- **Cloudflare** DNS API 支持
- **IPv4 / IPv6 / 双栈** 模式,同时更新 A 和 AAAA 记录
- 自动检测 WAN IP 变化并更新 DNS 记录
- 同名同类型记录重复时,保留匹配当前 IP 的记录并清理其余旧记录,避免旧 IP 继续参与解析
- 支持从网络接口或外部 URL 获取 IP
- 可配置检查间隔和强制更新间隔
- 一键手动触发更新
- 最近一天 IP 变化明细,显示 IPv4/IPv6 地址、开始时间、结束时间和持续时长
### 🔒 SSL/TLS 证书 (ACME)
- **Let's Encrypt** 自动证书签发
- 使用 **DNS-01** 挑战方式(Cloudflare)
- 支持 ECC / RSA 多种密钥类型
- 自动续期(通过 cron 每天检查)
- Staging 模式用于测试
### 🔄 反向代理
- 基于 **Nginx** 的轻量反向代理
- **IPv6 监听**支持(listen [::]:port 双栈)
- HTTP/2、WebSocket 支持
- 自动 SSL 证书关联
- 已启用 HTTPS 的任意监听端口可自动识别明文 HTTP,并以 308 跳转到同域名、同端口的 HTTPS
- 安全 Headers(HSTS 等)
- 多站点管理
- **直接 IP 访问拒绝**:只允许域名访问,扫描器直接断开
- **未知 Host 拒绝**:同一监听端口下,未配置的域名/前缀不会落到第一个反代站点
### 🛡 登录防护 (Login Guard)
- 监控 **SSH(dropbear)** 和 **LuCI 网页** 登录失败
- 在「失败窗口」内累积达到阈值(默认 3 次/10 分钟)→ 自动用 nftables 封禁源 IP
- 可配置封禁时长(1 小时 / 6 小时 / **12 小时(默认)** / 24 小时 / 1 周 / 30 天)
- **LAN 私有段自动豁免**(192.168.x / 10.x / 127.x / 172.16-31.x),可加额外白名单
- **滑动时间窗口**:超过窗口未再失败则计数自动清零
- 持久化存储 `/etc/minigate/login-guard/bans.txt`,重启/固件升级后已生效封禁自动恢复
- LuCI 页面实时显示封禁列表、剩余时间、归属地(结合归属地查询 API)
- 失败计数列表默认显示 5 条,支持切换 5 / 20 / 30 条,并复用访客追踪归属地查询;显示最近访问时间并按最新时间排序
- 支持网页**一键解封**、**手动封禁**、**清空全部**
- 后台 watchdog 每 5 分钟自检 nft set,防 fw4 reload 后规则丢失
### 👁 访客追踪
- 总览页面实时显示访客 IP、归属地、在线状态
- 5 分钟内有访问记为「在线」(绿点),否则「离线」(灰点)
- IP 归属地后端查询(ip9.com.cn → ip-api.com → pconline 多源回退)
### 软件更新
- 登录 LuCI 后打开 **服务 → minigate → 总览**,自动检查 GitHub 最新正式版;不会自动下载或安装
- 10 分钟内复用成功检查结果;「检查更新」可随时手动重新检查
- 点击「一键更新」先显示本次 Release 更新内容,确认后才开始安装
- 安装前校验 SHA-256 并备份程序和配置,安装或服务验证失败时尝试回滚
- 总览标题区提供「卸载」入口;二次确认默认保留配置、证书、封禁记录和日志,也可主动选择彻底清理
---
## 安装方法
### 方法 1:IPK 安装(推荐,OpenWrt / ImmortalWrt opkg 版本)
从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载:
`minigate_2026.9.18-2_all.ipk`
**通过 LuCI 界面安装:**
1. 打开 LuCI → **系统** → **软件包**
2. 点击 **上传软件包**
3. 选择 `minigate_2026.9.18-2_all.ipk`,点击安装
**通过命令行安装:**
```bash
cd /tmp
wget -O minigate_2026.9.18-2_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.9.18-2/minigate_2026.9.18-2_all.ipk
opkg update
opkg install /tmp/minigate_2026.9.18-2_all.ipk
rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
/etc/init.d/uhttpd restart
/etc/init.d/minigate restart
```
> 如果 LuCI 上传时报 `Malformed package file`,请确认下载的是 Release 里的 `.ipk` 资产,并确认该 `.ipk` 是 OpenWrt 原生 tar.gz 外层格式。ImmortalWrt 24.10.5 的新版 opkg 对格式更严格,Debian 风格 ar 外层 `.ipk` 会被判定为 malformed。
### 维护者打包
Release 用的 `.ipk` 请用仓库脚本生成,避免生成 Debian 风格 ar 外层包:
```bash
./scripts/build-ipk.sh
```
脚本会输出:
```text
dist/minigate_2026.9.18-2_all.ipk
```
该文件外层是 OpenWrt/ImmortalWrt 24.10 兼容的 `tar.gz`,内部成员顺序为 `debian-binary`、`control.tar.gz`、`data.tar.gz`。
### 方法 2:SSH 一键安装(推荐,opkg / apk)
适用于使用 **apk** 或 **opkg** 的 OpenWrt / ImmortalWrt。请先确认 LuCI、Nginx SSL 等下列依赖可用,且软件源与当前固件和架构匹配;不要为了安装混用其他固件的软件源。
**已经通过 SSH 登录软路由后,执行:**
```sh
curl -fsSL https://raw.githubusercontent.com/tpxcer/minigate/main/scripts/install.sh | sh
```
这条短命令会自动获取 GitHub 最新正式版。远程脚本内部仍会创建随机临时目录,在写入系统前检查 OpenWrt 环境、下载前后核对 Release、校验源码包 SHA-256、检查压缩包路径和版本信息,然后调用正式安装器并刷新 LuCI。
如果系统只有 `wget`,可以执行:
```sh
wget -qO- https://raw.githubusercontent.com/tpxcer/minigate/main/scripts/install.sh | sh
```
### 方法 3:固定版本命令安装
需要指定并核对具体版本时,执行:
```bash
d="$(mktemp -d /tmp/minigate.XXXXXX)" && cd "$d" && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.9.18-2/minigate-v2026.9.18-2-src.tar.gz && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.9.18-2/SHA256SUMS && grep 'minigate-v2026.9.18-2-src.tar.gz$' SHA256SUMS | sha256sum -c - && tar xzf minigate-v2026.9.18-2-src.tar.gz && sh install.sh && /etc/init.d/uhttpd restart && /etc/init.d/minigate restart
```
其中 `minigate.XXXXXX` 是临时目录模板,系统会自动把六个 `X` 替换为随机字符,避免与已有目录重名。该命令会校验源码包的 SHA-256、保留现有 minigate 配置并重启 LuCI 与 minigate 服务。
源码安装直接写入程序文件,不向 opkg/apk 注册软件包;希望软件包列表中显示 `minigate`,请使用 IPK 或匹配 SDK 编译的 APK。旧名包用户先阅读下方「旧包名迁移」。
**分步安装:**
```bash
# 1. SSH 到路由器
ssh root@192.168.1.1
# 2. 在路由器上直接下载 Release 源码包
cd /tmp
wget -O minigate-src.tar.gz https://github.com/tpxcer/minigate/releases/download/v2026.9.18-2/minigate-v2026.9.18-2-src.tar.gz
wget -O minigate-SHA256SUMS https://github.com/tpxcer/minigate/releases/download/v2026.9.18-2/SHA256SUMS
awk '$2 == "minigate-v2026.9.18-2-src.tar.gz" {print $1 " minigate-src.tar.gz"}' minigate-SHA256SUMS | sha256sum -c - || exit 1
# 3. 解压到独立目录并安装
mkdir -p /tmp/minigate-v2026.9.18-2
tar xzf minigate-src.tar.gz -C /tmp/minigate-v2026.9.18-2
cd /tmp/minigate-v2026.9.18-2
sh install.sh
# 4. 启动服务
/etc/init.d/minigate restart
# 5. 访问 LuCI → 服务 → minigate
```
### 方法 4:OpenWrt SDK 编译(适用所有版本,含 APK)
如果需要正规的 `.apk` 安装包,需使用 OpenWrt SDK 编译:
```bash
# 将源码放入 SDK 的 package 目录
cp -r minigate ~/openwrt/package/
# 编译
cd ~/openwrt
make package/minigate/compile V=s
# 生成的 ipk 或 apk 在 bin/packages/ 目录下
```
APK 的内部版本格式要求将同日序号 `-N` 写为 `-rN`,SDK 会自动转换;例如发布版 `2026.9.14-1` 对应 APK 版本 `2026.9.14-r1`。网页、Release 标签和 IPK 仍使用 `2026.9.14-1`,包名均为 `minigate`。
---
## 升级方法
### LuCI 一键更新
进入 **服务 → minigate → 总览 → 软件更新**:
1. 打开总览时自动检查,也可以点击「检查更新」立即重新检查
2. 有新版本时点击「一键更新」,弹出该版本的更新内容
3. 阅读后点击「确认更新」才开始;取消或关闭弹窗不会安装
4. 页面显示下载、SHA-256 校验、备份、安装和服务验证进度,成功后刷新
更新器只接受 `tpxcer/minigate` 的固定 Release 资产,不接受自定义下载地址。自动检查只在打开 minigate 总览时触发,不会修改全局 LuCI 登录流程。确认后若最新版本已经变化,会停止并要求重新查看更新内容。更新前会备份程序和 `/etc/config/minigate`;安装或服务验证失败时尝试恢复原版本,失败详情见 `/var/log/minigate-update.log`。
`v1.3.11` 已有一键更新,但需先升级本版才具备自动检查和说明弹窗。本版保留旧名源码附件用于兼容它;旧版不能识别 `2026.9.14-1` 这样的后缀版本,应先升级 `v2026.9.14` 或使用本文命令手动安装。更早版本需手动安装。
### 源码升级(通用)
SSH 登录后执行上方「方法 2」的一条命令,保留原有配置并校验下载内容。网页一键更新也采用源码覆盖方式,不会同步修改 opkg/apk 数据库中的包名或版本;包管理器安装的用户建议继续使用对应软件包升级。
### IPK 升级
```bash
opkg install --force-reinstall /tmp/minigate_2026.9.18-2_all.ipk
rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
/etc/init.d/minigate restart
```
配置文件 `/etc/config/minigate` 会自动保留。
### 旧包名迁移
如果 `opkg list-installed` 或 `apk info` 中仍有 `luci-app-minigate`,它是旧版内部包名,不等于新包 `minigate`。先备份 `/etc/config/minigate` 和 `/etc/minigate/`,并提前下载新安装包和旧版本回退包,再移除旧名包并安装新包。请在 LAN 或 SSH 连接下操作,迁移会短暂中断由 minigate 提供的反向代理。
新 IPK 声明旧包的兼容/替换关系,但不同固件的自动替换行为仍需实机验证;不要使用 `--force-overwrite` 混装,不要在新文件写入后再卸载旧名包,以免旧包把同路径的新文件一并删除。源码安装的用户若没有旧包数据库记录,无需迁移。
---
## 卸载方法
### 方式 1:LuCI 一键卸载(推荐)
进入 **服务 → minigate → 总览**,点击标题右侧的「卸载」:
1. 阅读中断提示并确认卸载范围
2. 默认不勾选数据清理,保留 `/etc/config/minigate`、证书、封禁记录和日志
3. 只有确认不再使用时,才勾选「同时删除配置、证书、封禁记录和日志」
4. 点击「确认卸载」后,页面会返回 LuCI 服务列表
卸载会立即停止 minigate 的 DDNS、证书续期、反向代理和登录防护。请从 LAN 或 SSH 登录 LuCI 后操作,避免通过 minigate 自身的公网反向代理入口执行。
### 方式 2:命令行保留配置卸载
适合以后还可能重装 minigate 的情况。此方式会删除 LuCI 页面、脚本和服务文件,但保留 `/etc/config/minigate` 以及 `/etc/minigate/` 下的证书、封禁记录等数据。
```sh
/bin/sh /usr/lib/minigate/uninstall.sh 0
```
脚本会自动识别源码安装、`opkg` 和 `apk`,清理程序、LuCI 页面、cron、运行进程及临时状态,并刷新 LuCI。
如果你是早期版本,尚未包含 `uninstall.sh`,可以使用包管理器卸载:
```sh
/etc/init.d/minigate stop 2>/dev/null
/etc/init.d/minigate disable 2>/dev/null
opkg remove minigate 2>/dev/null || apk del minigate 2>/dev/null
/etc/init.d/uhttpd restart
```
源码安装且没有包管理器记录的旧版本,建议先覆盖安装最新版,再使用 LuCI 或上面的统一卸载脚本,不要手工复制不完整的删除清单。
### 方式 3:命令行彻底卸载
确认不再使用 minigate 时再执行。此方式会删除配置、证书记录、登录防护封禁记录和日志。
```sh
/bin/sh /usr/lib/minigate/uninstall.sh 1
```
---
## 使用指南
### 第一步:基础配置
1. 进入 **LuCI → 服务 → minigate**
2. 在「总览」页面开启 minigate
3. 如需 IPv6 反代监听,开启「反向代理监听 IPv6」
### 第二步:配置 DDNS
1. 切换到 **DDNS** 标签页
2. 启用 DDNS,填入 Cloudflare Zone ID 和 API Token
3. 设置域名,选择协议版本(IPv4 / IPv6 / 双栈)
4. 保存并应用
### 第三步:签发 SSL 证书
1. 切换到 **SSL 证书** 标签页,启用 ACME
2. 先用 Staging 模式测试
3. 点击「立即签发 / 续期」
4. 成功后关闭 Staging 重新签发正式证书
### 第四步:配置反向代理
1. 切换到 **反向代理** 标签页
2. 添加规则,填写域名、目标地址和端口
3. 启用 SSL(自动关联 ACME 证书)
4. 如需输入 `http://域名:监听端口` 后自动进入 HTTPS,开启「HTTP 自动跳转」
5. 保存并应用;minigate 会自动跟随每条 HTTPS 规则的监听端口,无需额外指定固定跳转端口
### Cloudflare API Token 创建方法
1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)
2. 头像 → **My Profile** → **API Tokens** → **Create Token**
3. 使用 **Edit zone DNS** 模板
4. Zone Resources 选择你的域名
5. 创建并复制 Token
---
## 安全特性
### 域名访问限制
安装后,反向代理自动拒绝直接使用 IP 地址访问的请求(返回 444 断开连接)。同时,每个监听端口都会生成 `default_server` 兜底规则,未配置的 Host / 子域名前缀不会落到同端口第一个反代站点。只有通过正确域名访问才能到达后端服务,这可以有效防止网络扫描器探测和域名串站。
### 访客追踪
总览页面实时显示所有通过反向代理访问的 IP 地址、归属地和在线状态,帮助你发现异常访问。
---
## 文件结构
```
minigate/
├── CONTRIBUTING.md # 贡献说明
├── LICENSE # MIT 许可证
├── Makefile # OpenWrt 编译配置
├── README.md
├── SECURITY.md # 安全报告策略
├── install.sh # 一键安装脚本(兼容 opkg/apk)
├── luasrc/
│ ├── controller/minigate.lua # LuCI 路由控制器
│ ├── model/cbi/minigate/
│ │ ├── general.lua # 总览(含访客追踪)
│ │ ├── ddns.lua # DDNS(IPv4/IPv6)
│ │ ├── acme.lua # ACME 证书
│ │ ├── proxy.lua # 反向代理
│ │ └── login_guard.lua # 登录防护
│ └── view/minigate/log.htm # 日志页面
└── root/
├── etc/config/minigate # UCI 配置(含 login_guard 段)
├── etc/init.d/minigate # 服务脚本(启停含 login_guard)
└── usr/lib/minigate/
├── ddns.sh # DDNS(双栈)
├── acme.sh # 证书管理
├── proxy.sh # 反代(协议分流+IPv6+访问日志)
├── login_guard.sh # 登录防护 watcher + CLI
└── update.sh # GitHub Release 安全更新器
```
## 依赖
- `luci-base` - LuCI Web 界面
- `nginx-ssl` - Nginx(带 SSL 支持)
- `nginx-mod-stream` - 在同一入口端口识别 HTTP 与 TLS
- `openssl-util` - 证书工具
- `wget` - 下载安装包和源码包
- `curl` - HTTP 客户端
- `jsonfilter` - JSON 解析
- `coreutils-stat` - 登录防护读取计数文件更新时间
- `nftables` - 登录防护用(OpenWrt 22.03+ / ImmortalWrt 默认已装)
## 更新日志
### v2026.9.18-2
- 修复 DDNS 历史写入与 LuCI 查询使用不同 UCI 节名称,导致“最近一天 IP 变化明细”无法找到记录的问题
- 历史文件改用域名作为稳定标识;接口成功但暂无记录时,页面现在显示正常空状态,不再误报读取失败
### v2026.9.18-1
- 修复已有可用 OpenSSL CLI、但包管理器没有 `openssl-util` 安装记录时,一键更新仍尝试联网安装依赖并触发回滚的问题
- 源码安装现在先执行 `openssl version` 验证能力;已可用则跳过包安装,缺失时仍正常安装 `openssl-util`
### v2026.9.18
- 动态 DNS 页面新增最近 24 小时 IP 变化明细,显示 IPv4/IPv6、开始时间、结束时间和持续时长
- 历史只在 IP 实际变化时写入,稳定轮询不触碰历史文件,并保留跨越 24 小时边界的持续区间
- 桌面端使用紧凑表格,手机端优先纵向展示 IP 和持续时间,适配深浅色主题
- 修复源码安装未安装 `openssl-util`,导致 ACME 签发和续期缺少 OpenSSL CLI 的问题
### v2026.9.14-4
- 总览标题区新增「卸载」按钮,适配桌面、手机和深浅色主题
- 卸载前必须二次确认,默认保留配置、证书、封禁记录和日志
- 可主动选择彻底清理;统一兼容源码安装、opkg 和 apk 包安装
### v2026.9.14-3
- 新增 `curl -fsSL https://raw.githubusercontent.com/tpxcer/minigate/main/scripts/install.sh | sh` 短命令安装
- 自动获取 latest Release,并在写入系统前校验 SHA-256、压缩包路径和版本信息
- 下载前后核对 `SHA256SUMS`,避免发布切换期间混用不同版本文件
- 保留固定版本长命令,方便指定版本和手工复核
### v2026.9.14-2
- 修复一键更新启动 Nginx、登录防护和 LuCI 服务后更新锁被长驻进程继承,导致后续「检查更新」只显示旧状态的问题
- 更新流程在所有正常返回路径显式释放锁,启动长驻服务时同时关闭更新锁文件描述符
### v2026.9.14-1
- 修复子域名反向代理规则的 WebSocket 开关未生效问题
- 子域规则现在会正确生成 `Upgrade` / `Connection` 头,支持 Open-Box 等 WebSocket 动态页面通过外网穿透显示连接和日志
### v2026.9.14
- 项目、GitHub 仓库和 OpenWrt 内部包名统一为 `minigate`
- 改用北京时间发布日期版号,同一天后续版本依次追加 `-1`、`-2`
- 打开总览自动检查更新,点击一键更新先显示本次内容并要求确认
- 确认绑定具体版本,避免安装未看过说明的后续版本;修复成功状态重复刷新
- 保留 `v1.3.11` 识别的旧名源码包副本,方便迁移到首个日期版
### v1.3.11
- HTTP 自动跳转改为跟随任意 HTTPS 监听端口,同一端口同时接受 HTTP 和 TLS;非 443 端口会保留端口号
- 协议分流通过 PROXY protocol 保留真实访客 IP,未知 Host 仍直接拒绝
- 总览新增「检查更新 / 一键更新」,固定 GitHub Release 来源并执行 SHA-256 校验、备份、服务验证和失败回滚
### v1.3.10
- 新增独立 HTTP 自动跳转入口,为已配置 HTTPS 的代理域名返回 308 跳转
- 未配置 Host 继续返回 444,且禁止 HTTP 跳转端口与反向代理监听端口冲突
- 公网部署时可将 TCP 80 映射到内部跳转端口(默认 2001),无需暴露 LuCI 的 80 端口
### v1.3.9
- 修复 FanchmWrt 等 LuCI 主题使用自身深色开关时,minigate 仍显示白色卡片和表格的问题
- 页面背景、文字、边框和控件改为跟随 LuCI 主题变量,同时保留系统深色模式兼容
### v1.3.8
- 调整 LuCI 深色模式配色,降低高饱和强调色和卡片边框的突兀感
- 统一总览与登录防护页面的深色背景、文字和状态颜色
### v1.3.7
- DDNS 同步会保留一条匹配当前 IP 的 A / AAAA 记录,并删除其余同名同类型重复记录,避免旧 IP 继续参与解析
- Cloudflare 记录查询失败时不再误判为记录不存在,避免异常新建重复 DNS 记录
### v1.3.6
- 修复 ACME 重复签发/续期失败时,即使本地已有有效证书仍把 SSL/TLS 状态误标为 `error` 的问题
- ACME 现在会在失败但已有证书未过期时保留 `ok` 状态,并记录最近错误,避免总览页误报证书失效
- 修复 LuCI 手动签发证书、手动同步 DDNS 时命令参数未做安全引用的问题
- 对总览页、证书页、登录防护页中的域名、证书、访问记录、归属地等动态文本做 HTML 转义
### v1.3.5
- 修复同一监听端口下未知 Host / 任意子域名前缀会落到第一个反代站点的问题;现在会生成 `default_server` 兜底规则并直接断开
- HTTPS 反代端口自动生成 minigate 默认证书用于兜底拒绝站点,避免未知 SNI 复用真实站点
- 修复通过 minigate 反向代理访问 LuCI 时,登录防护页面保存设置导致当前反代连接被断开的问题
- 登录防护页面保存设置时改为轻量重载 minigate,不再完整重启反代 nginx
- 登录防护「失败计数中」新增最近访问时间,并按最新访问时间排在最前
- 修复 OpenWrt/ImmortalWrt 编译菜单中 `luci-app-minigate` 出现两条重复选项的问题
- Release 自动构建流程改为中文更新说明,并自动刷新安装包附件
### v1.3.4
- 🎨 修复 LuCI 白天模式下总览和登录防护页面仍显示深色卡片/表格的问题
- 🎨 保留黑夜模式原有深色显示效果,页面会跟随系统/浏览器配色切换
### v1.3.3
- 🐛 重新生成 OpenWrt opkg 更兼容的 `.ipk` ar 归档格式,修复部分系统仍提示 `Malformed package file`
### v1.3.2
- ✨ 登录防护「失败计数中」默认显示 5 条,支持 5 / 20 / 30 条下拉切换
- ✨ 失败计数归属地查询复用总览「访问记录」的查询方式
- 🐛 修复失败计数文件异常时 `lg_status` 刷新卡住的问题
### v1.3.1
- 🐛 修复手工安装脚本在 OpenWrt `/bin/sh` 下目录创建失败的问题
- 🐛 重新生成标准 `.ipk` 安装包,避免 LuCI 提示 `Malformed package file`
### v1.3.0
- ⚡ 归属地查询增加 24 小时本地缓存
- ⚡ 缩短外部归属地接口超时时间,减少页面等待
### v1.2.0
- ✨ 新增 **登录防护 (Login Guard)** —— SSH/LuCI 失败登录达到阈值自动封禁源 IP
- ✨ LuCI 网页:实时封禁列表 + 归属地 + 一键解封/封禁/清空
- ✨ 持久化封禁,重启/固件升级后自动恢复
- ✨ Watchdog 自检 nftables 资源,防 fw4 reload 后失效
- 🔧 install.sh 自动清理旧的独立 login-guard 部署(如有)+ 迁移 bans.txt
### v1.1.0
- ✨ DDNS 支持 IPv6 / 双栈模式(A + AAAA 记录)
- ✨ 反向代理支持 IPv6 监听
- ✨ 总览页面新增访客追踪(IP、归属地、在线状态)
- ✨ 归属地后端查询(解决浏览器跨域问题)
- ✨ 直接 IP 访问拒绝(防扫描器)
- ✨ 同时兼容 opkg(IPK)和 apk(源码安装)
- ✨ install.sh 自动适配 opkg/apk 环境
- 🐛 目标地址支持 IPv6 格式
### v1.0.0
- 🎉 初始版本:Cloudflare DDNS、Let's Encrypt ACME、Nginx 反向代理
## 许可证
MIT License
+55
View File
@@ -0,0 +1,55 @@
# Security Policy
MiniGate touches gateway-facing features such as DDNS, ACME certificates,
reverse proxy configuration, LuCI access, login-failure monitoring, and
nftables bans. Please report security issues responsibly.
## Supported Versions
Security fixes are targeted at the latest release published on GitHub
Releases. Users should upgrade to the latest release before reporting issues
that may already be fixed.
## Reporting a Vulnerability
If you find a vulnerability, please do not publish exploit details in a public
issue before it has been reviewed.
Report privately by email:
- tpxcer28@gmail.com
Please include:
- affected MiniGate version
- OpenWrt or ImmortalWrt version
- installation method, such as IPK, source install, or SDK build
- affected feature, such as DDNS, ACME, reverse proxy, login guard, or LuCI UI
- clear reproduction steps
- expected impact
I will try to acknowledge valid reports within 7 days and coordinate a fix or
mitigation in a release when appropriate.
## Scope
In scope:
- command injection or unsafe shell argument handling
- configuration injection in generated Nginx, ACME, DDNS, or nftables files
- authentication or authorization bypass in LuCI pages
- unsafe handling of API tokens, certificates, private keys, or ban records
- issues that weaken direct-IP or unknown-Host rejection behavior
Out of scope:
- vulnerabilities in upstream OpenWrt, ImmortalWrt, LuCI, Nginx, Cloudflare, or
Let's Encrypt components
- reports that require compromised router root access as the only prerequisite
- denial-of-service reports without a realistic router-facing impact
## Disclosure
Please allow time for investigation and patching before public disclosure. Once
a fix is available, the release notes will mention the security-relevant change
without exposing unnecessary exploit details.
+90
View File
@@ -0,0 +1,90 @@
#!/bin/sh
set -e
D="$(cd "$(dirname "$0")" && pwd)"
echo ""; echo "==== MiniGate 安装 ===="; echo ""
[ "$(id -u)" = "0" ] || { echo "[!] 需要root"; exit 1; }
dependency_ready() {
case "$1" in
openssl-util)
command -v openssl >/dev/null 2>&1 && openssl version >/dev/null 2>&1
;;
*)
return 1
;;
esac
}
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; 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; do
dependency_ready "$p" && continue
apk info -e "$p" >/dev/null 2>&1 || apk add "$p" 2>/dev/null
done
fi
}
# 检测包管理器 + 安装依赖(stream 用于同端口 HTTP/HTTPS 识别)
install_dependencies
[ -f /etc/init.d/minigate ] && /etc/init.d/minigate stop 2>/dev/null || true
# 清理旧的独立 login-guard 部署(如有)
[ -f /etc/init.d/login-guard ] && {
/etc/init.d/login-guard stop 2>/dev/null || true
/etc/init.d/login-guard disable 2>/dev/null || true
rm -f /etc/init.d/login-guard
rm -f /usr/sbin/login-guard /usr/sbin/login-guard.sh
# 迁移 bans.txt(如有)
[ -f /etc/login-guard/bans.txt ] && {
mkdir -p /etc/minigate/login-guard
cp /etc/login-guard/bans.txt /etc/minigate/login-guard/bans.txt
}
echo "[*] 已清理旧的独立 login-guard"
}
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
cp "$D"/root/usr/lib/minigate/*.sh /usr/lib/minigate/; chmod +x /usr/lib/minigate/*.sh
cp "$D"/root/etc/init.d/minigate /etc/init.d/; chmod +x /etc/init.d/minigate
# 配置:保留旧的,但确保有 login_guard 区段
if [ -f /etc/config/minigate ]; then
echo "[*] 保留现有配置"
grep -q "config login_guard" /etc/config/minigate || cat >> /etc/config/minigate <<EOF
config login_guard 'login_guard'
option enabled '0'
option threshold '3'
option window '600'
option bantime '43200'
EOF
else
cp "$D"/root/etc/config/minigate /etc/config/
fi
# v1.3.11 起由每条 HTTPS 规则的监听端口直接承担 HTTP 跳转。
uci -q delete minigate.global.http_redirect_port 2>/dev/null || true
uci -q commit minigate 2>/dev/null || true
mkdir -p /usr/lib/lua/luci/controller
mkdir -p /usr/lib/lua/luci/model/cbi/minigate
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
/etc/init.d/minigate enable 2>/dev/null
/etc/init.d/minigate start 9>&- 2>/dev/null || true
echo ""; echo "==== 安装完成 ===="
echo "LuCI -> 服务 -> MiniGate"
echo "登录防护 tab 默认关闭,到 LuCI 启用即可。"
echo ""
+629
View File
@@ -0,0 +1,629 @@
module("luci.controller.minigate", package.seeall)
local function shellquote(value)
value = tostring(value or "")
return "'" .. value:gsub("'", "'\"'\"'") .. "'"
end
local function update_response(command)
local sys = require "luci.sys"
local jsonc = require "luci.jsonc"
local fs = require "nixio.fs"
local raw = sys.exec("/bin/sh /usr/lib/minigate/update.sh " .. command .. " 2>/dev/null") or ""
local ok, data = pcall(jsonc.parse, raw)
if not ok or type(data) ~= "table" then
data = {
status = "error",
progress = 0,
current = "",
latest = "",
available = false,
running = false,
success = false,
message = "更新服务返回了无效结果"
}
end
local release_raw = fs.readfile("/tmp/minigate-update-release.json")
if release_raw and data.latest and data.latest ~= "" then
local release_ok, release = pcall(jsonc.parse, release_raw)
if release_ok and type(release) == "table" then
local tag = tostring(release.tag_name or ""):gsub("^v", "")
if tag == tostring(data.latest) then
local title = type(release.name) == "string" and release.name or ("minigate v" .. tag)
local notes = type(release.body) == "string" and release.body or ""
data.release_title = title
data.release_notes = notes
end
end
end
luci.http.prepare_content("application/json")
luci.http.write_json(data)
end
function index()
entry({"admin","services","minigate"}, alias("admin","services","minigate","general"), "minigate", 60).dependent=false
entry({"admin","services","minigate","general"}, cbi("minigate/general"), "总览", 10)
entry({"admin","services","minigate","ddns"}, cbi("minigate/ddns"), "动态DNS", 20)
entry({"admin","services","minigate","acme"}, cbi("minigate/acme"), "SSL 证书", 30)
entry({"admin","services","minigate","proxy"}, cbi("minigate/proxy"), "反向代理", 40)
entry({"admin","services","minigate","login_guard"}, cbi("minigate/login_guard"), "登录防护", 45)
entry({"admin","services","minigate","log"}, template("minigate/log"), "日志", 50)
entry({"admin","services","minigate","status"}, call("action_status")).leaf=true
entry({"admin","services","minigate","get_log"}, call("action_get_log")).leaf=true
entry({"admin","services","minigate","acme_install"}, call("action_acme_install")).leaf=true
entry({"admin","services","minigate","acme_issue"}, call("action_acme_issue")).leaf=true
entry({"admin","services","minigate","ddns_sync"}, call("action_ddns_sync")).leaf=true
entry({"admin","services","minigate","ddns_history"}, call("action_ddns_history")).leaf=true
entry({"admin","services","minigate","proxy_access"}, call("action_proxy_access")).leaf=true
entry({"admin","services","minigate","geo_lookup"}, call("action_geo_lookup")).leaf=true
entry({"admin","services","minigate","lg_status"}, call("action_lg_status")).leaf=true
entry({"admin","services","minigate","lg_ban"}, call("action_lg_ban")).leaf=true
entry({"admin","services","minigate","lg_unban"}, call("action_lg_unban")).leaf=true
entry({"admin","services","minigate","lg_flush"}, call("action_lg_flush")).leaf=true
entry({"admin","services","minigate","update_status"}, call("action_update_status")).leaf=true
entry({"admin","services","minigate","update_auto"}, call("action_update_auto")).leaf=true
entry({"admin","services","minigate","update_check"}, call("action_update_check")).leaf=true
entry({"admin","services","minigate","update_apply"}, post("action_update_apply")).leaf=true
entry({"admin","services","minigate","uninstall"}, post("action_uninstall")).leaf=true
end
function action_update_status()
update_response("status")
end
function action_update_auto()
update_response("auto")
end
function action_update_check()
update_response("check")
end
function action_update_apply()
local sys = require "luci.sys"
local fs = require "nixio.fs"
local version = luci.http.formvalue("version")
local ok = false
if type(version) ~= "string" or #version > 32 or not (
version:match("^%d+%.%d+%.%d+$") or version:match("^%d+%.%d+%.%d+%-%d+$")) then
luci.http.status(400, "Bad Request")
luci.http.prepare_content("application/json")
luci.http.write_json({success = false, message = "请先查看更新内容并确认版本"})
return
end
if fs.access("/usr/lib/minigate/update.sh") then
ok = sys.call("runner=$(mktemp /tmp/minigate-update-run.XXXXXX)" ..
" && cp /usr/lib/minigate/update.sh \"$runner\" && chmod 700 \"$runner\"" ..
" && ( ( /bin/sh \"$runner\" apply " .. shellquote(version) ..
"; rm -f \"$runner\" ) >/dev/null 2>&1 & )") == 0
end
luci.http.prepare_content("application/json")
luci.http.write_json({
success = ok,
message = ok and "更新任务已启动" or "无法启动更新任务"
})
end
function action_uninstall()
local sys = require "luci.sys"
local fs = require "nixio.fs"
local confirm = luci.http.formvalue("confirm")
local purge = luci.http.formvalue("purge")
local ok = false
if confirm ~= "uninstall-minigate" or (purge ~= "0" and purge ~= "1") then
luci.http.status(400, "Bad Request")
luci.http.prepare_content("application/json")
luci.http.write_json({success = false, message = "卸载确认无效,请重新操作"})
return
end
if fs.access("/usr/lib/minigate/uninstall.sh") then
ok = sys.call("runner=$(mktemp /tmp/minigate-uninstall.XXXXXX)" ..
" && cp /usr/lib/minigate/uninstall.sh \"$runner\" && chmod 700 \"$runner\"" ..
" && ( ( sleep 1; /bin/sh \"$runner\" " .. shellquote(purge) ..
"; rm -f \"$runner\" ) >/dev/null 2>&1 & )") == 0
end
luci.http.prepare_content("application/json")
luci.http.write_json({
success = ok,
message = ok and "卸载任务已启动" or "无法启动卸载任务"
})
end
function action_status()
local uci=require"luci.model.uci".cursor()
local pr=false
local f=io.open("/var/run/minigate-nginx.pid","r")
if f then local p=f:read("*l"); f:close()
if p and tonumber(p) then pr=(os.execute("kill -0 "..p.." 2>/dev/null")==0) end
end
local al={}
for _,lf in ipairs({"/var/log/minigate-ddns.log","/var/log/minigate-acme.log","/var/log/minigate-proxy.log"}) do
local fh=io.open(lf,"r"); if fh then for l in fh:lines() do al[#al+1]=l end; fh:close() end
end
local st=#al-19; if st<1 then st=1 end
local ll={}; for i=st,#al do ll[#ll+1]=al[i] end
local ca=false; local cf=io.open("/etc/crontabs/root","r")
if cf then local c=cf:read("*a"); cf:close(); ca=c:find("minigate")~=nil end
local dl={}
uci:foreach("minigate","ddns",function(s)
dl[#dl+1]={
name=s[".name"],
enabled=s.enabled or"0",
domain=s.domain or"",
ip_version=s.ip_version or"ipv4",
status=s.status or"unknown",
status_msg=s.status_msg or"",
last_ip=s.last_ip or"",
last_ip6=s.last_ip6 or"",
last_update=s.last_update or"",
next_sync=s.next_sync or""
}
end)
-- 全局 IPv6 监听状态
local ipv6_listen = uci:get("minigate","global","ipv6_listen") or "0"
-- 收集代理规则信息
local proxy_rules = {}
local wc_ports = {}
uci:foreach("minigate","proxy_wildcard",function(s)
if (s.enabled == "1" or s.enabled == true) and s.domain then
local base = (s.domain or ""):gsub("^%*%.","")
local ssl_val = tostring(s.ssl or "0")
wc_ports[base] = {port=s.listen_port or "443", ssl=ssl_val}
end
end)
uci:foreach("minigate","subproxy",function(s)
local parent = s.parent_domain or ""
local prefix = s.prefix or ""
local taddr = s.target_addr or ""
local tport = s.target_port or "80"
if prefix ~= "" and parent ~= "" then
local wc = wc_ports[parent] or {}
local lport = wc.port or "443"
local ssl_val = wc.ssl or "0"
local scheme = (ssl_val == "1" or ssl_val == "true") and "https" or "http"
proxy_rules[#proxy_rules+1] = {domain=prefix.."."..parent, target=taddr..":"..tport, listen_port=lport, scheme=scheme, ipv6_listen=ipv6_listen}
end
end)
uci:foreach("minigate","proxy",function(s)
if (s.enabled == "1" or s.enabled == true) and s.domain and s.target_addr then
local ssl_val = tostring(s.ssl or "0")
local scheme = (ssl_val == "1" or ssl_val == "true") and "https" or "http"
proxy_rules[#proxy_rules+1] = {domain=s.domain, target=(s.target_addr or "")..":"..(s.target_port or "80"), listen_port=s.listen_port or "443", scheme=scheme, ipv6_listen=ipv6_listen}
end
end)
luci.http.prepare_content("application/json")
luci.http.write_json({
enabled=uci:get("minigate","global","enabled")or"0",
ipv6_listen=ipv6_listen,
proxy_running=pr,
cron_active=ca,
recent_log=table.concat(ll,"\n"),
ddns_list=dl,
proxy_rules=proxy_rules,
acme={
enabled=uci:get("minigate","acme","enabled")or"0",
status=uci:get("minigate","acme","status")or"unknown",
last_domain=uci:get("minigate","acme","last_domain")or"",
last_issue=uci:get("minigate","acme","last_issue")or"",
cert_expiry=uci:get("minigate","acme","cert_expiry")or"",
last_error=uci:get("minigate","acme","last_error")or""
}
})
end
function action_get_log()
local sys=require"luci.sys"; local s=luci.http.formvalue("source")or"all"
local fs={ddns="/var/log/minigate-ddns.log",acme="/var/log/minigate-acme.log",proxy="/var/log/minigate-proxy.log /var/log/minigate-nginx-error.log"}
local cmd=s=="all"and"cat /var/log/minigate-*.log 2>/dev/null|tail -200"or"cat "..(fs[s]or"").." 2>/dev/null|tail -200"
luci.http.prepare_content("application/json"); luci.http.write_json({log=sys.exec(cmd)or""})
end
function action_acme_install()
local sys=require"luci.sys"; sys.call("/bin/sh /usr/lib/minigate/acme.sh install 2>&1")
local fs=require"nixio.fs"; local ok=fs.access("/etc/minigate/acme/data/acme.sh")and fs.access("/etc/minigate/acme/data/dnsapi/dns_cf.sh")
luci.http.prepare_content("application/json"); luci.http.write_json({success=ok,message=ok and"安装成功"or"安装失败"})
end
function action_acme_issue()
local sys=require"luci.sys"; local d=luci.http.formvalue("domain")or""
local cmd="/bin/sh /usr/lib/minigate/acme.sh issue"; if d~=""then cmd=cmd.." "..shellquote(d)end
sys.call(cmd.." 2>&1")
local uci=require"luci.model.uci".cursor(); local st=uci:get("minigate","acme","status")or"unknown"
local le=uci:get("minigate","acme","last_error")or""
local msg=(st=="ok" and le~="")and"现有证书有效;最近一次签发失败,已保留有效证书"or((st=="ok")and"签发成功!"or"签发失败,查看日志")
luci.http.prepare_content("application/json"); luci.http.write_json({success=(st=="ok"),message=msg})
end
function action_ddns_sync()
local sys=require"luci.sys"; local sec=luci.http.formvalue("section")or""
if sec~=""then sys.call("/bin/sh /usr/lib/minigate/ddns.sh single "..shellquote(sec).." 2>&1")
else sys.call("/bin/sh /usr/lib/minigate/ddns.sh force 2>&1") end
local uci=require"luci.model.uci".cursor()
local st=sec~=""and(uci:get("minigate",sec,"status")or"unknown")or"done"
local ip=sec~=""and(uci:get("minigate",sec,"last_ip")or"")or""
local ip6=sec~=""and(uci:get("minigate",sec,"last_ip6")or"")or""
luci.http.prepare_content("application/json"); luci.http.write_json({success=(st=="ok"or st=="partial"),status=st,ip=ip,ip6=ip6})
end
local function history_section_name(value)
return tostring(value or ""):gsub("[^%w_%-]", "_")
end
local function history_ip_valid(family, ip)
if family == "ipv4" then
return ip:match("^%d+%.%d+%.%d+%.%d+$") ~= nil
end
return ip:match("^[%x:]+$") ~= nil and ip:find(":", 1, true) ~= nil
end
local function read_history_events(path, family, now)
local events = {}
local file = io.open(path, "r")
if not file then return events end
for line in file:lines() do
local raw_ts, ip = line:match("^(%d+)\t([^\t\r\n]+)$")
local ts = tonumber(raw_ts)
if ts and ts > 0 and ts <= now + 300 and history_ip_valid(family, ip or "") then
local previous = events[#events]
if not previous or previous.ip ~= ip then
events[#events + 1] = {timestamp=ts, ip=ip}
end
end
end
file:close()
table.sort(events, function(a, b) return a.timestamp < b.timestamp end)
return events
end
function action_ddns_history()
local uci = require "luci.model.uci".cursor()
local history_dir = os.getenv("MINIGATE_HISTORY_DIR") or "/etc/minigate/ddns-history"
local requested = luci.http.formvalue("section") or ""
local now = os.time()
local cutoff = now - 86400
local rows = {}
local sections = {}
if #requested > 64 or (requested ~= "" and not requested:match("^[%w_%-]+$")) then
luci.http.status(400, "Bad Request")
luci.http.prepare_content("application/json")
luci.http.write_json({success=false, message="DDNS 记录参数无效"})
return
end
uci:foreach("minigate", "ddns", function(section)
local name = section[".name"] or ""
if name ~= "" and (requested == "" or requested == name) then
sections[#sections + 1] = {
name=name,
domain=section.domain or name,
enabled=(section.enabled == "1" or section.enabled == true),
ip_version=section.ip_version or "ipv4"
}
end
end)
for _, section in ipairs(sections) do
if section.enabled then
local families = {}
if section.ip_version == "ipv4" or section.ip_version == "dual" then families[#families + 1] = "ipv4" end
if section.ip_version == "ipv6" or section.ip_version == "dual" then families[#families + 1] = "ipv6" end
for _, family in ipairs(families) do
local path = history_dir .. "/" .. history_section_name(section.domain) .. "." .. family .. ".tsv"
local events = read_history_events(path, family, now)
for index, event in ipairs(events) do
local next_event = events[index + 1]
local end_time = next_event and next_event.timestamp or now
if end_time > cutoff and event.timestamp <= now then
rows[#rows + 1] = {
section=section.name,
domain=section.domain,
family=family,
ip=event.ip,
start_time=event.timestamp,
end_time=end_time,
duration=math.max(0, end_time - event.timestamp),
active=(next_event == nil)
}
end
end
end
end
end
table.sort(rows, function(a, b)
if a.start_time == b.start_time then return a.domain < b.domain end
return a.start_time > b.start_time
end)
luci.http.prepare_content("application/json")
luci.http.write_json({
success=true,
generated_at=now,
window_start=cutoff,
window_seconds=86400,
sections=sections,
records=rows
})
end
function action_proxy_access()
local sys = require "luci.sys"
local limit = tonumber(luci.http.formvalue("limit") or "5") or 5
if limit ~= 20 and limit ~= 50 then
limit = 5
end
-- 读取最近 500 条日志,按 IP 聚合
local raw = sys.exec("tail -n 500 /var/log/minigate-access.log 2>/dev/null") or ""
local visitors = {} -- ip -> {last_time, domain, count, first_time}
local order = {} -- 保持顺序
for line in raw:gmatch("[^\n]+") do
local time = line:match('"time":"([^"]*)"')
local domain = line:match('"domain":"([^"]*)"')
local client = line:match('"client":"([^"]*)"')
if time and client and client ~= "" then
if not visitors[client] then
visitors[client] = { last_time = time, first_time = time, domain = domain or "", count = 1 }
order[#order + 1] = client
else
visitors[client].last_time = time
visitors[client].count = visitors[client].count + 1
if domain and domain ~= "" then
visitors[client].domain = domain
end
end
end
end
-- 按最后访问时间倒序
table.sort(order, function(a, b)
return visitors[a].last_time > visitors[b].last_time
end)
-- 按前端选择取前 N 个,默认 5 个
local result = {}
local now = os.time()
for i = 1, math.min(#order, limit) do
local ip = order[i]
local v = visitors[ip]
-- 解析 ISO 时间判断是否在线(5分钟内)
local online = false
local y,mo,da,h,mi,se = v.last_time:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)")
if y then
local ts = os.time({year=tonumber(y),month=tonumber(mo),day=tonumber(da),hour=tonumber(h),min=tonumber(mi),sec=tonumber(se)})
online = (now - ts) < 300
end
result[#result + 1] = {
ip = ip,
last_time = v.last_time,
domain = v.domain,
count = v.count,
online = online
}
end
luci.http.prepare_content("application/json")
luci.http.write_json({ visitors = result })
end
function action_geo_lookup()
local sys = require "luci.sys"
local fs = require "nixio.fs"
local ip = luci.http.formvalue("ip") or ""
-- 安全校验:只允许 IP 地址字符
if not ip:match("^[%d%.%:a-fA-F]+$") then
luci.http.prepare_content("application/json")
luci.http.write_json({ ip = ip, geo = "无效IP" })
return
end
local cache_dir = "/tmp/minigate-geo-cache"
local cache_file = cache_dir .. "/" .. ip:gsub("[^%w%.%-_:]", "_")
fs.mkdirr(cache_dir)
local cached = fs.readfile(cache_file)
if cached and cached ~= "" then
luci.http.prepare_content("application/json")
luci.http.write_json({ ip = ip, geo = cached:gsub("%s+$", "") })
return
end
local geo = nil
-- API 1: ip9.com.cn(UTF-8,国内快)
if not geo then
local raw = sys.exec("curl -s --connect-timeout 1 --max-time 2 'https://ip9.com.cn/get?ip=" .. ip .. "' 2>/dev/null")
if raw and raw ~= "" then
local country = raw:match('"country":"([^"]*)"') or ""
local prov = raw:match('"prov":"([^"]*)"') or ""
local city = raw:match('"city":"([^"]*)"') or ""
local isp = raw:match('"isp":"([^"]*)"') or ""
local loc = (country .. prov .. city):gsub("中国", "")
if isp ~= "" then loc = loc .. " " .. isp end
loc = loc:gsub("^%s+", ""):gsub("%s+$", "")
if loc ~= "" then geo = loc end
end
end
-- API 2: ip-api.com(UTF-8,国外覆盖好)
if not geo then
local raw = sys.exec("curl -s --connect-timeout 1 --max-time 2 'http://ip-api.com/json/" .. ip .. "?lang=zh-CN&fields=status,country,regionName,city,isp' 2>/dev/null")
if raw and raw ~= "" then
local status = raw:match('"status":"([^"]*)"')
if status == "success" then
local country = raw:match('"country":"([^"]*)"') or ""
local region = raw:match('"regionName":"([^"]*)"') or ""
local city = raw:match('"city":"([^"]*)"') or ""
local isp = raw:match('"isp":"([^"]*)"') or ""
local loc = (country .. region .. city):gsub("中国", "")
if isp ~= "" then loc = loc .. " " .. isp end
loc = loc:gsub("^%s+", ""):gsub("%s+$", "")
if loc ~= "" then geo = loc end
end
end
end
-- API 3: pconline(GBK 需转码)
if not geo then
local raw = sys.exec("curl -s --connect-timeout 1 --max-time 2 'https://whois.pconline.com.cn/ipJson.jsp?ip=" .. ip .. "&json=true' 2>/dev/null | iconv -f gbk -t utf-8 2>/dev/null")
if raw and raw ~= "" then
local addr = raw:match('"addr":"([^"]*)"')
if addr and addr ~= "" then geo = addr:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") end
end
end
fs.writefile(cache_file, geo or "未知")
luci.http.prepare_content("application/json")
luci.http.write_json({ ip = ip, geo = geo or "未知" })
end
-- ============== 登录防护 API ==============
function action_lg_status()
local sys = require "luci.sys"
local fs = require "nixio.fs"
local uci = require "luci.model.uci".cursor()
local watch_limit = tonumber(luci.http.formvalue("watch_limit") or "5") or 5
if watch_limit ~= 20 and watch_limit ~= 30 then
watch_limit = 5
end
local ban_limit = tonumber(luci.http.formvalue("ban_limit") or "5") or 5
if ban_limit ~= 20 and ban_limit ~= 30 then
ban_limit = 5
end
local enabled = uci:get("minigate","login_guard","enabled") or "0"
local threshold = tonumber(uci:get("minigate","login_guard","threshold")) or 3
local bantime = tonumber(uci:get("minigate","login_guard","bantime")) or 43200
local window_s = tonumber(uci:get("minigate","login_guard","window")) or 600
-- 服务是否在跑
local pid_out = sys.exec("pgrep -f '/usr/lib/minigate/login_guard.sh run' 2>/dev/null")
local running = (pid_out ~= nil and pid_out:match("%d") ~= nil)
-- 已封禁列表(解析 nft -j 输出)
-- 注意:nft -j 在 key 和 value 之间有空格,要用 %s* 匹配
-- 每个 elem 内 val 在前 expires 在后,分别提取后按序号配对(避免跨 elem 匹配错乱)
local banned = {}
local raw = sys.exec("nft -j list set inet fw4 login_banned_v4 2>/dev/null") or ""
local ips = {}
for v in raw:gmatch('"val"%s*:%s*"([0-9%.]+)"') do
ips[#ips+1] = v
end
local exps = {}
for e in raw:gmatch('"expires"%s*:%s*(%d+)') do
exps[#exps+1] = tonumber(e)
end
for i = 1, #ips do
if exps[i] then
banned[#banned+1] = { ip = ips[i], remaining = exps[i] }
end
end
-- 按剩余时间倒序
table.sort(banned, function(a,b) return a.remaining > b.remaining end)
local banned_total = #banned
local banned_limited = {}
for i = 1, math.min(banned_total, ban_limit) do
banned_limited[#banned_limited + 1] = banned[i]
end
-- 失败计数中(读 /var/run/minigate/login-guard/counters/)
local watching = {}
local counter_dir = "/var/run/minigate/login-guard/counters"
local now = os.time()
local list_out = sys.exec("ls " .. counter_dir .. " 2>/dev/null") or ""
for ip in list_out:gmatch("[^\n]+") do
local path = counter_dir .. "/" .. ip
local fh = io.open(path, "r")
if fh then
local line = fh:read("*l") or ""
fh:close()
local first, count = line:match("(%d+)%s+(%d+)")
if first and count then
local first_time = tonumber(first)
local stat = fs.stat(path)
local last_time = (stat and stat.mtime) or first_time
local age = now - first_time
if age <= window_s then
watching[#watching+1] = {
ip = ip,
count = tonumber(count),
age = age,
first_time = first_time,
last_time = last_time,
last_seen = os.date("%Y-%m-%d %H:%M:%S", last_time)
}
end
end
end
end
table.sort(watching, function(a,b)
return (a.last_time or 0) > (b.last_time or 0)
end)
local watching_total = #watching
local watching_limited = {}
for i = 1, math.min(watching_total, watch_limit) do
watching_limited[#watching_limited + 1] = watching[i]
end
luci.http.prepare_content("application/json")
luci.http.write_json({
enabled = enabled,
running = running,
threshold = threshold,
bantime = bantime,
window = window_s,
banned = banned_limited,
banned_total = banned_total,
ban_limit = ban_limit,
watching = watching_limited,
watching_total = watching_total,
watch_limit = watch_limit
})
end
function action_lg_ban()
local sys = require "luci.sys"
local ip = luci.http.formvalue("ip") or ""
if not ip:match("^%d+%.%d+%.%d+%.%d+$") then
luci.http.prepare_content("application/json")
luci.http.write_json({success=false, error="无效 IP"})
return
end
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh ban '" .. ip .. "' >/dev/null 2>&1")
luci.http.prepare_content("application/json")
luci.http.write_json({success = (rc == 0), ip = ip})
end
function action_lg_unban()
local sys = require "luci.sys"
local ip = luci.http.formvalue("ip") or ""
if not ip:match("^%d+%.%d+%.%d+%.%d+$") then
luci.http.prepare_content("application/json")
luci.http.write_json({success=false, error="无效 IP"})
return
end
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh unban '" .. ip .. "' >/dev/null 2>&1")
luci.http.prepare_content("application/json")
luci.http.write_json({success = (rc == 0), ip = ip})
end
function action_lg_flush()
local sys = require "luci.sys"
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh flush >/dev/null 2>&1")
luci.http.prepare_content("application/json")
luci.http.write_json({success = (rc == 0)})
end
@@ -0,0 +1,98 @@
local m,s,o
local sys=require"luci.sys"
local fs=require"nixio.fs"
local uc=require"luci.model.uci".cursor()
local function esc(v)
v=tostring(v or "")
v=v:gsub("&","&amp;"):gsub("<","&lt;"):gsub(">","&gt;")
v=v:gsub('"',"&quot;"):gsub("'","&#39;")
return v
end
local function shellquote(v)
v=tostring(v or "")
return "'"..v:gsub("'","'\"'\"'").."'"
end
local dd={}
uc:foreach("minigate","ddns",function(sec) if sec.domain and sec.domain~=""then dd[#dd+1]=sec.domain end end)
m=Map("minigate","MiniGate - SSL 证书","ACME(Let's Encrypt)自动签发。域名和令牌自动关联「动态DNS」。")
s=m:section(NamedSection,"acme","acme","ACME 设置"); s.anonymous=true
o=s:option(Flag,"enabled","启用 ACME"); o.rmempty=false
o=s:option(Value,"email","账户邮箱"); o.placeholder="admin@example.com"; o.rmempty=true
o.description="可选。"
o=s:option(ListValue,"key_type","密钥类型")
o:value("ec-256","ECC P-256(推荐)"); o:value("ec-384","ECC P-384"); o:value("rsa-2048","RSA 2048"); o:value("rsa-4096","RSA 4096")
o.default="ec-256"
o=s:option(Flag,"staging","测试模式"); o.rmempty=false; o.default="1"
o.description="正式使用请关闭。"
-- 证书列表
o=s:option(DummyValue,"_certs","已签发证书"); o.rawhtml=true
o.cfgvalue=function()
local cd="/etc/minigate/certs"
local h='<table class="table"><tr class="tr table-titles"><th class="th">域名</th><th class="th">过期时间</th><th class="th">路径</th></tr>'
local found=false
if fs.dir(cd)then
for entry in fs.dir(cd)do
local fp=cd.."/"..entry
-- lstat 返回 table,检查 type 字段
local st=fs.lstat(fp)
if entry~="_default" and st and st.type=="dir"then
local cert=fp.."/fullchain.pem"
if fs.access(cert)then
found=true
local exp=""
local f=io.popen("openssl x509 -in "..shellquote(cert).." -noout -enddate 2>/dev/null")
if f then local l=f:read("*l"); if l then exp=l:gsub("notAfter=","") end; f:close() end
local dn=entry:gsub("_wildcard_%.", "*.")
h=h..'<tr class="tr"><td class="td"><strong>'..esc(dn)..'</strong></td><td class="td">'..esc(exp)..'</td><td class="td"><code>'..esc(fp)..'/</code></td></tr>'
end
end
end
end
if not found then h=h..'<tr class="tr"><td class="td" colspan="3" style="text-align:center;color:#999;padding:15px">暂无证书</td></tr>' end
return h..'</table>'
end
o=s:option(DummyValue,"_ast","acme.sh 状态"); o.rawhtml=true
o.cfgvalue=function()
if fs.access("/etc/minigate/acme/data/acme.sh")and fs.access("/etc/minigate/acme/data/dnsapi/dns_cf.sh")then return'<span style="color:#4caf50">&#10003; 已安装</span>'
elseif fs.access("/etc/minigate/acme/data/acme.sh")then return'<span style="color:#ff9800">&#9888; 缺DNS插件</span>'
else return'<span style="color:#f44336">&#10007; 未安装</span>'end
end
-- 操作
s=m:section(NamedSection,"acme","acme","操作"); s.anonymous=true
o=s:option(DummyValue,"_act"," "); o.rawhtml=true
o.cfgvalue=function()
local iu=luci.dispatcher.build_url("admin/services/minigate/acme_install")
local su=luci.dispatcher.build_url("admin/services/minigate/acme_issue")
local opts=""
for _,d in ipairs(dd)do opts=opts..'<option value="'..esc(d)..'">'..esc(d)..'</option>' end
return[[
<div style="margin-bottom:15px"><button class="cbi-button cbi-button-reload" id="bi" onclick="doI()" style="min-width:220px">下载并安装 acme.sh</button></div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
<label>签发域名:</label>
<select id="sd" style="padding:5px 10px;border-radius:4px;border:1px solid #ccc;min-width:250px">]]..
(opts~=""and opts or'<option value="">请先配置动态DNS</option>')..[[</select>
<button class="cbi-button cbi-button-apply" id="bs" onclick="doS()">立即签发 / 续期</button></div>
<div id="ap" style="margin-top:12px;display:none"><div style="background:#f0f0f0;border-radius:4px;padding:10px 15px;font-size:13px"><span style="display:inline-block;animation:sp 1s linear infinite;margin-right:8px">&#9203;</span><span id="am">处理中...</span></div></div>
<div id="ar" style="margin-top:12px;display:none"><div id="ai" style="border-radius:4px;padding:10px 15px;font-size:13px"></div></div>
<style>@keyframes sp{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}</style>
<script type="text/javascript">
function sP(m){document.getElementById('ap').style.display='block';document.getElementById('ar').style.display='none';document.getElementById('am').textContent=m}
function sR(ok,m){document.getElementById('ap').style.display='none';document.getElementById('ar').style.display='block';var e=document.getElementById('ai');e.style.background=ok?'#e8f5e9':'#ffebee';e.style.color=ok?'#2e7d32':'#c62828';e.textContent=m}
function doI(){document.getElementById('bi').disabled=true;sP('下载安装中(约30秒)...');XHR.get(']]..iu..[[',null,function(x,d){document.getElementById('bi').disabled=false;d?sR(d.success,d.message):sR(false,'失败');if(d&&d.success)setTimeout(function(){location.reload()},2000)})}
function doS(){var d=document.getElementById('sd').value;if(!d){sR(false,'请选择域名');return}document.getElementById('bs').disabled=true;sP('签发 '+d+'(约1-2分钟)...');XHR.get(']]..su..[[',{domain:d},function(x,r){document.getElementById('bs').disabled=false;r?sR(r.success,r.message):sR(false,'超时');if(r&&r.success)setTimeout(function(){location.reload()},2000)})}
</script>]]
end
return m
+116
View File
@@ -0,0 +1,116 @@
local m,s,o
local sys=require"luci.sys"
m=Map("minigate","MiniGate - 动态DNS","支持多域名、IPv4/IPv6双栈。保存后立即同步。需先启用全局开关。")
m.on_after_commit=function(self)
sys.call("/bin/sh /usr/lib/minigate/ddns.sh force >/dev/null 2>&1 &")
local en=self.uci:get("minigate","global","enabled")
if en=="1"then sys.call("/etc/init.d/minigate restart >/dev/null 2>&1 &") end
end
s=m:section(TypedSection,"ddns","DDNS 记录")
s.anonymous=true; s.addremove=true; s.sortable=true
o=s:option(Flag,"enabled","启用"); o.rmempty=false
o=s:option(Value,"domain","域名"); o.rmempty=false; o.placeholder="*.example.com 或 home.example.com"
o=s:option(Value,"cf_zone_id","Zone ID"); o.rmempty=false; o.placeholder="Cloudflare 区域 ID"
o=s:option(Value,"cf_api_token","API 令牌"); o.rmempty=false; o.password=true
-- ====== 协议版本 ======
o=s:option(ListValue,"ip_version","协议版本")
o:value("ipv4","仅 IPv4(A 记录)")
o:value("ipv6","仅 IPv6(AAAA 记录)")
o:value("dual","双栈(A + AAAA)")
o.default="ipv4"
o.description="选择更新哪种 DNS 记录。双栈模式同时更新 A 和 AAAA 记录。"
o=s:option(ListValue,"ip_source","IP 来源")
o:value("interface","从网卡获取"); o:value("url","从外部URL获取"); o.default="interface"
-- IPv4 网卡
o=s:option(ListValue,"interface","IPv4 网络接口"); o.default="wan"
local uci=require"luci.model.uci".cursor()
uci:foreach("network","interface",function(sec) if sec[".name"]~="loopback"then o:value(sec[".name"],sec[".name"])end end)
o:depends("ip_source","interface")
-- IPv4 URL
o=s:option(ListValue,"ip_url","IPv4 获取地址")
o:value("http://ip.3322.net","ip.3322.net")
o:value("http://members.3322.org/dyndns/getip","members.3322.org")
o:value("http://ns1.dnspod.net:6666","ns1.dnspod.net (腾讯)")
o:value("http://ip.tool.chinaz.com/getip","chinaz.com (站长工具)")
o.default="http://ip.3322.net"
o.description="旁路由/代理环境自动绕过代理直连获取真实公网IP"
o:depends("ip_source","url")
-- IPv6 网卡
o=s:option(ListValue,"interface6","IPv6 网络接口"); o.default="wan6"
o.description="用于获取 IPv6 地址的接口(通常为 wan6)"
uci:foreach("network","interface",function(sec) if sec[".name"]~="loopback"then o:value(sec[".name"],sec[".name"])end end)
o:depends({ip_source="interface",ip_version="ipv6"})
o:depends({ip_source="interface",ip_version="dual"})
-- IPv6 URL
o=s:option(ListValue,"ip_url6","IPv6 获取地址")
o:value("https://api6.ipify.org","api6.ipify.org")
o:value("https://6.ipw.cn","6.ipw.cn")
o:value("https://v6.ident.me","v6.ident.me")
o:value("https://ifconfig.co","ifconfig.co (v6)")
o:value("http://v6.66666.host:66/ip","v6.66666.host")
o.default="https://api6.ipify.org"
o.description="仅通过 IPv6 连接获取地址"
o:depends({ip_source="url",ip_version="ipv6"})
o:depends({ip_source="url",ip_version="dual"})
o=s:option(Value,"check_interval","间隔(秒)"); o.datatype="uinteger"; o.default="300"
-- 状态+手动同步
o=s:option(DummyValue,"_info","运行状态"); o.rawhtml=true
o.cfgvalue=function(self,section)
local st=m.uci:get("minigate",section,"status")or"unknown"
local msg=m.uci:get("minigate",section,"status_msg")or""
local ip=m.uci:get("minigate",section,"last_ip")or""
local ip6=m.uci:get("minigate",section,"last_ip6")or""
local lu=m.uci:get("minigate",section,"last_update")or""
local ns=m.uci:get("minigate",section,"next_sync")or""
local ver=m.uci:get("minigate",section,"ip_version")or"ipv4"
local h=""
if st=="ok"then
h='<span style="color:#4caf50">&#10003; 正常</span>'
elseif st=="partial"then
h='<span style="color:#ff9800">&#9888; 部分成功</span>'
elseif st=="error"then
h='<span style="color:#f44336">&#10007; '..(msg~=""and msg or"异常")..'</span>'
else h='<span style="color:#999">未同步</span>' end
-- 显示 IP 地址
if ip~=""then h=h..'<br><span style="font-size:11px;color:#888">A: '..ip..'</span>' end
if ip6~=""then h=h..'<br><span style="font-size:11px;color:#2196f3">AAAA: '..ip6..'</span>' end
if msg~=""and st~="error"then h=h..'<br><span style="font-size:11px;color:#888">'..msg..'</span>' end
if lu~=""then h=h..'<br><span style="font-size:11px;color:#888">更新: '..lu..'</span>' end
if ns~=""then h=h..'<br><span style="font-size:11px;color:#888">下次: '..ns..'</span>' end
h=h..'<br><button class="cbi-button cbi-button-action" style="margin-top:4px;font-size:12px;padding:2px 10px" '
h=h..'onclick="doSync(\''..section..'\',this)">手动同步</button>'
h=h..'<span id="sr-'..section..'" style="margin-left:8px;font-size:12px"></span>'
return h
end
-- 最近一天 IP 变化明细
s=m:section(SimpleSection)
s.template="minigate/ddns_history"
-- 注入 JS
s=m:section(NamedSection,"global","global"); s.anonymous=true
o=s:option(DummyValue,"_js"," "); o.rawhtml=true
o.cfgvalue=function()
local url=luci.dispatcher.build_url("admin/services/minigate/ddns_sync")
return'<script type="text/javascript">function doSync(s,b){b.disabled=true;b.textContent="同步中...";var e=document.getElementById("sr-"+s);e.textContent="";XHR.get("'..url..'",{section:s},function(x,d){b.disabled=false;b.textContent="手动同步";if(d&&d.success){e.innerHTML=\'<span style="color:#4caf50">\\u2713 \'+d.ip+(d.ip6?\' / \'+d.ip6:\'\')+\'</span>\';setTimeout(function(){location.reload()},1500)}else e.innerHTML=\'<span style="color:#f44336">\\u2717 失败</span>\'})}</script>'
end
return m
@@ -0,0 +1,589 @@
local m, s, o
local sys = require "luci.sys"
m = Map("minigate", "minigate", "轻量级网关管理:动态域名解析(IPv4/IPv6双栈)、SSL 证书签发、反向代理。")
m.on_after_commit = function(self)
local en = self.uci:get("minigate", "global", "enabled")
if en == "1" then
sys.call("/etc/init.d/minigate restart >/dev/null 2>&1 &")
else
sys.call("/etc/init.d/minigate stop >/dev/null 2>&1 &")
end
end
s = m:section(NamedSection, "global", "global", "服务状态")
s.anonymous = true
s:tab("status", "总览")
local su = luci.dispatcher.build_url("admin/services/minigate/status")
o = s:taboption("status", DummyValue, "_status")
o.rawhtml = true
o.cfgvalue = function()
local su = luci.dispatcher.build_url("admin/services/minigate/status")
local au = luci.dispatcher.build_url("admin/services/minigate/proxy_access")
local gu = luci.dispatcher.build_url("admin/services/minigate/geo_lookup")
local uu = luci.dispatcher.build_url("admin/services/minigate/update_status")
local xu = luci.dispatcher.build_url("admin/services/minigate/update_auto")
local cu = luci.dispatcher.build_url("admin/services/minigate/update_check")
local iu = luci.dispatcher.build_url("admin/services/minigate/update_apply")
local du = luci.dispatcher.build_url("admin/services/minigate/uninstall")
local services_url = luci.dispatcher.build_url("admin/services")
return [[
<style>
.mg-wrap{display:flex;flex-direction:column;gap:16px;--mg-success:#2f9e44;--mg-info:#1971c2;--mg-warning:#f08c00;--mg-danger:#e03131;--mg-danger-solid:#c92a2a;--mg-muted:#6b7280;--mg-link:#15803d;--mg-accent:#7c3aed;--mg-surface:#fff;--mg-surface-muted:#f6f8fb;--mg-surface-alt:#f8fafc;--mg-surface-hover:#eef4ff;--mg-control:#fff;--mg-text:#1f2937;--mg-text-soft:#2f3a47;--mg-text-muted:#5f6b7a;--mg-border:#d7dce3;--mg-border-soft:#e6eaf0;--mg-code-bg:#e8edf6;--mg-shadow:0 10px 24px rgba(15,23,42,.08)}
.mg-map{--mg-danger:#c92a2a;--mg-danger-hover:#fff5f5;--mg-danger-border:#f1b8b8}
.mg-page-head{display:flex;align-items:flex-start;justify-content:space-between;gap:28px}
.mg-page-head-copy{min-width:0;flex:1}
.mg-page-head-copy>h2{margin-top:0}
.mg-title-action{display:flex;flex:0 0 auto;margin-top:15px}
.mg-uninstall-trigger{min-width:112px;min-height:38px;border:1px solid var(--mg-danger-border)!important;border-radius:6px!important;background:transparent!important;color:var(--mg-danger)!important;font-weight:600}
.mg-uninstall-trigger:hover,.mg-uninstall-trigger:focus{background:var(--mg-danger-hover)!important}
.mg-status-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
.mg-card{position:relative;min-height:154px;border-radius:8px;padding:18px 18px 16px;background:var(--mg-surface);border:1px solid var(--mg-border);box-shadow:var(--mg-shadow);overflow:hidden}
.mg-card:before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--accent,#777)}
.mg-card-title{font-size:12px;color:var(--mg-text-muted);margin-bottom:12px}
.mg-card-state{font-size:18px;font-weight:700;margin-bottom:14px;line-height:1.25}
.mg-card-body{font-size:12px;color:var(--mg-text-soft);line-height:1.9;word-break:break-word}
.mg-card-body a,.mg-link{color:var(--mg-link);text-decoration:none}
.mg-card-body a:hover,.mg-link:hover{text-decoration:underline}
.mg-panel{border-radius:8px;background:var(--mg-surface);border:1px solid var(--mg-border);box-shadow:var(--mg-shadow);overflow:hidden}
.mg-panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;padding:14px 16px;border-bottom:1px solid var(--mg-border)}
.mg-panel-title{font-size:13px;font-weight:700;color:var(--mg-text)}
.mg-count{color:var(--mg-accent);font-weight:600}
.mg-limit-label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--mg-text-muted);white-space:nowrap}
.mg-select{height:28px;border-radius:6px;border:1px solid var(--mg-border);background:var(--mg-control);color:var(--mg-text);padding:0 8px;font-size:12px}
.mg-table-wrap{overflow-x:auto}
.mg-table{width:100%;border-collapse:collapse;min-width:760px}
.mg-table th{padding:10px 12px;color:var(--mg-text-muted);font-size:11px;font-weight:600;text-align:left;border-bottom:1px solid var(--mg-border);background:var(--mg-surface-muted)}
.mg-table td{padding:11px 12px;color:var(--mg-text);font-size:12px;border-bottom:1px solid var(--mg-border-soft);vertical-align:middle}
.mg-table tr:nth-child(even) td{background:var(--mg-surface-alt)}
.mg-table tr:hover td{background:var(--mg-surface-hover)}
.mg-status{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.mg-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:#777}
.mg-dot.on{background:var(--mg-success);box-shadow:0 0 0 3px rgba(47,158,68,.14)}
.mg-ip{display:inline-block;border-radius:6px;background:var(--mg-code-bg);color:var(--mg-text);padding:2px 7px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px}
.mg-empty{padding:18px 16px;color:var(--mg-text-muted);font-size:12px}
.mg-update-body{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:15px 16px;flex-wrap:wrap}
.mg-update-copy{display:flex;flex-direction:column;gap:5px;min-width:220px;flex:1}
.mg-update-version{font-size:13px;font-weight:700;color:var(--mg-text)}
.mg-update-message{font-size:12px;color:var(--mg-text-muted);line-height:1.6}
.mg-update-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.mg-update-progress{height:3px;background:var(--mg-border-soft);overflow:hidden}
.mg-update-progress span{display:block;width:0;height:100%;background:var(--mg-info);transition:width .25s ease}
.mg-modal{position:fixed;inset:0;z-index:10000;display:none;align-items:center;justify-content:center;padding:20px;background:rgba(15,23,42,.58)}
.mg-modal.is-open{display:flex}
.mg-modal-dialog{display:flex;flex-direction:column;width:min(620px,100%);max-height:min(80vh,720px);border:1px solid var(--mg-border);border-radius:8px;background:var(--mg-surface);box-shadow:0 20px 48px rgba(15,23,42,.28);overflow:hidden}
.mg-modal-head{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;gap:16px;padding:15px 16px;border-bottom:1px solid var(--mg-border)}
.mg-modal-title{margin:0;min-width:0;overflow-wrap:anywhere;color:var(--mg-text);font-size:15px;font-weight:700;line-height:1.4}
.mg-modal-close{display:inline-flex;align-items:center;justify-content:center;flex:0 0 44px;width:44px;height:44px;padding:0;border:0;background:transparent;color:var(--mg-text-muted);font-size:28px;line-height:1;cursor:pointer}
.mg-modal-close:hover{color:var(--mg-text);background:var(--mg-surface-hover)}
.mg-modal-notes{min-height:0;padding:16px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--mg-text-soft);font-size:13px;line-height:1.7;background:var(--mg-surface-muted)}
.mg-modal-actions{display:flex;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end;gap:8px;padding:14px 16px;border-top:1px solid var(--mg-border);background:var(--mg-surface)}
.mg-modal-actions button{min-height:44px}
.mg-modal button:focus{outline:2px solid var(--mg-info);outline-offset:2px}
.mg-uninstall-body{padding:16px;color:var(--mg-text-soft);font-size:13px;line-height:1.7;background:var(--mg-surface-muted)}
.mg-uninstall-body p{margin:0 0 12px}
.mg-uninstall-warning{margin-bottom:14px;padding:11px 12px;border-left:4px solid var(--mg-danger);background:var(--mg-surface);color:var(--mg-text)}
.mg-uninstall-choice{display:flex;align-items:flex-start;gap:10px;padding:12px;border:1px solid var(--mg-border);border-radius:6px;background:var(--mg-surface);color:var(--mg-text);cursor:pointer}
.mg-uninstall-choice input{flex:0 0 auto;width:18px;height:18px;margin:2px 0 0}
.mg-uninstall-detail{margin:8px 0 0;color:var(--mg-text-muted);font-size:12px}
.mg-uninstall-status{min-height:21px;margin-top:10px;color:var(--mg-danger);font-size:12px}
.mg-danger-button{border-color:var(--mg-danger-solid)!important;background:var(--mg-danger-solid)!important;color:#fff!important}
.mg-danger-button:hover{filter:brightness(.94)}
.mg-danger-button:focus{outline-color:var(--mg-danger)!important}
:root[data-darkmode="true"] .mg-wrap{--mg-success:#78c98d;--mg-info:#7db7df;--mg-warning:#d7a64a;--mg-danger:#e58a84;--mg-danger-solid:#a9504d;--mg-muted:#9aa7b3;--mg-link:#7bcf91;--mg-accent:#b69af4;--mg-surface:var(--background-color-medium,#171e26);--mg-surface-muted:var(--background-color-high,#131a22);--mg-surface-alt:var(--background-color-low,#19212a);--mg-surface-hover:#202a34;--mg-control:var(--background-color-high,#111820);--mg-text:var(--text-color-high,#d5dbe3);--mg-text-soft:var(--text-color-high,#c8d0d9);--mg-text-muted:var(--text-color-medium,#9aa7b3);--mg-border:var(--border-color-medium,rgba(148,163,184,.18));--mg-border-soft:var(--border-color-low,rgba(148,163,184,.12));--mg-code-bg:var(--background-color-low,#253142);--mg-shadow:none}
:root[data-darkmode="true"] .mg-map{--mg-danger:#e58a84;--mg-danger-hover:rgba(229,138,132,.1);--mg-danger-border:rgba(229,138,132,.45)}
@media(prefers-color-scheme:dark){
:root:not([data-darkmode]) .mg-wrap{--mg-success:#78c98d;--mg-info:#7db7df;--mg-warning:#d7a64a;--mg-danger:#e58a84;--mg-danger-solid:#a9504d;--mg-muted:#9aa7b3;--mg-link:#7bcf91;--mg-accent:#b69af4;--mg-surface:#171e26;--mg-surface-muted:#131a22;--mg-surface-alt:#19212a;--mg-surface-hover:#202a34;--mg-control:#111820;--mg-text:#d5dbe3;--mg-text-soft:#c8d0d9;--mg-text-muted:#9aa7b3;--mg-border:rgba(148,163,184,.18);--mg-border-soft:rgba(148,163,184,.12);--mg-code-bg:#253142;--mg-shadow:none}
:root:not([data-darkmode]) .mg-map{--mg-danger:#e58a84;--mg-danger-hover:rgba(229,138,132,.1);--mg-danger-border:rgba(229,138,132,.45)}
}
@media(max-width:900px){.mg-status-grid{grid-template-columns:1fr}.mg-card{min-height:auto}}
@media(max-width:640px){.mg-page-head{flex-direction:column;gap:12px}.mg-title-action{width:100%;margin-top:0}.mg-uninstall-trigger{width:100%}.mg-modal{padding:12px}.mg-modal-dialog{max-height:88vh}.mg-modal-actions{display:grid;grid-template-columns:1fr 1fr}.mg-modal-actions button{width:100%;min-width:0}}
</style>
<div class="mg-wrap">
<div id="mg-title-action" class="mg-title-action" hidden>
<button type="button" id="mg-uninstall-open" class="cbi-button mg-uninstall-trigger" onclick="mgOpenUninstallDialog()">卸载</button>
</div>
<div id="mg" class="mg-status-grid">
<div id="mg-ddns-card" class="mg-card" style="--accent:var(--mg-success)">
<div class="mg-card-title">动态 DNS</div>
<div id="mg-d1" class="mg-card-state">--</div>
<div id="mg-d2" class="mg-card-body"></div>
</div>
<div id="mg-cert-card" class="mg-card" style="--accent:var(--mg-info)">
<div class="mg-card-title">SSL/TLS 证书</div>
<div id="mg-a1" class="mg-card-state">--</div>
<div id="mg-a2" class="mg-card-body"></div>
</div>
<div id="mg-proxy-card" class="mg-card" style="--accent:var(--mg-warning)">
<div class="mg-card-title">反向代理</div>
<div id="mg-p1" class="mg-card-state">--</div>
<div id="mg-p2" class="mg-card-body"></div>
</div>
</div>
<div id="mg-update" class="mg-panel">
<div class="mg-panel-head"><div class="mg-panel-title">软件更新</div></div>
<div class="mg-update-body">
<div class="mg-update-copy">
<div id="mg-u-version" class="mg-update-version">当前版本 --</div>
<div id="mg-u-message" class="mg-update-message">正在读取更新状态...</div>
</div>
<div class="mg-update-actions">
<button type="button" id="mg-u-check" class="cbi-button cbi-button-reload" onclick="mgCheckUpdate()">检查更新</button>
<button type="button" id="mg-u-apply" class="cbi-button cbi-button-apply" onclick="mgApplyUpdate()" style="display:none">一键更新</button>
</div>
</div>
<div class="mg-update-progress"><span id="mg-u-progress"></span></div>
</div>
<div id="mg-update-modal" class="mg-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="mg-update-modal-title">
<div class="mg-modal-dialog">
<div class="mg-modal-head">
<h3 id="mg-update-modal-title" class="mg-modal-title">本次更新内容</h3>
<button type="button" class="mg-modal-close" aria-label="关闭更新说明" title="关闭更新说明" onclick="mgCloseUpdateDialog()">&times;</button>
</div>
<div id="mg-update-modal-notes" class="mg-modal-notes" tabindex="0" aria-label="本次更新内容"></div>
<div class="mg-modal-actions">
<button type="button" class="cbi-button" onclick="mgCloseUpdateDialog()">取消</button>
<button type="button" id="mg-update-confirm" class="cbi-button cbi-button-apply" onclick="mgStartUpdate()">确认更新</button>
</div>
</div>
</div>
<div id="mg-visitors" class="mg-panel">
<div class="mg-panel-head">
<div class="mg-panel-title">访问记录 <span id="mg-v-count" class="mg-count"></span></div>
<label class="mg-limit-label">显示
<select id="mg-v-limit" class="mg-select">
<option value="5" selected>5</option>
<option value="20">20</option>
<option value="50">50</option>
</select>条</label>
</div>
<div id="mg-v-list" class="mg-table-wrap"><div class="mg-empty">加载中...</div></div>
</div>
<div id="mg-uninstall-modal" class="mg-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="mg-uninstall-modal-title">
<div class="mg-modal-dialog">
<div class="mg-modal-head">
<h3 id="mg-uninstall-modal-title" class="mg-modal-title">卸载 minigate</h3>
<button type="button" id="mg-uninstall-close" class="mg-modal-close" aria-label="关闭卸载确认" title="关闭卸载确认" onclick="mgCloseUninstallDialog()">&times;</button>
</div>
<div class="mg-uninstall-body">
<p>卸载会停止动态 DNS、证书续期、反向代理和登录防护,并移除 minigate 的程序及 LuCI 页面。</p>
<div class="mg-uninstall-warning">由 minigate 提供的公网访问会立即中断。</div>
<label class="mg-uninstall-choice" for="mg-uninstall-purge">
<input type="checkbox" id="mg-uninstall-purge" onchange="mgUpdateUninstallChoice()">
<span>同时删除配置、证书、封禁记录和日志</span>
</label>
<p id="mg-uninstall-detail" class="mg-uninstall-detail">默认保留这些数据,方便以后重新安装。</p>
<div id="mg-uninstall-status" class="mg-uninstall-status" role="status" aria-live="polite"></div>
</div>
<div class="mg-modal-actions">
<button type="button" id="mg-uninstall-cancel" class="cbi-button" onclick="mgCloseUninstallDialog()">取消</button>
<button type="button" id="mg-uninstall-confirm" class="cbi-button mg-danger-button" onclick="mgStartUninstall()">确认卸载</button>
</div>
</div>
</div>
</div>
<script type="text/javascript">
var _geoCache={};
var _geoPending={};
var _visitorLimit=localStorage.getItem('mgVisitorLimit')||'5';
if(_visitorLimit!='5'&&_visitorLimit!='20'&&_visitorLimit!='50')_visitorLimit='5';
function escHtml(s){
return String(s||'').replace(/[&<>"']/g,function(c){
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
function mgColor(name){
var root=document.querySelector('.mg-wrap');
if(!root)return'#999';
return getComputedStyle(root).getPropertyValue('--mg-'+name).trim()||'#999';
}
function fmtT(iso){
if(!iso)return'--';
var m=iso.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/);
return m?(m[2]+'-'+m[3]+' '+m[4]+':'+m[5]+':'+m[6]):iso;
}
function queryGeo(ip,cb){
if(_geoCache[ip]){cb(_geoCache[ip]);return;}
if(_geoPending[ip]){
_geoPending[ip].push(cb);
return;
}
_geoPending[ip]=[cb];
XHR.get(']] .. gu .. [[',{ip:ip},function(x,d){
var loc=(d&&d.geo)?d.geo:'未知';
var list=_geoPending[ip]||[];
delete _geoPending[ip];
_geoCache[ip]=loc;
for(var i=0;i<list.length;i++)list[i](loc);
});
}
function loadVisitors(){
XHR.get(']] .. au .. [[',{limit:_visitorLimit},function(x,d){
var el=document.getElementById('mg-v-list');
var ct=document.getElementById('mg-v-count');
if(!d||!d.visitors||d.visitors.length===0){
el.innerHTML='<div class="mg-empty">暂无访问记录</div>';
ct.textContent='';
return;
}
ct.textContent='('+d.visitors.length+' 个IP)';
var h='<table class="mg-table">';
h+='<thead><tr><th>状态</th><th>IP 地址</th><th>归属地</th><th>最后访问</th><th>域名</th><th>次数</th></tr></thead><tbody>';
for(var i=0;i<d.visitors.length;i++){
var v=d.visitors[i];
var dot=v.online
?'<span class="mg-dot on" title="在线"></span>'
:'<span class="mg-dot" title="离线"></span>';
var stxt=v.online?'<span style="color:'+mgColor('success')+'">在线</span>':'<span style="color:'+mgColor('muted')+'">离线</span>';
h+='<tr>';
h+='<td><span class="mg-status">'+dot+stxt+'</span></td>';
h+='<td><code class="mg-ip">'+escHtml(v.ip)+'</code></td>';
h+='<td id="geo-'+i+'"><span style="color:'+mgColor('muted')+'">查询中...</span></td>';
h+='<td style="white-space:nowrap">'+escHtml(fmtT(v.last_time))+'</td>';
h+='<td>'+escHtml(v.domain)+'</td>';
h+='<td>'+escHtml(v.count)+'</td>';
h+='</tr>';
}
h+='</tbody></table>';
el.innerHTML=h;
// 逐个查询归属地(避免并发太多)
var qi=0;
function nextGeo(){
if(qi>=d.visitors.length)return;
var idx=qi;qi++;
queryGeo(d.visitors[idx].ip,function(loc){
var ge=document.getElementById('geo-'+idx);
if(ge)ge.innerHTML='<span style="font-size:11px">'+escHtml(loc)+'</span>';
setTimeout(nextGeo,150);
});
}
nextGeo();
});
}
var _updatePolling=false;
var _latestUpdate=null;
var _updateDialogOpener=null;
var _confirmedUpdate=null;
var _bodyOverflow='';
var _updateStarting=false;
var _updateStarted=false;
var _updateReloading=false;
var _uninstallDialogOpener=null;
var _uninstallStarting=false;
function mgPreparePageHeader(){
var action=document.getElementById('mg-title-action');
var wrap=document.querySelector('.mg-wrap');
if(!action||!wrap)return;
var map=wrap;
while(map&&(' '+map.className+' ').indexOf(' cbi-map ')<0)map=map.parentNode;
if(!map)return;
var title=null,description=null;
for(var i=0;i<map.children.length;i++){
var child=map.children[i];
if(!title&&child.tagName&&child.tagName.toLowerCase()==='h2')title=child;
else if(title&&(' '+child.className+' ').indexOf(' cbi-map-descr ')>=0){description=child;break;}
}
if(!title)return;
var head=document.createElement('div');
var copy=document.createElement('div');
head.className='mg-page-head';
copy.className='mg-page-head-copy';
map.className+=' mg-map';
map.insertBefore(head,title);
head.appendChild(copy);
copy.appendChild(title);
if(description)copy.appendChild(description);
action.hidden=false;
head.appendChild(action);
}
function mgOpenUninstallDialog(){
if(_uninstallStarting)return;
var modal=document.getElementById('mg-uninstall-modal');
var purge=document.getElementById('mg-uninstall-purge');
_uninstallDialogOpener=document.getElementById('mg-uninstall-open');
purge.checked=false;
document.getElementById('mg-uninstall-status').textContent='';
mgUpdateUninstallChoice();
modal.className='mg-modal is-open';
modal.setAttribute('aria-hidden','false');
_bodyOverflow=document.body.style.overflow;
document.body.style.overflow='hidden';
document.getElementById('mg-uninstall-confirm').focus();
}
function mgCloseUninstallDialog(){
if(_uninstallStarting)return;
var modal=document.getElementById('mg-uninstall-modal');
modal.className='mg-modal';
modal.setAttribute('aria-hidden','true');
document.body.style.overflow=_bodyOverflow;
if(_uninstallDialogOpener)_uninstallDialogOpener.focus();
}
function mgUpdateUninstallChoice(){
var purge=document.getElementById('mg-uninstall-purge').checked;
document.getElementById('mg-uninstall-detail').textContent=purge
?'配置、证书、封禁记录和日志将一并删除,无法恢复。'
:'默认保留这些数据,方便以后重新安装。';
document.getElementById('mg-uninstall-confirm').textContent=purge?'彻底卸载':'确认卸载';
}
function mgStartUninstall(){
if(_uninstallStarting)return;
var purge=document.getElementById('mg-uninstall-purge').checked?'1':'0';
var confirm=document.getElementById('mg-uninstall-confirm');
var status=document.getElementById('mg-uninstall-status');
_uninstallStarting=true;
confirm.disabled=true;
document.getElementById('mg-uninstall-purge').disabled=true;
document.getElementById('mg-uninstall-cancel').disabled=true;
document.getElementById('mg-uninstall-close').disabled=true;
status.textContent='正在启动卸载任务...';
XHR.post(']] .. du .. [[',{confirm:'uninstall-minigate',purge:purge},function(x,d){
if(!d||!d.success){
_uninstallStarting=false;
confirm.disabled=false;
document.getElementById('mg-uninstall-purge').disabled=false;
document.getElementById('mg-uninstall-cancel').disabled=false;
document.getElementById('mg-uninstall-close').disabled=false;
status.textContent=(d&&d.message)||'无法启动卸载任务';
return;
}
confirm.textContent='卸载中...';
status.textContent='正在移除 minigate,完成后将返回服务页面。';
setTimeout(function(){location.href=']] .. services_url .. [[';},3000);
});
}
function mgRenderUpdate(d){
if(!d)return;
_latestUpdate=d;
var version=document.getElementById('mg-u-version');
var message=document.getElementById('mg-u-message');
var check=document.getElementById('mg-u-check');
var apply=document.getElementById('mg-u-apply');
var progress=document.getElementById('mg-u-progress');
var current=d.current||'--';
version.textContent='当前版本 '+current+(d.latest?' · 最新版本 '+d.latest:'');
message.textContent=d.message||'--';
message.style.color=d.success===false?mgColor('danger'):(d.status=='success'?mgColor('success'):mgColor('muted'));
progress.style.width=Math.max(0,Math.min(100,Number(d.progress)||0))+'%';
progress.style.background=d.success===false?mgColor('danger'):(d.status=='success'?mgColor('success'):mgColor('info'));
check.disabled=!!d.running;
apply.disabled=!!d.running;
document.getElementById('mg-uninstall-open').disabled=!!d.running;
apply.style.display=d.available?'inline-block':'none';
if(d.running&&!_updatePolling){
_updatePolling=true;
setTimeout(mgLoadUpdate,1200);
}else if(!d.running){
_updatePolling=false;
}
if(d.status=='success'&&_updateStarted&&!_updateReloading){
_updateReloading=true;
setTimeout(function(){location.reload()},3500);
}
}
function mgLoadUpdate(){
var keepPolling=_updatePolling;
XHR.get(']] .. uu .. [[',null,function(x,d){
_updatePolling=false;
if(d){
mgRenderUpdate(d);
}else if(keepPolling){
_updatePolling=true;
setTimeout(mgLoadUpdate,2000);
}
});
}
function mgCheckUpdate(){
var check=document.getElementById('mg-u-check');
check.disabled=true;
document.getElementById('mg-u-message').textContent='正在连接 GitHub...';
XHR.get(']] .. cu .. [[',null,function(x,d){
check.disabled=false;
mgRenderUpdate(d||{success:false,message:'检查失败,请稍后重试'});
});
}
function mgAutoCheckUpdate(){
var check=document.getElementById('mg-u-check');
check.disabled=true;
document.getElementById('mg-u-message').textContent='正在自动检查更新...';
XHR.get(']] .. xu .. [[',null,function(x,d){
check.disabled=false;
mgRenderUpdate(d||{success:false,message:'自动检查失败,可稍后手动重试'});
});
}
function mgApplyUpdate(){
if(!_latestUpdate||!_latestUpdate.available||!_latestUpdate.latest||_latestUpdate.running||_updateStarting||_confirmedUpdate)return;
var modal=document.getElementById('mg-update-modal');
var title=document.getElementById('mg-update-modal-title');
var notes=document.getElementById('mg-update-modal-notes');
_updateDialogOpener=document.getElementById('mg-u-apply');
_confirmedUpdate=_latestUpdate;
title.textContent=_latestUpdate.release_title||('更新到 v'+(_latestUpdate.latest||''));
notes.textContent=_latestUpdate.release_notes||'GitHub Release 未提供本次更新说明。';
modal.className='mg-modal is-open';
modal.setAttribute('aria-hidden','false');
_bodyOverflow=document.body.style.overflow;
document.body.style.overflow='hidden';
notes.focus();
}
function mgCloseUpdateDialog(){
var modal=document.getElementById('mg-update-modal');
modal.className='mg-modal';
modal.setAttribute('aria-hidden','true');
document.body.style.overflow=_bodyOverflow;
_confirmedUpdate=null;
if(_updateDialogOpener)_updateDialogOpener.focus();
}
function mgStartUpdate(){
if(!_confirmedUpdate||_updateStarting)return;
var confirmed=_confirmedUpdate;
_updateStarting=true;
mgCloseUpdateDialog();
var apply=document.getElementById('mg-u-apply');
apply.disabled=true;
document.getElementById('mg-u-check').disabled=true;
document.getElementById('mg-u-message').textContent='正在启动更新任务...';
XHR.post(']] .. iu .. [[',{version:confirmed.latest},function(x,d){
_updateStarting=false;
if(!d||!d.success){
confirmed.success=false;
confirmed.message=(d&&d.message)||'无法启动更新任务';
mgRenderUpdate(confirmed);
return;
}
_updateStarted=true;
_updatePolling=true;
setTimeout(mgLoadUpdate,700);
});
}
var updateModal=document.getElementById('mg-update-modal');
var uninstallModal=document.getElementById('mg-uninstall-modal');
if(updateModal){
updateModal.onclick=function(e){if(e.target===updateModal)mgCloseUpdateDialog();};
}
if(uninstallModal){
uninstallModal.onclick=function(e){if(e.target===uninstallModal)mgCloseUninstallDialog();};
}
document.addEventListener('keydown',function(e){
var modal=null;
if(updateModal&&updateModal.className.indexOf('is-open')>=0)modal=updateModal;
else if(uninstallModal&&uninstallModal.className.indexOf('is-open')>=0)modal=uninstallModal;
if(!modal)return;
if(e.key==='Escape'){
e.preventDefault();
if(modal===updateModal)mgCloseUpdateDialog();else mgCloseUninstallDialog();
}
if(e.key==='Tab'){
var items=modal.querySelectorAll('button:not([disabled]),input:not([disabled]),[tabindex="0"]');
var first=items[0],last=items[items.length-1];
if(!first)return;
if(e.shiftKey&&(document.activeElement===first||!modal.contains(document.activeElement))){
e.preventDefault();last.focus();
}else if(!e.shiftKey&&(document.activeElement===last||!modal.contains(document.activeElement))){
e.preventDefault();first.focus();
}
}
});
mgPreparePageHeader();
var limitSel=document.getElementById('mg-v-limit');
if(limitSel){
limitSel.value=_visitorLimit;
limitSel.onchange=function(){
_visitorLimit=this.value;
localStorage.setItem('mgVisitorLimit',_visitorLimit);
loadVisitors();
};
}
XHR.poll(6,']] .. su .. [[',null,function(x,d){
if(!d)return;
var d1=document.getElementById('mg-d1'),d2=document.getElementById('mg-d2');
var card=document.getElementById('mg-ddns-card');
if(d.ddns_list&&d.ddns_list.length>0){
var e=d.ddns_list[0];
var c=e.status=='ok'?mgColor('success'):(e.status=='partial'?mgColor('warning'):(e.enabled=='1'?mgColor('danger'):mgColor('muted')));
var l=e.status=='ok'?'\u2713 \u8fd0\u884c\u4e2d':(e.status=='partial'?'\u26a0 \u90e8\u5206\u6210\u529f':(e.enabled=='1'?'\u26a0 \u5f02\u5e38':'\u672a\u542f\u7528'));
card.style.setProperty('--accent',c);
d1.innerHTML='<span style="color:'+c+'">'+l+'</span>';
var info=(e.domain||'')+'\n';
if(e.last_ip)info+='A: '+e.last_ip+'\n';
if(e.last_ip6)info+='AAAA: '+e.last_ip6+'\n';
var sm=e.status_msg||'';
if(sm){
var dupA=e.last_ip&&sm.indexOf('A:'+e.last_ip)>=0;
var dupAAAA=e.last_ip6&&sm.indexOf('AAAA:'+e.last_ip6)>=0;
var onlyIpStatus=sm.replace(/A:[^;]+;?/g,'').replace(/AAAA:[^;]+;?/g,'').replace(/\s+/g,'')=='';
if(!(onlyIpStatus&&(dupA||dupAAAA)))info+=sm+'\n';
}
if(e.last_update)info+='\u66f4\u65b0: '+e.last_update+'\n';
if(e.next_sync)info+='\u4e0b\u6b21: '+e.next_sync;
if(d.ddns_list.length>1)info+='\n(+'+(d.ddns_list.length-1)+' \u6761\u8bb0\u5f55)';
d2.innerHTML=escHtml(info).replace(/\n/g,'<br>');
}else{d1.innerHTML='<span style="color:'+mgColor('muted')+'">\u672a\u914d\u7f6e</span>';d2.textContent='';card.style.setProperty('--accent',mgColor('muted'));}
var a1=document.getElementById('mg-a1'),a2=document.getElementById('mg-a2');
if(d.acme&&d.acme.enabled=='1'){
a1.innerHTML=d.acme.status=='ok'?'<span style="color:'+mgColor('info')+'">\u2713 \u6709\u6548</span>':'<span style="color:'+mgColor('danger')+'">'+escHtml(d.acme.status)+'</span>';
a2.innerHTML=escHtml(d.acme.last_domain||'')+(d.acme.cert_expiry?'<br>\u8fc7\u671f: '+escHtml(d.acme.cert_expiry):'');
}else{a1.innerHTML='<span style="color:'+mgColor('muted')+'">\u672a\u542f\u7528</span>';a2.textContent='';}
var p1=document.getElementById('mg-p1'),p2=document.getElementById('mg-p2');
p1.innerHTML=d.proxy_running?'<span style="color:'+mgColor('warning')+'">\u2713 \u8fd0\u884c\u4e2d</span>':'<span style="color:'+mgColor('muted')+'">\u5df2\u505c\u6b62</span>';
var pinfo='';
if(d.proxy_rules&&d.proxy_rules.length>0){
for(var i=0;i<d.proxy_rules.length;i++){
var r=d.proxy_rules[i];
var url=r.scheme+'://'+r.domain+(r.listen_port!='443'&&r.listen_port!='80'?':'+r.listen_port:'');
var v6tag=r.ipv6_listen=='1'?' <span style="color:'+mgColor('info')+';font-size:10px">[IPv6]</span>':'';
pinfo+='<div><span class="mg-link">'+escHtml(url)+'</span>'+v6tag+' \u2192 '+escHtml(r.target)+'</div>';
}}else if(d.proxy_running){pinfo='Nginx \u8fd0\u884c\u4e2d';}
p2.innerHTML=pinfo;
});
loadVisitors();
setInterval(loadVisitors,15000);
mgAutoCheckUpdate();
</script>
]] end
s = m:section(NamedSection, "global", "global", "全局设置")
s.anonymous = true
o = s:option(Flag, "enabled", "启用 MiniGate")
o.description = "总开关。关闭后停止 DDNS 定时任务、ACME 续期和反向代理。保存后立即生效。"
o.rmempty = false
o = s:option(Flag, "ipv6_listen", "反向代理监听 IPv6")
o.description = "开启后,Nginx 反向代理将同时监听 IPv4 和 IPv6 地址(listen [::]:port)。"
o.rmempty = false
o.default = "0"
return m
@@ -0,0 +1,451 @@
local m, s, o
local sys = require "luci.sys"
local fs = require "nixio.fs"
m = Map("minigate", "登录防护 (Login Guard)",
"监控 SSH(dropbear)和 LuCI 网页登录失败,达到阈值自动封禁源 IP。" ..
"适用于 SSH 端口被映射到公网、防爆破。LAN 私有 IP 自动豁免。")
m.on_after_commit = function(self)
sys.call("/etc/init.d/minigate reload >/dev/null 2>&1 &")
end
-- ============== 实时状态 ==============
s = m:section(NamedSection, "login_guard", "login_guard", "实时状态")
s.anonymous = true
local lu = luci.dispatcher.build_url("admin/services/minigate/lg_status")
local uu = luci.dispatcher.build_url("admin/services/minigate/lg_unban")
local bu = luci.dispatcher.build_url("admin/services/minigate/lg_ban")
local fu = luci.dispatcher.build_url("admin/services/minigate/lg_flush")
local gu = luci.dispatcher.build_url("admin/services/minigate/geo_lookup")
o = s:option(DummyValue, "_dash")
o.rawhtml = true
o.cfgvalue = function()
local function esc(v)
v = tostring(v or "")
v = v:gsub("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;")
v = v:gsub('"', "&quot;"):gsub("'", "&#39;")
return v
end
local function fmt_duration(s)
s = tonumber(s) or 0
if s >= 3600 then
return string.format("%dh%dm", math.floor(s / 3600), math.floor((s % 3600) / 60))
elseif s >= 60 then
return string.format("%dm%ds", math.floor(s / 60), s % 60)
end
return tostring(s) .. "s"
end
local threshold = tonumber(m.uci:get("minigate", "login_guard", "threshold")) or 3
local now = os.time()
local watching_rows = {}
local watching_items = {}
local counter_dir = "/var/run/minigate/login-guard/counters"
local list_out = sys.exec("ls " .. counter_dir .. " 2>/dev/null") or ""
for ip in list_out:gmatch("[^\n]+") do
local path = counter_dir .. "/" .. ip
local fh = io.open(path, "r")
if fh then
local line = fh:read("*l") or ""
fh:close()
local first, count = line:match("^(%d+)%s+(%d+)")
if first and count then
local stat = fs.stat(path)
local last_time = (stat and stat.mtime) or tonumber(first)
watching_items[#watching_items + 1] = {
ip = ip,
count = tonumber(count) or 0,
age = now - tonumber(first),
last_time = last_time,
last_seen = os.date("%Y-%m-%d %H:%M:%S", last_time)
}
end
end
end
table.sort(watching_items, function(a,b)
return (a.last_time or 0) > (b.last_time or 0)
end)
for i = 1, math.min(#watching_items, 30) do
local item = watching_items[i]
local style = (i > 5) and ' style="display:none"' or ''
watching_rows[#watching_rows + 1] =
'<tr class="lg-watch-row" data-ip="' .. esc(item.ip) .. '" data-index="' .. tostring(i) .. '"' .. style .. '><td><code class="lg-ip">' .. esc(item.ip) .. '</code></td>' ..
'<td id="lg-watch-geo-initial-' .. tostring(i) .. '"><span style="color:var(--lg-muted)">查询中...</span></td>' ..
'<td><span style="font-weight:bold">' .. esc(item.count) .. ' / ' .. esc(threshold) .. '</span></td>' ..
'<td>' .. esc(item.last_seen) .. '</td>' ..
'<td>' .. esc(fmt_duration(item.age)) .. '</td></tr>'
end
local initial_watching = '<div class="lg-empty">无</div>'
if #watching_rows > 0 then
initial_watching =
'<table class="lg-table"><thead><tr><th>IP 地址</th><th>归属地</th><th>失败次数</th><th>最近访问时间</th><th>距首次失败</th></tr></thead><tbody>' ..
table.concat(watching_rows, "") ..
'</tbody></table>'
end
return [[
<style>
.lg-wrap{display:flex;flex-direction:column;gap:16px;--lg-success:#2f9e44;--lg-warning:#f08c00;--lg-danger:#e03131;--lg-muted:#6b7280;--lg-primary:#5b55c8;--lg-surface:#fff;--lg-surface-muted:#f6f8fb;--lg-surface-alt:#f8fafc;--lg-surface-hover:#eef4ff;--lg-control:#fff;--lg-text:#1f2937;--lg-text-muted:#5f6b7a;--lg-border:#d7dce3;--lg-border-soft:#e6eaf0;--lg-code-bg:#e8edf6;--lg-danger-bg:#fee2e2;--lg-danger-text:#991b1b;--lg-shadow:0 10px 24px rgba(15,23,42,.08)}
.lg-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
.lg-card{position:relative;min-height:118px;border-radius:8px;padding:18px;background:var(--lg-surface);border:1px solid var(--lg-border);box-shadow:var(--lg-shadow);overflow:hidden}
.lg-card:before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--accent,#777)}
.lg-card-title{font-size:12px;color:var(--lg-text-muted);margin-bottom:14px}
.lg-card-value{font-size:22px;font-weight:700;line-height:1.25}
.lg-panel{border-radius:8px;background:var(--lg-surface);border:1px solid var(--lg-border);box-shadow:var(--lg-shadow);overflow:hidden}
.lg-panel-head{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;padding:14px 16px;border-bottom:1px solid var(--lg-border)}
.lg-panel-title{font-size:13px;font-weight:700;color:var(--lg-text)}
.lg-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.lg-input{height:30px;min-width:180px;border-radius:6px;border:1px solid var(--lg-border);background:var(--lg-control);color:var(--lg-text);padding:0 10px;font-size:12px;box-sizing:border-box}
.lg-select{height:30px;border-radius:6px;border:1px solid var(--lg-border);background:var(--lg-control);color:var(--lg-text);padding:0 8px;font-size:12px;box-sizing:border-box}
.lg-limit-label{display:flex;align-items:center;gap:6px;color:var(--lg-text-muted);font-size:12px;white-space:nowrap}
.lg-btn{height:30px;border:0;border-radius:6px;padding:0 12px;font-size:12px;color:#fff;cursor:pointer}
.lg-btn-primary{background:var(--lg-primary)}
.lg-btn-danger{background:var(--lg-danger)}
.lg-table-wrap{overflow-x:auto}
.lg-table{width:100%;border-collapse:collapse;min-width:720px}
.lg-table th{padding:10px 12px;color:var(--lg-text-muted);font-size:11px;font-weight:600;text-align:left;border-bottom:1px solid var(--lg-border);background:var(--lg-surface-muted)}
.lg-table td{padding:11px 12px;color:var(--lg-text);font-size:12px;border-bottom:1px solid var(--lg-border-soft);vertical-align:middle}
.lg-table tr:nth-child(even) td{background:var(--lg-surface-alt)}
.lg-table tr:hover td{background:var(--lg-surface-hover)}
.lg-ip{display:inline-block;border-radius:6px;background:var(--lg-code-bg);color:var(--lg-text);padding:2px 7px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px}
.lg-ip-danger{background:var(--lg-danger-bg);color:var(--lg-danger-text)}
.lg-empty{padding:18px 16px;color:var(--lg-text-muted);font-size:12px}
.lg-unban{height:26px;border:1px solid var(--lg-border);border-radius:6px;background:var(--lg-control);color:var(--lg-text);padding:0 10px;font-size:11px;cursor:pointer}
:root[data-darkmode="true"] .lg-wrap{--lg-success:#78c98d;--lg-warning:#d7a64a;--lg-danger:#e58a84;--lg-muted:#9aa7b3;--lg-primary:#8e8add;--lg-surface:var(--background-color-medium,#171e26);--lg-surface-muted:var(--background-color-high,#131a22);--lg-surface-alt:var(--background-color-low,#19212a);--lg-surface-hover:#202a34;--lg-control:var(--background-color-high,#111820);--lg-text:var(--text-color-high,#d5dbe3);--lg-text-muted:var(--text-color-medium,#9aa7b3);--lg-border:var(--border-color-medium,rgba(148,163,184,.18));--lg-border-soft:var(--border-color-low,rgba(148,163,184,.12));--lg-code-bg:var(--background-color-low,#253142);--lg-danger-bg:#42303a;--lg-danger-text:#efb2b0;--lg-shadow:none}
@media(prefers-color-scheme:dark){
:root:not([data-darkmode]) .lg-wrap{--lg-success:#78c98d;--lg-warning:#d7a64a;--lg-danger:#e58a84;--lg-muted:#9aa7b3;--lg-primary:#8e8add;--lg-surface:#171e26;--lg-surface-muted:#131a22;--lg-surface-alt:#19212a;--lg-surface-hover:#202a34;--lg-control:#111820;--lg-text:#d5dbe3;--lg-text-muted:#9aa7b3;--lg-border:rgba(148,163,184,.18);--lg-border-soft:rgba(148,163,184,.12);--lg-code-bg:#253142;--lg-danger-bg:#42303a;--lg-danger-text:#efb2b0;--lg-shadow:none}
}
@media(max-width:900px){.lg-summary{grid-template-columns:1fr}.lg-card{min-height:auto}.lg-input{width:100%;min-width:0}.lg-tools{width:100%}}
</style>
<div class="lg-wrap">
<div id="lg-summary" class="lg-summary">
<div class="lg-card" style="--accent:var(--lg-success)">
<div class="lg-card-title">服务状态</div>
<div id="lg-running" class="lg-card-value">--</div>
</div>
<div class="lg-card" style="--accent:var(--lg-danger)">
<div class="lg-card-title">当前已封禁</div>
<div id="lg-banned-count" class="lg-card-value" style="color:var(--lg-danger)">--</div>
</div>
<div class="lg-card" style="--accent:var(--lg-warning)">
<div class="lg-card-title">失败计数中</div>
<div id="lg-watching-count" class="lg-card-value" style="color:var(--lg-warning)">--</div>
</div>
</div>
<div class="lg-panel">
<div class="lg-panel-head">
<div class="lg-panel-title">已封禁 IP 列表</div>
<div class="lg-tools">
<label class="lg-limit-label">显示
<select id="lg-ban-limit" class="lg-select">
<option value="5">5 条</option>
<option value="20">20 条</option>
<option value="30">30 条</option>
</select>
</label>
<input id="lg-ban-input" class="lg-input" type="text" placeholder="输入 IP 手动封禁" />
<button class="lg-btn lg-btn-primary" onclick="lgManualBan()">封禁</button>
<button class="lg-btn lg-btn-danger" onclick="lgFlushAll()">清空全部</button>
</div>
</div>
<div id="lg-banned-list" class="lg-table-wrap"><div class="lg-empty">加载中...</div></div>
</div>
<div class="lg-panel">
<div class="lg-panel-head">
<div class="lg-panel-title">失败计数中(未达阈值)</div>
<div class="lg-tools">
<label class="lg-limit-label">显示
<select id="lg-watch-limit" class="lg-select">
<option value="5">5 条</option>
<option value="20">20 条</option>
<option value="30">30 条</option>
</select>
</label>
</div>
</div>
<div id="lg-watching-list" class="lg-table-wrap">]] .. initial_watching .. [[</div>
</div>
</div>
<script type="text/javascript">
var _lgGeoCache={};
var _lgGeoPending={};
var _lgBanLimit=5;
var _lgWatchLimit=5;
function lgEscHtml(s){
return String(s||'').replace(/[&<>"']/g,function(c){
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
function lgColor(name){
var root=document.querySelector('.lg-wrap');
if(!root)return'#999';
return getComputedStyle(root).getPropertyValue('--lg-'+name).trim()||'#999';
}
function lgQueryGeo(ip,cb){
if(_lgGeoCache[ip]){cb(_lgGeoCache[ip]);return;}
if(_lgGeoPending[ip]){
_lgGeoPending[ip].push(cb);
return;
}
_lgGeoPending[ip]=[cb];
XHR.get(']] .. gu .. [[',{ip:ip},function(x,d){
var loc=(d&&d.geo)?d.geo:'未知';
var list=_lgGeoPending[ip]||[];
delete _lgGeoPending[ip];
_lgGeoCache[ip]=loc;
for(var i=0;i<list.length;i++)list[i](loc);
});
}
function lgApplyInitialWatchingLimit(){
var rows=document.querySelectorAll('#lg-watching-list .lg-watch-row');
for(var i=0;i<rows.length;i++){
rows[i].style.display=(i<_lgWatchLimit)?'':'none';
}
}
function lgQueryInitialWatchingGeo(){
var rows=document.querySelectorAll('#lg-watching-list .lg-watch-row');
var qi=0;
function next(){
while(qi<rows.length && rows[qi].style.display=='none')qi++;
if(qi>=rows.length)return;
var row=rows[qi++];
var ip=row.getAttribute('data-ip')||'';
var idx=row.getAttribute('data-index')||'';
if(!ip){setTimeout(next,0);return;}
lgQueryGeo(ip,function(loc){
var ge=document.getElementById('lg-watch-geo-initial-'+idx);
if(ge)ge.innerHTML='<span style="font-size:11px">'+lgEscHtml(loc)+'</span>';
setTimeout(next,150);
});
}
next();
}
function fmtDuration(s){
if(s>=3600) return Math.floor(s/3600)+'h'+Math.floor((s%3600)/60)+'m';
if(s>=60) return Math.floor(s/60)+'m'+(s%60)+'s';
return s+'s';
}
function lgUnban(ip,btn){
if(!confirm('确认解封 '+ip+' ?'))return;
btn.disabled=true; btn.textContent='...';
XHR.get(']] .. uu .. [[',{ip:ip},function(x,d){
if(d&&d.success){lgRefresh();}
else{btn.disabled=false;btn.textContent='解封';alert('失败');}
});
}
function lgManualBan(){
var ip=document.getElementById('lg-ban-input').value.trim();
if(!ip){alert('请输入 IP');return;}
if(!ip.match(/^\d+\.\d+\.\d+\.\d+$/)){alert('IP 格式不对');return;}
XHR.get(']] .. bu .. [[',{ip:ip},function(x,d){
if(d&&d.success){
document.getElementById('lg-ban-input').value='';
lgRefresh();
}else alert('失败');
});
}
function lgFlushAll(){
if(!confirm('确认清空所有封禁?此操作不可撤销。'))return;
XHR.get(']] .. fu .. [[',null,function(x,d){
if(d&&d.success)lgRefresh();
});
}
function lgRenderWatching(watching,threshold){
var wEl=document.getElementById('lg-watching-list');
if(!wEl)return;
watching=watching||[];
threshold=Number(threshold)||3;
if(watching.length==0){
wEl.innerHTML='<div class="lg-empty">无</div>';
return;
}
var h2='<table class="lg-table">';
h2+='<thead><tr><th>IP 地址</th><th>归属地</th><th>失败次数</th><th>最近访问时间</th><th>距首次失败</th></tr></thead><tbody>';
for(var j=0;j<watching.length;j++){
var w=watching[j]||{};
var ip=w.ip||'--';
var count=Number(w.count)||0;
var age=Number(w.age)||0;
var lastSeen=w.last_seen||'--';
var pct=Math.min(100,Math.round(count*100/threshold));
var color=pct>=66?lgColor('danger'):(pct>=33?lgColor('warning'):lgColor('muted'));
h2+='<tr>';
h2+='<td><code class="lg-ip">'+lgEscHtml(ip)+'</code></td>';
h2+='<td id="lg-watch-geo-'+j+'"><span style="color:'+lgColor('muted')+'">查询中...</span></td>';
h2+='<td><span style="color:'+color+';font-weight:bold">'+count+' / '+threshold+'</span></td>';
h2+='<td>'+lgEscHtml(lastSeen)+'</td>';
h2+='<td>'+fmtDuration(age)+'</td>';
h2+='</tr>';
}
h2+='</tbody></table>';
wEl.innerHTML=h2;
// 与总览“访问记录”保持一致:逐个查询归属地,避免并发太多。
var qi=0;
function nextWatchingGeo(){
if(qi>=watching.length)return;
var idx=qi;qi++;
var row=watching[idx]||{};
if(!row.ip){
setTimeout(nextWatchingGeo,0);
return;
}
lgQueryGeo(row.ip,function(loc){
var ge=document.getElementById('lg-watch-geo-'+idx);
if(ge) ge.innerHTML='<span style="font-size:11px">'+lgEscHtml(loc)+'</span>';
setTimeout(nextWatchingGeo,150);
});
}
nextWatchingGeo();
}
function lgApplyStatus(d){
if(!d){
var wEl=document.getElementById('lg-watching-list');
if(wEl)wEl.innerHTML='<div class="lg-empty">刷新失败,请稍后再试</div>';
return;
}
// 服务状态
var rEl=document.getElementById('lg-running');
if(d.enabled=='1'){
if(d.running) rEl.innerHTML='<span style="color:'+lgColor('success')+'">✓ 运行中</span>';
else rEl.innerHTML='<span style="color:'+lgColor('danger')+'">✗ 未运行</span>';
}else{
rEl.innerHTML='<span style="color:'+lgColor('muted')+'">未启用</span>';
}
document.getElementById('lg-banned-count').textContent=(d.banned_total!=null)?d.banned_total:(d.banned||[]).length;
document.getElementById('lg-watching-count').textContent=(d.watching_total!=null)?d.watching_total:(d.watching||[]).length;
// 已封禁列表
var listEl=document.getElementById('lg-banned-list');
if(!d.banned||d.banned.length==0){
listEl.innerHTML='<div class="lg-empty">暂无封禁</div>';
}else{
var h='<table class="lg-table">';
h+='<thead><tr><th>IP 地址</th><th>归属地</th><th>剩余时间</th><th>操作</th></tr></thead><tbody>';
for(var i=0;i<d.banned.length;i++){
var b=d.banned[i];
h+='<tr>';
h+='<td><code class="lg-ip lg-ip-danger">'+lgEscHtml(b.ip)+'</code></td>';
h+='<td id="lg-geo-'+i+'"><span style="color:'+lgColor('muted')+'">查询中...</span></td>';
h+='<td>'+fmtDuration(b.remaining)+'</td>';
h+='<td><button class="lg-unban" onclick="lgUnban(\''+b.ip+'\',this)">解封</button></td>';
h+='</tr>';
}
h+='</tbody></table>';
listEl.innerHTML=h;
// 异步查归属地
var qi=0;
(function next(){
if(qi>=d.banned.length)return;
var idx=qi; qi++;
lgQueryGeo(d.banned[idx].ip,function(loc){
var ge=document.getElementById('lg-geo-'+idx);
if(ge) ge.innerHTML='<span style="font-size:11px">'+lgEscHtml(loc)+'</span>';
setTimeout(next,150);
});
})();
}
// 失败计数列表
try{lgRenderWatching(d.watching,d.threshold);}
catch(e){
var wEl=document.getElementById('lg-watching-list');
if(wEl)wEl.innerHTML='<div class="lg-empty">渲染失败:'+lgEscHtml(e.message)+'</div>';
}
}
function lgRefresh(){
XHR.get(']] .. lu .. [[',{ban_limit:_lgBanLimit,watch_limit:_lgWatchLimit},function(x,d){
lgApplyStatus(d);
});
}
var lgBanLimitSel=document.getElementById('lg-ban-limit');
if(lgBanLimitSel){
lgBanLimitSel.value=String(_lgBanLimit);
lgBanLimitSel.onchange=function(){
var v=parseInt(this.value,10);
_lgBanLimit=(v==20||v==30)?v:5;
lgRefresh();
};
}
var lgWatchLimitSel=document.getElementById('lg-watch-limit');
if(lgWatchLimitSel){
lgWatchLimitSel.value=String(_lgWatchLimit);
lgWatchLimitSel.onchange=function(){
var v=parseInt(this.value,10);
_lgWatchLimit=(v==20||v==30)?v:5;
lgApplyInitialWatchingLimit();
lgQueryInitialWatchingGeo();
lgRefresh();
};
}
lgApplyInitialWatchingLimit();
lgQueryInitialWatchingGeo();
lgRefresh();
setInterval(lgRefresh,8000);
</script>
]]
end
-- ============== 设置 ==============
s = m:section(NamedSection, "login_guard", "login_guard", "设置")
s.anonymous = true
o = s:option(Flag, "enabled", "启用登录防护")
o.description = "开启后将自动监控 SSH/LuCI 失败登录。LAN IP 永远不会被封禁。"
o.rmempty = false
o = s:option(Value, "threshold", "失败次数阈值")
o.description = "在「失败窗口」时间内累积达到此次数则封禁。"
o.datatype = "uinteger"
o.default = "3"
o.placeholder = "3"
o = s:option(ListValue, "window", "失败窗口(秒)")
o.description = "在多长时间内累计失败次数。超过此时间未再失败则计数清零。"
o:value("300", "5 分钟")
o:value("600", "10 分钟")
o:value("1800", "30 分钟")
o:value("3600", "1 小时")
o:value("86400", "24 小时")
o.default = "600"
o = s:option(ListValue, "bantime", "封禁时长")
o:value("3600", "1 小时")
o:value("21600", "6 小时")
o:value("43200", "12 小时")
o:value("86400", "24 小时")
o:value("604800", "1 周")
o:value("2592000", "30 天")
o.default = "43200"
o = s:option(DynamicList, "whitelist", "白名单(额外)")
o.description = "除了 LAN 私有段(192.168.x、10.x、127.x、172.16-31.x)默认豁免外,再加这些 IP/CIDR。" ..
"支持单 IP(如 1.2.3.4)或 /8 /16 /24 /32 子网。"
o.placeholder = "如 8.8.8.8 或 203.0.113.0/24"
return m
@@ -0,0 +1,174 @@
local m, s, o
local sys = require "luci.sys"
local uc = require "luci.model.uci".cursor()
m = Map("minigate", "MiniGate - 反向代理", "保存后自动生效。")
m.on_after_commit = function(self)
sys.call("sleep 1 && /bin/sh /usr/lib/minigate/proxy.sh reload >/dev/null 2>&1 &")
end
local ddns_domains = {}
local ddns_ok = {}
local wildcard_domains = {}
local normal_domains = {}
uc:foreach("minigate", "ddns", function(sec)
local d = sec.domain or ""
local st = sec.status or ""
if d ~= "" then
ddns_domains[#ddns_domains + 1] = d
if st == "ok" then ddns_ok[d] = true end
if d:match("^%*%.") then
wildcard_domains[#wildcard_domains + 1] = d
else
normal_domains[#normal_domains + 1] = d
end
end
end)
s = m:section(NamedSection, "global", "global", "HTTP 自动跳转",
"让每个已启用 HTTPS 的监听端口同时接受 HTTP;明文请求会以 308 跳转到同域名、同端口的 HTTPS。")
s.anonymous = true
o = s:option(Flag, "http_redirect", "启用 HTTP 跳转")
o.default = "0"
o.rmempty = false
-- ================================
-- 通配符域名
-- ================================
if #wildcard_domains > 0 then
s = m:section(TypedSection, "proxy_wildcard", "通配符域名",
"配置监听端口和HTTPS,具体转发由「子域名」控制。")
s.anonymous = false
s.addremove = true
s.template = "cbi/tblsection"
s.sectionhead = "名称"
o = s:option(ListValue, "enabled", "启用")
o:value("1", "✓ 启用")
o:value("0", "✗ 禁用")
o.default = "1"
o.rmempty = false
o = s:option(ListValue, "domain", "域名")
o:value("", "-- 请选择 --")
for _, d in ipairs(wildcard_domains) do
local label = d
if ddns_ok[d] then label = d .. " ✓" end
o:value(d, label)
end
o.rmempty = false
o = s:option(Value, "listen_port", "监听端口")
o.datatype = "port"
o.default = "2000"
o.rmempty = false
o = s:option(ListValue, "ssl", "HTTPS")
o:value("1", "✓ 开启")
o:value("0", "✗ 关闭")
o.default = "1"
o.rmempty = false
-- ================================
-- 子域名规则
-- ================================
s = m:section(TypedSection, "subproxy", "子域名规则")
s.anonymous = true
s.addremove = true
s.sortable = true
s.template = "cbi/tblsection"
o = s:option(ListValue, "parent_domain", "主域名")
o:value("", "--")
for _, d in ipairs(wildcard_domains) do
local base = d:gsub("^%*%.", "")
o:value(base, d)
end
o.rmempty = false
o = s:option(Value, "prefix", "前缀")
o.rmempty = false
o.placeholder = "app"
o = s:option(Value, "target_addr", "目标地址")
o.rmempty = false
o.placeholder = "192.168.1.100"
o = s:option(Value, "target_port", "端口")
o.datatype = "port"
o.default = "80"
o.rmempty = false
o = s:option(DummyValue, "_final", "实际域名")
o.rawhtml = true
o.cfgvalue = function(self, section)
local base = m.uci:get("minigate", section, "parent_domain") or ""
local prefix = m.uci:get("minigate", section, "prefix") or ""
if prefix ~= "" and base ~= "" then
return '<strong style="color:#4caf50">' .. prefix .. '.' .. base .. '</strong>'
end
return '<span style="color:#999">--</span>'
end
end -- wildcard_domains
-- ================================
-- 普通域名代理
-- ================================
if #normal_domains > 0 or #wildcard_domains == 0 then
s = m:section(TypedSection, "proxy", "代理规则",
"非通配符域名的反向代理。")
s.anonymous = false
s.addremove = true
s.template = "cbi/tblsection"
s.sortable = true
s.sectionhead = "名称"
o = s:option(ListValue, "enabled", "启用")
o:value("1", "✓ 启用")
o:value("0", "✗ 禁用")
o.default = "1"
o.rmempty = false
o = s:option(ListValue, "domain", "域名")
o:value("", "-- 请选择 --")
for _, d in ipairs(normal_domains) do
local label = d
if ddns_ok[d] then label = d .. " ✓" end
o:value(d, label)
end
o.rmempty = false
o = s:option(Value, "listen_port", "端口")
o.datatype = "port"
o.default = "443"
o.rmempty = false
o = s:option(Value, "target_addr", "目标地址")
o.rmempty = false
o.placeholder = "192.168.1.100"
o = s:option(Value, "target_port", "目标端口")
o.datatype = "port"
o.default = "80"
o.rmempty = false
o = s:option(ListValue, "ssl", "HTTPS")
o:value("1", "✓ 开启")
o:value("0", "✗ 关闭")
o.default = "1"
o.rmempty = false
o = s:option(ListValue, "websocket", "WS")
o:value("0", "✗ 关闭")
o:value("1", "✓ 开启")
o.default = "0"
o.rmempty = false
end -- normal_domains
return m
@@ -0,0 +1,185 @@
<% local history_url = luci.dispatcher.build_url("admin", "services", "minigate", "ddns_history") %>
<style>
.mg-ddns-history{--mh-surface:#fff;--mh-surface-muted:#f6f8fb;--mh-surface-alt:#f8fafc;--mh-hover:#eef4ff;--mh-control:#fff;--mh-text:#1f2937;--mh-muted:#5f6b7a;--mh-border:#d7dce3;--mh-border-soft:#e6eaf0;--mh-code:#e8edf6;--mh-info:#1971c2;--mh-success:#2f9e44;margin-top:18px;padding:0!important;border:1px solid var(--mh-border);border-radius:8px;background:var(--mh-surface);overflow:hidden}
.mg-history-head{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:14px 16px;border-bottom:1px solid var(--mh-border)}
.mg-history-title{margin:0;color:var(--mh-text);font-size:14px;font-weight:700;line-height:1.4}
.mg-history-controls{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.mg-history-label{color:var(--mh-muted);font-size:12px;white-space:nowrap}
.mg-history-select{height:32px;max-width:220px;border:1px solid var(--mh-border);border-radius:6px;background:var(--mh-control);color:var(--mh-text);padding:0 28px 0 9px;font-size:12px}
.mg-history-refresh{min-width:64px;height:32px;border-radius:6px!important;font-size:12px!important;cursor:pointer}
.mg-history-refresh:disabled{cursor:wait;opacity:.65}
.mg-history-summary{padding:9px 16px;border-bottom:1px solid var(--mh-border-soft);background:var(--mh-surface-muted);color:var(--mh-muted);font-size:12px}
.mg-history-table-wrap{overflow-x:auto}
.mg-history-table{width:100%;min-width:820px;border-collapse:collapse}
.mg-history-table th{padding:10px 12px;border-bottom:1px solid var(--mh-border);background:var(--mh-surface-muted);color:var(--mh-muted);font-size:11px;font-weight:600;text-align:left;white-space:nowrap}
.mg-history-table td{padding:11px 12px;border-bottom:1px solid var(--mh-border-soft);color:var(--mh-text);font-size:12px;vertical-align:middle}
.mg-history-table tbody tr:nth-child(even) td{background:var(--mh-surface-alt)}
.mg-history-table tbody tr:hover td{background:var(--mh-hover)}
.mg-history-table tbody tr:last-child td{border-bottom:0}
.mg-history-domain{font-weight:600;word-break:break-all}
.mg-history-family{display:inline-flex;align-items:center;min-width:42px;padding:2px 7px;border-radius:999px;background:rgba(25,113,194,.12);color:var(--mh-info);font-size:11px;font-weight:700;justify-content:center}
.mg-history-family.ipv6{background:rgba(47,158,68,.12);color:var(--mh-success)}
.mg-history-ip{display:inline-block;padding:3px 7px;border-radius:6px;background:var(--mh-code);color:var(--mh-text);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;white-space:nowrap}
.mg-history-active{color:var(--mh-success);font-weight:600;white-space:nowrap}
.mg-history-empty{padding:24px 16px;color:var(--mh-muted);font-size:12px;text-align:center}
:root[data-darkmode="true"] .mg-ddns-history{--mh-surface:var(--background-color-medium,#171e26);--mh-surface-muted:var(--background-color-high,#131a22);--mh-surface-alt:var(--background-color-low,#19212a);--mh-hover:#202a34;--mh-control:var(--background-color-high,#111820);--mh-text:var(--text-color-high,#d5dbe3);--mh-muted:var(--text-color-medium,#9aa7b3);--mh-border:var(--border-color-medium,rgba(148,163,184,.18));--mh-border-soft:var(--border-color-low,rgba(148,163,184,.12));--mh-code:#253142;--mh-info:#7db7df;--mh-success:#78c98d}
@media(prefers-color-scheme:dark){
:root:not([data-darkmode]) .mg-ddns-history{--mh-surface:#171e26;--mh-surface-muted:#131a22;--mh-surface-alt:#19212a;--mh-hover:#202a34;--mh-control:#111820;--mh-text:#d5dbe3;--mh-muted:#9aa7b3;--mh-border:rgba(148,163,184,.18);--mh-border-soft:rgba(148,163,184,.12);--mh-code:#253142;--mh-info:#7db7df;--mh-success:#78c98d}
}
@media(max-width:640px){
.mg-history-head{align-items:flex-start;flex-direction:column}
.mg-history-controls{width:100%}
.mg-history-select{min-width:0;max-width:none;flex:1}
.mg-history-table-wrap{overflow:visible}
.mg-history-table{min-width:0}
.mg-history-table thead{display:none}
.mg-history-table tbody tr{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:5px 12px;padding:12px 14px;border-bottom:1px solid var(--mh-border-soft)}
.mg-history-table tbody tr:nth-child(even){background:var(--mh-surface-alt)}
.mg-history-table tbody tr:last-child{border-bottom:0}
.mg-history-table td{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:2px 0;border:0!important;background:transparent!important;min-width:0}
.mg-history-table td:nth-child(1){justify-content:flex-start;font-size:13px}
.mg-history-table td:nth-child(2){justify-content:flex-end}
.mg-history-table td:nth-child(n+3){grid-column:1/-1}
.mg-history-table td:nth-child(3){justify-content:flex-start;margin:2px 0 4px}
.mg-history-table td:nth-child(4):before{content:"开始时间";color:var(--mh-muted)}
.mg-history-table td:nth-child(5):before{content:"结束时间";color:var(--mh-muted)}
.mg-history-table td:nth-child(6):before{content:"持续时间";color:var(--mh-muted)}
.mg-history-ip{max-width:100%;white-space:normal;overflow-wrap:anywhere}
}
</style>
<div class="cbi-section mg-ddns-history" id="mg-ddns-history">
<div class="mg-history-head">
<h3 class="mg-history-title">最近一天 IP 变化明细</h3>
<div class="mg-history-controls">
<label class="mg-history-label" for="mg-history-filter">DDNS 记录</label>
<select class="mg-history-select" id="mg-history-filter">
<option value="">全部记录</option>
</select>
<button class="cbi-button cbi-button-neutral mg-history-refresh" id="mg-history-refresh" type="button">刷新</button>
</div>
</div>
<div class="mg-history-summary" id="mg-history-summary" aria-live="polite">正在读取...</div>
<div class="mg-history-table-wrap" id="mg-history-table-wrap" hidden>
<table class="mg-history-table" aria-label="最近一天 IP 变化明细">
<thead>
<tr>
<th>DDNS 记录</th>
<th>类型</th>
<th>IP 地址</th>
<th>开始时间</th>
<th>结束时间</th>
<th>持续时间</th>
</tr>
</thead>
<tbody id="mg-history-body"></tbody>
</table>
</div>
<div class="mg-history-empty" id="mg-history-empty" hidden>暂无 IP 变化记录</div>
</div>
<script type="text/javascript">
(function(){
'use strict';
var endpoint='<%=history_url%>';
var records=[];
var filter=document.getElementById('mg-history-filter');
var refresh=document.getElementById('mg-history-refresh');
var summary=document.getElementById('mg-history-summary');
var tableWrap=document.getElementById('mg-history-table-wrap');
var body=document.getElementById('mg-history-body');
var empty=document.getElementById('mg-history-empty');
function asArray(value){
if(Array.isArray(value))return value;
if(value&&typeof value==='object'&&Object.keys(value).length===0)return [];
return null;
}
function formatTime(timestamp){
if(!timestamp)return '-';
return new Date(timestamp*1000).toLocaleString('zh-CN',{hour12:false});
}
function formatDuration(seconds){
seconds=Math.max(0,Number(seconds)||0);
var days=Math.floor(seconds/86400);
var hours=Math.floor((seconds%86400)/3600);
var minutes=Math.floor((seconds%3600)/60);
if(days>0)return days+'天 '+hours+'小时';
if(hours>0)return hours+'小时 '+minutes+'分钟';
if(minutes>0)return minutes+'分钟';
return Math.floor(seconds)+'秒';
}
function cell(row,text,className){
var td=document.createElement('td');
var value=document.createElement(className==='mg-history-ip'?'code':'span');
if(className)value.className=className;
value.textContent=text;
td.appendChild(value);
row.appendChild(td);
}
function render(){
var selected=filter.value;
var visible=records.filter(function(item){return !selected||item.section===selected});
body.textContent='';
visible.forEach(function(item){
var row=document.createElement('tr');
cell(row,item.domain,'mg-history-domain');
cell(row,item.family==='ipv6'?'IPv6':'IPv4','mg-history-family '+(item.family==='ipv6'?'ipv6':''));
cell(row,item.ip,'mg-history-ip');
cell(row,formatTime(item.start_time),'');
cell(row,item.active?'使用中':formatTime(item.end_time),item.active?'mg-history-active':'');
cell(row,formatDuration(item.duration),'');
body.appendChild(row);
});
summary.textContent='最近 24 小时 · '+visible.length+' 段 IP 记录';
tableWrap.hidden=visible.length===0;
empty.hidden=visible.length!==0;
}
function setSections(sections){
var selected=filter.value;
while(filter.options.length>1)filter.remove(1);
(sections||[]).forEach(function(section){
if(!section.enabled)return;
var option=document.createElement('option');
option.value=section.name;
option.textContent=section.domain;
filter.appendChild(option);
});
filter.value=selected;
}
function loadHistory(){
refresh.disabled=true;
refresh.textContent='读取中';
summary.textContent='正在读取...';
XHR.get(endpoint,null,function(xhr,data){
var nextRecords=asArray(data&&data.records);
var nextSections=asArray(data&&data.sections);
refresh.disabled=false;
refresh.textContent='刷新';
if(!data||data.success!==true||nextRecords===null){
records=[];
summary.textContent='读取失败,请稍后重试';
tableWrap.hidden=true;
empty.hidden=false;
empty.textContent='无法读取 IP 变化明细';
return;
}
empty.textContent='暂无 IP 变化记录';
records=nextRecords;
setSections(nextSections||[]);
render();
});
}
filter.addEventListener('change',render);
refresh.addEventListener('click',loadHistory);
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',loadHistory);
else loadHistory();
})();
</script>
+21
View File
@@ -0,0 +1,21 @@
<%+header%>
<h2>MiniGate - 日志</h2>
<div style="margin-bottom:15px">
<select id="ls" style="padding:5px 10px;border-radius:4px;border:1px solid #ccc">
<option value="all">全部</option><option value="ddns">DDNS</option><option value="acme">ACME</option><option value="proxy">代理</option>
</select>
<button class="cbi-button cbi-button-action" onclick="rl()" style="margin-left:10px">刷新</button>
<label style="margin-left:15px"><input type="checkbox" id="ar" checked> 自动刷新(5秒)</label>
</div>
<pre id="lc" style="background:#1e1e2e;color:#cdd6f4;font-family:monospace;font-size:12px;line-height:1.5;padding:15px;border-radius:8px;max-height:600px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;margin:0">加载中...</pre>
<script type="text/javascript">
var lt;
var lu='<%=luci.dispatcher.build_url("admin/services/minigate/get_log")%>';
function rl(){var s=document.getElementById('ls').value;var c=document.getElementById('lc');
XHR.get(lu,{source:s},function(x,d){if(d&&d.log){c.textContent=d.log||'(暂无)';c.scrollTop=c.scrollHeight}else c.textContent='(无法读取)'})}
function sa(){if(lt)clearInterval(lt);if(document.getElementById('ar').checked)lt=setInterval(rl,5000)}
document.getElementById('ar').addEventListener('change',sa);
document.getElementById('ls').addEventListener('change',rl);
rl();sa();
</script>
<%+footer%>
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
# Convert PO files to LMO for LuCI
# Usage: ./po2lmo.sh
# Run this on a system with luci-base installed, or on the OpenWrt build host
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PO_DIR="${SCRIPT_DIR}/po"
I18N_DIR="${SCRIPT_DIR}/root/usr/lib/lua/luci/i18n"
mkdir -p "$I18N_DIR"
# Method 1: Use po2lmo if available (OpenWrt build system)
if command -v po2lmo >/dev/null 2>&1; then
for lang_dir in "$PO_DIR"/*/; do
lang=$(basename "$lang_dir")
for po_file in "$lang_dir"/*.po; do
base=$(basename "$po_file" .po)
echo "Converting: ${lang}/${base}.po -> ${base}.${lang}.lmo"
po2lmo "$po_file" "${I18N_DIR}/${base}.${lang}.lmo"
done
done
echo "Done! LMO files generated in $I18N_DIR"
exit 0
fi
# Method 2: Direct install on router (copy PO and let LuCI handle it)
echo "po2lmo not found. Falling back to direct PO installation."
echo ""
echo "To install translations on the router, run:"
echo " scp po/zh-cn/minigate.po root@<router>:/tmp/"
echo " ssh root@<router>"
echo " mkdir -p /usr/lib/lua/luci/i18n"
echo " po2lmo /tmp/minigate.po /usr/lib/lua/luci/i18n/minigate.zh-cn.lmo"
echo " rm -rf /tmp/luci-*"
echo ""
echo "Or if building with the OpenWrt SDK, the Makefile handles this automatically."
+18
View File
@@ -0,0 +1,18 @@
## 更新内容
- 修复子域名反向代理规则的 WebSocket 开关未生效问题。
- 子域名规则现在会正确把 LuCI 中的「WS」配置传入 Nginx 站点生成逻辑。
- 修复后,Open-Box 等依赖 WebSocket 的动态连接、日志、内存和流量页面可通过 MiniGate 外网穿透正常建立动态通道。
## 验证
- `root/usr/lib/minigate/proxy.sh` 语法检查通过。
- 已在软路由实机验证:开启 `box` 子域规则 WebSocket 后,生成的 Nginx 站点配置包含 `Upgrade` 与 `Connection "upgrade"`。
- 普通公网 API 返回 200,未登录 WebSocket 请求返回 401,确认请求已进入上游认证层而非被反代层返回 404。
## 安装与兼容性
- opkg 安装包:`minigate_2026.9.14-1_all.ipk`。
- 源码安装包:`minigate-v2026.9.14-1-src.tar.gz`,适用于依赖和固件匹配的 opkg / apk 系统。
- 继续附带 `luci-app-minigate-v2026.9.14-1-src.tar.gz` 兼容旧更新器识别的源码包名。
- 本次只修复 MiniGate 反代生成逻辑,不改变现有配置文件格式。
+22
View File
@@ -0,0 +1,22 @@
## 更新内容
- 修复完成一次一键更新后,后续点击「检查更新」没有反应、只显示旧状态的问题。
- 更新流程现在会在成功、失败或回滚返回后显式释放更新锁。
- 安装与回滚启动 MiniGate、Nginx、登录防护和 LuCI 服务时会关闭更新锁文件描述符,避免长驻进程继承锁。
## 根因
旧版更新器持有 fd 9 执行安装脚本。安装脚本启动 Nginx 和登录防护日志监听进程后,这些长驻进程继承了更新锁;更新脚本退出后锁仍然存在,后续检查只能返回上一次保存的状态。
## 验证
- 更新器回归测试覆盖统一解锁和长驻服务文件描述符隔离。
- Shell 语法、版本一致性和安装包内容在发布前检查。
- 软路由部署后连续执行两次检查更新,并核对更新锁、MiniGate 服务、Nginx 配置和外网反向代理。
## 安装与兼容性
- opkg 安装包:`minigate_2026.9.14-2_all.ipk`。
- 源码安装包:`minigate-v2026.9.14-2-src.tar.gz`,适用于依赖和固件匹配的 opkg / apk 系统。
- 继续附带 `luci-app-minigate-v2026.9.14-2-src.tar.gz` 兼容旧更新器识别的源码包名。
- 本次不改变现有 MiniGate 配置格式。
+19
View File
@@ -0,0 +1,19 @@
## 更新内容
- 新增适合 SSH 登录软路由后直接执行的短安装命令:
```sh
curl -fsSL https://raw.githubusercontent.com/tpxcer/minigate/main/scripts/install.sh | sh
```
- 在线安装脚本自动获取 GitHub latest Release,不需要手工填写版本号或源码包文件名。
- 安装前后分别获取 `SHA256SUMS`,避免下载过程中恰好切换版本而混用文件。
- 安装前验证源码包 SHA-256、压缩包路径和三处版本信息,通过后才调用正式安装器。
- 保留 README 中的固定版本安装方式,便于指定版本和手工复核。
## 安装与兼容性
- opkg 安装包:`minigate_2026.9.14-3_all.ipk`。
- 源码安装包:`minigate-v2026.9.14-3-src.tar.gz`。
- 继续附带 `luci-app-minigate-v2026.9.14-3-src.tar.gz`,兼容旧更新器识别的源码包名。
- 本次只更新 GitHub 项目,不自动安装到软路由。
+21
View File
@@ -0,0 +1,21 @@
## 更新内容
- 在 minigate 总览标题右侧新增「卸载」按钮。
- 点击后显示二次确认弹窗,明确提示 DDNS、证书续期、反向代理和登录防护会立即停止。
- 默认保留 `/etc/config/minigate`、证书、封禁记录和日志,方便以后重新安装。
- 用户主动勾选后,可同时彻底删除配置、证书、封禁记录和日志。
- 卸载脚本兼容源码安装、opkg 和 apk,并清理 minigate 的 cron、运行进程、防火墙状态、临时文件和 LuCI 缓存。
- 按钮与确认弹窗适配深浅色主题和移动端布局,取消、Esc 或点击遮罩均不会执行卸载。
## 安全边界
- LuCI 卸载接口只接受 POST 请求,并校验固定确认值和 `0/1` 数据清理选项。
- 卸载脚本先复制到随机临时文件再后台执行,避免删除程序目录后中断自身。
- 卸载会中断由 minigate 提供的公网入口,建议从 LAN 或 SSH 登录 LuCI 后操作。
## 安装与兼容性
- opkg 安装包:`minigate_2026.9.14-4_all.ipk`。
- 源码安装包:`minigate-v2026.9.14-4-src.tar.gz`。
- 继续附带 `luci-app-minigate-v2026.9.14-4-src.tar.gz`,兼容旧更新器识别的源码包名。
- 本次本地实现不会自动安装到软路由,也不会自动卸载现有实例。
+17
View File
@@ -0,0 +1,17 @@
## 更新内容
- 项目名称和 OpenWrt 内部包名统一为 `minigate`,GitHub 仓库改为 `tpxcer/minigate`。
- 版本号按北京时间(Asia/Shanghai)发布日期命名:首版为 `2026.9.14`,同日后续版本依次为 `2026.9.14-1`、`2026.9.14-2`。
- 登录 LuCI 后打开 minigate 总览,自动检查 GitHub 最新正式版。自动检查只提示,不下载或安装;10 分钟内复用成功检查结果,手动检查不受此限制。
- 点击「一键更新」先显示本次 Release 的更新内容,点击「确认更新」才安装。期间若最新版本改变,会要求重新查看并确认。
- 更新说明弹窗跟随深浅主题,支持滚动长说明、键盘切换、Esc 关闭和取消。
- 修复更新成功状态可能导致页面反复刷新的问题;保留 SHA-256 校验、配置备份、服务验证与失败回滚。
## 安装与兼容性
- opkg 安装包:`minigate_2026.9.14_all.ipk`。
- 源码安装包:`minigate-v2026.9.14-src.tar.gz`,适用于依赖和固件匹配的 opkg / apk 系统。
- 为兼容 `v1.3.11` 的更新器,本次另附内容相同的 `luci-app-minigate-v2026.9.14-src.tar.gz`。旧名称只用于兼容,不是新内部包名。
- `v1.3.11` 用户升级到本版后,才具备自动检查和更新说明弹窗;旧版不能识别带 `-N` 后缀的日期版,建议先升级本版,或按 README 手动安装。
- 源码/网页更新不修改包管理器数据库。原来通过旧名 IPK/APK 安装的用户,请按 README 先备份并迁移包名,不要混用两种包名。
- 本次仅发布 GitHub 更新,不自动安装到任何软路由。
+18
View File
@@ -0,0 +1,18 @@
## 修复内容
- 修复从 `v2026.9.14-4` 一键更新时可能显示「安装失败,正在恢复原版本」的问题。
- 当 OpenSSL CLI 已可正常执行、但定制固件的包数据库没有 `openssl-util` 安装记录时,源码安装器不再重复调用包管理器安装。
- OpenSSL CLI 确实缺失时,仍会通过当前系统的 opkg 或 apk 安装 `openssl-util`。
## 原因
- `v2026.9.18` 为修复 ACME 依赖,把 `openssl-util` 加入源码安装依赖。
- 部分定制固件可能已经通过可信包内容部署 `/usr/bin/openssl`,但没有对应的 apk 安装记录。
- 旧检测只看包数据库,会继续运行 `apk add openssl-util`;软件源与固件不匹配时安装失败,MiniGate 更新器会按设计自动回滚。
## 安装与兼容性
- opkg 安装包:`minigate_2026.9.18-1_all.ipk`。
- 源码安装包:`minigate-v2026.9.18-1-src.tar.gz`。
- 继续附带 `luci-app-minigate-v2026.9.18-1-src.tar.gz`,兼容旧更新器识别的源码包名。
- 本次修复不会自动安装到软路由;请在 LuCI 中重新检查更新后手动更新。
+18
View File
@@ -0,0 +1,18 @@
## 修复内容
- 修复动态 DNS 正常运行,但「最近一天 IP 变化明细」显示读取失败、无法显示记录的问题。
- DDNS 写入端和 LuCI 查询端统一使用域名生成历史文件名,不再依赖两端表示不同的 UCI 匿名节名称。
- 接口成功但暂时没有 IP 变化记录时,页面现在显示「暂无 IP 变化记录」,不再误报读取失败。
## 原因
- DDNS 脚本枚举匿名配置节时得到 `@ddns[0]`,LuCI 查询接口得到的是 UCI 内部 `cfg...` 名称,导致写入和读取的文件路径不一致。
- 空 Lua 表可能被 JSON 编码为 `{}`,旧前端只接受数组,因而把正常空结果当成接口错误。
## 安装与兼容性
- opkg 安装包:`minigate_2026.9.18-2_all.ipk`。
- 源码安装包:`minigate-v2026.9.18-2-src.tar.gz`。
- 继续附带 `luci-app-minigate-v2026.9.18-2-src.tar.gz`,兼容旧更新器识别的源码包名。
- 旧版本生成的历史文件会保留,不会自动删除;更新后 MiniGate 启动时会立即执行一次 DDNS,同步生成新格式记录。
- 本次修复不会自动安装到软路由;请在 LuCI 中手动更新后终验。
+21
View File
@@ -0,0 +1,21 @@
## 更新内容
- 动态 DNS 页面新增「最近一天 IP 变化明细」。
- 按 DDNS 记录分别展示 IPv4 / IPv6 地址、开始时间、结束时间和持续时长。
- 当前仍在使用的 IP 标记为「使用中」,支持筛选单条 DDNS 记录和手动刷新。
- 桌面端使用紧凑表格,手机端改为纵向明细,优先显示 IP 地址和持续时间。
- 修复源码安装漏装 `openssl-util`,导致 ACME 签发和续期缺少 OpenSSL CLI 的问题。
## 存储与性能
- 历史保存在 `/etc/minigate/ddns-history`,每条记录文件权限为 `0600`。
- 只有检测到 IP 真正变化时才写入,稳定轮询不会触碰历史文件。
- 保留最近 24 小时事件和窗口前最后一条锚点,可正确计算跨越查询边界的持续区间。
- 普通卸载继续保留历史数据;只有主动选择彻底清理时才会删除。
## 安装与兼容性
- opkg 安装包:`minigate_2026.9.18_all.ipk`。
- 源码安装包:`minigate-v2026.9.18-src.tar.gz`。
- 继续附带 `luci-app-minigate-v2026.9.18-src.tar.gz`,兼容旧更新器识别的源码包名。
- 本次发布不会自动安装到软路由。
+16
View File
@@ -0,0 +1,16 @@
config global 'global'
option enabled '0'
option ipv6_listen '0'
option http_redirect '0'
config acme 'acme'
option enabled '0'
option email ''
option staging '1'
option key_type 'ec-256'
config login_guard 'login_guard'
option enabled '0'
option threshold '3'
option window '600'
option bantime '43200'
+79
View File
@@ -0,0 +1,79 @@
#!/bin/sh /etc/rc.common
START=99
STOP=10
# === 启动登录防护 watcher(独立子进程) ===
start_login_guard() {
local lg_en=$(uci -q get minigate.login_guard.enabled)
[ "$lg_en" = "1" ] || return 0
# 已在跑则跳过
pgrep -f '/usr/lib/minigate/login_guard.sh run' >/dev/null 2>&1 && return 0
/bin/sh /usr/lib/minigate/login_guard.sh run >/dev/null 2>&1 &
logger -t minigate "登录防护已启动"
}
stop_login_guard() {
local pids
pids=$(pgrep -f '/usr/lib/minigate/login_guard.sh run' 2>/dev/null)
[ -n "$pids" ] && kill $pids >/dev/null 2>&1
sleep 1
pids=$(pgrep -f '/usr/lib/minigate/login_guard.sh 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 "登录防护已停止"
}
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
/etc/init.d/cron restart 2>/dev/null
logger -t minigate "已启动"
}
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续期/反代/登录防护 全部关闭)"
}
restart() {
stop
sleep 1
start
}
reload() {
/bin/sh /usr/lib/minigate/proxy.sh reload
/bin/sh /usr/lib/minigate/ddns.sh force
# 登录防护配置改了也需要重启 watcher
stop_login_guard
start_login_guard
}
+157
View File
@@ -0,0 +1,157 @@
#!/bin/sh
ACME_HOME="/etc/minigate/acme/data"
ACME_BIN="${ACME_HOME}/acme.sh"
CERT_DIR="/etc/minigate/certs"
LOGFILE="/var/log/minigate-acme.log"
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [ACME] $*" >> "$LOGFILE"; }
safe_dir() { echo "$1" | sed 's/\*/_wildcard_/g'; }
cert_path_for_domain() {
echo "${CERT_DIR}/$(safe_dir "$1")/fullchain.pem"
}
cert_expiry() {
openssl x509 -in "$1" -noout -enddate 2>/dev/null | cut -d= -f2
}
cert_is_valid() {
local cert="$1"
[ -f "$cert" ] && openssl x509 -checkend 0 -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
[ -f /usr/lib/minigate/proxy.sh ] && /bin/sh /usr/lib/minigate/proxy.sh reload 2>/dev/null
return 0
fi
return 1
}
find_token_for_domain() {
local target="$1"
local sections=$(uci -q show minigate | grep '=ddns$' | cut -d. -f2 | cut -d= -f1)
for sec in $sections; do
local d=$(uci -q get minigate.${sec}.domain)
if [ "$d" = "$target" ]; then
echo "$(uci -q get minigate.${sec}.cf_api_token)|$(uci -q get minigate.${sec}.cf_zone_id)"
return 0
fi
done
local parent=$(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
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
}
do_install() {
if [ -x "$ACME_BIN" ] && [ -d "${ACME_HOME}/dnsapi" ]; then
log "acme.sh 已安装"; return 0
fi
log "下载 acme.sh ..."
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
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
else
log "安装失败"; return 1
fi
}
do_issue() {
local domain="$1"
local email=$(uci -q get minigate.acme.email)
local staging=$(uci -q get minigate.acme.staging)
local key_type=$(uci -q get minigate.acme.key_type)
if [ -z "$domain" ]; then
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; }
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; }
local safe=$(safe_dir "$domain")
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
return 1
fi
export CF_Token="$token"
[ -n "$zone_id" ] && export CF_Zone_ID="$zone_id"
local kl="ec-256"
case "$key_type" in ec-256|ec-384) kl="$key_type";; rsa-2048) kl="2048";; rsa-4096) kl="4096";; esac
local sf="--server letsencrypt"
[ "$staging" = "1" ] && sf="--staging --server letsencrypt"
log "签发: $domain (staging=$staging)"
uci -q set minigate.acme.status="issuing"; uci commit minigate
"$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
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 echo "$domain" | grep -q '^\*\.'; then
local base=$(echo "$domain" | sed 's/^\*\.//')
[ ! -e "${CERT_DIR}/${base}" ] && ln -sf "${safe}" "${CERT_DIR}/${base}"
fi
[ -f /usr/lib/minigate/proxy.sh ] && /bin/sh /usr/lib/minigate/proxy.sh reload 2>/dev/null
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
fi
}
do_renew() { do_install || return 1; log "续期..."; "$ACME_BIN" --home "$ACME_HOME" --renew-all --server letsencrypt >> "$LOGFILE" 2>&1; log "完成"; }
case "${1:-issue}" in install) do_install;; issue) do_issue "$2";; renew) do_renew;; *) echo "用法: $0 {install|issue [域名]|renew}";; esac
+353
View File
@@ -0,0 +1,353 @@
#!/bin/sh
LOGFILE="/var/log/minigate-ddns.log"
HISTORY_DIR="${MINIGATE_HISTORY_DIR:-/etc/minigate/ddns-history}"
HISTORY_WINDOW="${MINIGATE_HISTORY_WINDOW:-86400}"
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"; }; }
history_now() {
if [ -n "${MINIGATE_HISTORY_NOW:-}" ]; then
printf '%s\n' "$MINIGATE_HISTORY_NOW"
else
date +%s
fi
}
compact_history_file() {
local file="$1" now="$2" cutoff tmp
cutoff=$((now - HISTORY_WINDOW))
tmp="${file}.tmp.$$"
awk -F '\t' -v cutoff="$cutoff" '
$1 ~ /^[0-9]+$/ && NF >= 2 {
if ($1 < cutoff) {
anchor = $0
next
}
if (!emitted && anchor != "") {
print anchor
emitted = 1
}
print
emitted = 1
}
END {
if (!emitted && anchor != "") print anchor
}
' "$file" > "$tmp" && chmod 600 "$tmp" 2>/dev/null && mv "$tmp" "$file"
}
record_ip_history_locked() {
local file="$1" ip="$2" now="$3" last_ip
if [ -f "$file" ]; then
last_ip=$(awk -F '\t' 'NF >= 2 { value=$2 } END { print value }' "$file" 2>/dev/null)
else
last_ip=""
fi
[ "$last_ip" = "$ip" ] && return 0
printf '%s\t%s\n' "$now" "$ip" >> "$file" || return 1
chmod 600 "$file" 2>/dev/null || true
compact_history_file "$file" "$now"
}
record_ip_history() {
local section="$1" family="$2" ip="$3" safe_section file now last_ip
case "$family" in ipv4|ipv6) ;; *) return 1 ;; esac
[ -n "$ip" ] || return 1
safe_section=$(printf '%s' "$section" | tr -c 'A-Za-z0-9_-' '_')
[ -n "$safe_section" ] || return 1
if [ ! -d "$HISTORY_DIR" ]; then
mkdir -p "$HISTORY_DIR" || return 1
chmod 700 "$HISTORY_DIR" 2>/dev/null || true
fi
file="${HISTORY_DIR}/${safe_section}.${family}.tsv"
if [ -f "$file" ]; then
last_ip=$(awk -F '\t' 'NF >= 2 { value=$2 } END { print value }' "$file" 2>/dev/null)
[ "$last_ip" = "$ip" ] && return 0
fi
now=$(history_now)
case "$now" in ''|*[!0-9]*) return 1 ;; esac
if command -v flock >/dev/null 2>&1; then
(
umask 077
exec 8>"${file}.lock"
flock -x 8 || exit 1
record_ip_history_locked "$file" "$ip" "$now"
)
else
record_ip_history_locked "$file" "$ip" "$now"
fi
}
# ====== IPv4 获取 ======
get_ip4_iface() {
local ip=""
ip=$(ubus call network.interface.${1:-wan} status 2>/dev/null | jsonfilter -e '@["ipv4-address"][0].address' 2>/dev/null)
[ -z "$ip" ] && ip=$(ifstatus "${1:-wan}" 2>/dev/null | jsonfilter -e '@["ipv4-address"][0].address' 2>/dev/null)
echo "$ip" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'
}
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 "$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 -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; }
fi
done
echo ""
}
# ====== 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
}
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 ':')
# 先尝试直连
ip=$(curl -6 -s --connect-timeout 5 "$url" 2>/dev/null | grep -oE '([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}' | head -1)
# 如果失败,DNS 解析后直连绕过代理
if [ -z "$ip" ]; then
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
local scheme="http"
echo "$url" | grep -q '^https' && scheme="https"
[ -z "$port" ] && { [ "$scheme" = "https" ] && port="443" || port="80"; }
ip=$(curl -6 -s --connect-timeout 5 --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"
}
# ====== Cloudflare API:支持 A 和 AAAA 记录 ======
cf_update() {
local zone_id="$1" token="$2" domain="$3" ip="$4" record_type="$5"
# record_type: A 或 AAAA
[ -z "$record_type" ] && record_type="A"
local resp match_resp rids rid keep_id result
resp=$(curl -s --connect-timeout 10 -H "Authorization: Bearer $token" -H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records?type=${record_type}&name=${domain}" 2>/dev/null)
result=$(echo "$resp" | jsonfilter -e '$.success' 2>/dev/null)
[ "$result" = "true" ] || { echo "false"; return 1; }
rids=$(echo "$resp" | jsonfilter -e '$.result[*].id' 2>/dev/null)
if [ -n "$rids" ]; then
match_resp=$(curl -s --connect-timeout 10 -H "Authorization: Bearer $token" -H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records?type=${record_type}&name=${domain}&content=${ip}" 2>/dev/null)
result=$(echo "$match_resp" | jsonfilter -e '$.success' 2>/dev/null)
[ "$result" = "true" ] || { echo "false"; return 1; }
keep_id=$(echo "$match_resp" | jsonfilter -e '$.result[0].id' 2>/dev/null)
if [ -z "$keep_id" ]; then
for rid in $rids; do keep_id="$rid"; break; done
resp=$(curl -s --connect-timeout 10 -X PUT -H "Authorization: Bearer $token" -H "Content-Type: application/json" \
-d "{\"type\":\"$record_type\",\"name\":\"$domain\",\"content\":\"$ip\",\"ttl\":120,\"proxied\":false}" \
"https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${keep_id}" 2>/dev/null)
result=$(echo "$resp" | jsonfilter -e '$.success' 2>/dev/null)
[ "$result" = "true" ] || { echo "false"; return 1; }
fi
for rid in $rids; do
[ "$rid" = "$keep_id" ] && continue
resp=$(curl -s --connect-timeout 10 -X DELETE -H "Authorization: Bearer $token" -H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${rid}" 2>/dev/null)
result=$(echo "$resp" | jsonfilter -e '$.success' 2>/dev/null)
[ "$result" = "true" ] || { echo "false"; return 1; }
done
echo "true"
else
resp=$(curl -s --connect-timeout 10 -X POST -H "Authorization: Bearer $token" -H "Content-Type: application/json" \
-d "{\"type\":\"$record_type\",\"name\":\"$domain\",\"content\":\"$ip\",\"ttl\":120,\"proxied\":false}" \
"https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records" 2>/dev/null)
echo "$resp" | jsonfilter -e '$.success' 2>/dev/null
fi
}
calc_next() {
local interval="${1:-300}"
local now=$(date +%s)
local next=$((now + interval))
date -d "@${next}" '+%H:%M:%S' 2>/dev/null || echo "${interval}秒后"
}
update_one() {
local sec="$1" force="$2"
local enabled=$(uci -q get minigate.${sec}.enabled)
[ "$enabled" = "1" ] || return 0
local domain=$(uci -q get minigate.${sec}.domain)
local zone_id=$(uci -q get minigate.${sec}.cf_zone_id)
local token=$(uci -q get minigate.${sec}.cf_api_token)
local ip_source=$(uci -q get minigate.${sec}.ip_source)
local iface=$(uci -q get minigate.${sec}.interface)
local ip_url=$(uci -q get minigate.${sec}.ip_url)
local interval=$(uci -q get minigate.${sec}.check_interval); interval=${interval:-300}
local ip_version=$(uci -q get minigate.${sec}.ip_version); ip_version=${ip_version:-ipv4}
local iface6=$(uci -q get minigate.${sec}.interface6); iface6=${iface6:-wan6}
local ip_url6=$(uci -q get minigate.${sec}.ip_url6); ip_url6=${ip_url6:-https://api6.ipify.org}
[ -z "$domain" ] || [ -z "$zone_id" ] || [ -z "$token" ] && {
log "$domain: 配置不完整"
uci -q set minigate.${sec}.status="error"; uci -q set minigate.${sec}.status_msg="配置不完整"
return 1
}
local cache="/tmp/minigate_ddns_${sec}.cache"
local cache6="/tmp/minigate_ddns_${sec}_v6.cache"
local cached=$(cat "$cache" 2>/dev/null)
local cached6=$(cat "$cache6" 2>/dev/null)
local next_time=$(calc_next "$interval")
local any_ok=0 any_fail=0
local result_ip="" result_ip6=""
local msgs=""
# ====== IPv4 处理 ======
if [ "$ip_version" = "ipv4" ] || [ "$ip_version" = "dual" ]; then
local ip4=""
case "$ip_source" in url) ip4=$(get_ip4_url "$ip_url");; *) ip4=$(get_ip4_iface "$iface");; esac
if [ -z "$ip4" ]; then
log "$domain: 获取IPv4失败"
msgs="${msgs}IPv4获取失败; "
any_fail=1
else
record_ip_history "$domain" "ipv4" "$ip4" || log "$domain: IPv4历史记录写入失败"
fi
if [ -n "$ip4" ] && { [ "$force" = "1" ] || [ "$ip4" != "$cached" ]; }; then
log "$domain: IPv4 ${cached:-无} -> $ip4"
local result=$(cf_update "$zone_id" "$token" "$domain" "$ip4" "A")
if [ "$result" = "true" ]; then
log "$domain: A记录同步成功"
echo "$ip4" > "$cache"
result_ip="$ip4"
msgs="${msgs}A:${ip4} ✓; "
any_ok=1
else
log "$domain: A记录同步失败"
msgs="${msgs}A记录失败; "
any_fail=1
fi
elif [ -n "$ip4" ]; then
log "$domain: IPv4地址一致($ip4)"
result_ip="$ip4"
msgs="${msgs}A:${ip4} (未变); "
any_ok=1
fi
fi
# ====== IPv6 处理 ======
if [ "$ip_version" = "ipv6" ] || [ "$ip_version" = "dual" ]; then
local ip6=""
case "$ip_source" in url) ip6=$(get_ip6_url "$ip_url6");; *) ip6=$(get_ip6_iface "$iface6");; esac
if [ -z "$ip6" ]; then
log "$domain: 获取IPv6失败"
msgs="${msgs}IPv6获取失败; "
# 双栈模式下 IPv6 获取失败不算整体失败
[ "$ip_version" = "ipv6" ] && any_fail=1
else
record_ip_history "$domain" "ipv6" "$ip6" || log "$domain: IPv6历史记录写入失败"
fi
if [ -n "$ip6" ] && { [ "$force" = "1" ] || [ "$ip6" != "$cached6" ]; }; then
log "$domain: IPv6 ${cached6:-无} -> $ip6"
local result=$(cf_update "$zone_id" "$token" "$domain" "$ip6" "AAAA")
if [ "$result" = "true" ]; then
log "$domain: AAAA记录同步成功"
echo "$ip6" > "$cache6"
result_ip6="$ip6"
msgs="${msgs}AAAA:${ip6} ✓; "
any_ok=1
else
log "$domain: AAAA记录同步失败"
msgs="${msgs}AAAA记录失败; "
any_fail=1
fi
elif [ -n "$ip6" ]; then
log "$domain: IPv6地址一致($ip6)"
result_ip6="$ip6"
msgs="${msgs}AAAA:${ip6} (未变); "
any_ok=1
fi
fi
# ====== 更新状态 ======
if [ "$any_ok" = "1" ] && [ "$any_fail" = "0" ]; then
uci -q set minigate.${sec}.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"
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"
}
main() {
local gen=$(uci -q get minigate.global.enabled)
if [ "$gen" != "1" ]; then
log "全局未启用,跳过 DDNS"
return 0
fi
local force=0; [ "$1" = "force" ] && force=1
log "--- 开始同步 ---"
local sections=$(uci -q show minigate | grep '=ddns$' | cut -d. -f2 | cut -d= -f1)
local c=0
for sec in $sections; do update_one "$sec" "$force"; c=$((c + 1)); done
[ "$c" = "0" ] && log "无 DDNS 记录"
uci commit minigate
log "--- 同步完成 ($c 条) ---"
trim_log
}
if [ "${MINIGATE_DDNS_LIBRARY_ONLY:-0}" = "1" ]; then
return 0 2>/dev/null || exit 0
fi
# 手动单条同步
if [ "$1" = "single" ] && [ -n "$2" ]; then
log "手动同步: $2"
update_one "$2" "1"
uci commit minigate
trim_log
exit 0
fi
main "$@"
+177
View File
@@ -0,0 +1,177 @@
#!/bin/sh
# MiniGate GeoFence - 地区访问控制
# 检查反代访问日志,非允许地区的 IP 用防火墙封禁
LOGFILE="/var/log/minigate-geofence.log"
CACHE_DIR="/tmp/minigate_geo"
BLOCK_SET="minigate_blocked"
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
}
# 主逻辑:扫描 access log 中的新 IP
scan_and_block() {
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
# 提取所有外网 IP(排除内网)
local ips=$(grep -oP '"client":"[^"]*"' "$ACCESS_LOG" 2>/dev/null | \
grep -oP '[\d\.]+' | \
grep -v '^192\.168\.' | grep -v '^10\.' | grep -v '^172\.(1[6-9]|2[0-9]|3[01])\.' | grep -v '^127\.' | \
sort -u)
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 "已清除所有封禁"
}
case "${1:-scan}" in
scan) scan_and_block ;;
unblock) unblock_ip "$2" ;;
flush) flush_blocks ;;
*) echo "用法: $0 {scan|unblock <ip>|flush}" ;;
esac
+351
View File
@@ -0,0 +1,351 @@
#!/bin/sh
# MiniGate · 登录防护(Login Guard)
# 监控 SSH (dropbear) 和 LuCI 网页登录失败,达到阈值自动封禁源 IP
#
# 用法:
# login_guard.sh run 后台监控(init.d 调用)
# login_guard.sh list 列出当前已封禁的 IP
# login_guard.sh status 状态总览
# login_guard.sh ban <IP> 手动封禁
# login_guard.sh unban <IP> 解封
# login_guard.sh flush 清空所有封禁
# login_guard.sh log 最近日志
# === 从 uci 读配置 ===
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")
DATA_DIR=/etc/minigate/login-guard
RUN_DIR=/var/run/minigate/login-guard
BANS_FILE="$DATA_DIR/bans.txt"
COUNTER_DIR="$RUN_DIR/counters"
LOG_FILE=/var/log/minigate-login-guard.log
ACCESS_LOG=/var/log/minigate-access.log
SET="login_banned_v4"
TABLE="inet fw4"
mkdir -p "$DATA_DIR" "$COUNTER_DIR"
touch "$BANS_FILE"
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG_FILE"
logger -t minigate-lg "$*"
}
# === 白名单:从 uci 读 list whitelist + 默认私有段 ===
is_whitelisted() {
local ip="$1"
case "$ip" in
192.168.*|10.*|127.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*) 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
done
return 1
}
# === 确保 nft set + drop rule 存在(fw4 reload 后自动重建) ===
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
[ $recreated -eq 1 ] && log "已创建 nft set + drop 规则"
return $recreated
}
# === 把 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 次失败)"
rm -f "$COUNTER_DIR/$ip"
}
# === 启动时从持久化文件恢复封禁 ===
restore_bans() {
[ -f "$BANS_FILE" ] || return 0
local now=$(date +%s)
local tmp="${BANS_FILE}.tmp"
> "$tmp"
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
echo "$ip $exp" >> "$tmp"
count=$((count + 1))
fi
done < "$BANS_FILE"
mv "$tmp" "$BANS_FILE"
[ $count -gt 0 ] && log "从持久化文件恢复 $count 条封禁"
}
# === 处理一条登录失败 ===
handle_failure() {
local ip="$1"
[ -z "$ip" ] && return
is_whitelisted "$ip" && return
# 滑动窗口:超过 window 秒的旧失败丢弃
local f="$COUNTER_DIR/$ip"
local now=$(date +%s)
local first=0
local count=0
if [ -f "$f" ]; then
read -r first count < "$f"
if [ -n "$first" ] && [ $((now - first)) -gt "$WINDOW" ]; then
first=$now
count=0
fi
else
first=$now
fi
count=$((count + 1))
echo "$first $count" > "$f"
log "FAIL $ip ($count/$THRESHOLD)"
if [ "$count" -ge "$THRESHOLD" ]; then
# 加 ban 前再确认 nft 还在(fw4 可能重载)
local ban_target="$ip"
ensure_nft && restore_bans
ban_ip "$ban_target"
fi
}
extract_ip() {
echo "$1" | grep -oE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" | head -1
}
extract_json_field() {
local key="$1"
sed -n "s/.*\"${key}\":\"\([^\"]*\)\".*/\1/p" | head -1
}
is_luci_failed_post() {
local line="$1"
echo "$line" | grep -q '"method":"POST"' || return 1
echo "$line" | grep -q '"status":403' || return 1
echo "$line" | grep -Eq '"uri":"/cgi-bin/luci(/|")' || return 1
return 0
}
access_log_loop() {
touch "$ACCESS_LOG" 2>/dev/null
tail -n 0 -f "$ACCESS_LOG" 2>/dev/null | while read -r line; do
is_luci_failed_post "$line" || continue
ip=$(echo "$line" | extract_json_field client)
handle_failure "$ip"
done
}
# === 后台 watchdog:每 5 分钟检查 nft 资源 ===
watchdog_loop() {
while true; do
sleep 300
if ensure_nft; then
restore_bans
fi
done
}
# === 主监控循环 ===
run_watcher() {
[ "$ENABLED" = "1" ] || {
log "未启用(minigate.login_guard.enabled=0),退出"
exit 0
}
ensure_nft
restore_bans
log "登录防护启动 阈值=$THRESHOLD 窗口=${WINDOW}s 封禁=${BANTIME}s"
# 后台 watchdog
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
}
# === CLI 命令 ===
# 用 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)
}
# 提取 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
}
cmd_list() {
echo "=== 当前已封禁 IP ==="
local out
out=$(parse_set_json)
if [ -z "$out" ]; then
echo "(暂无)"
return
fi
echo "$out" | while read -r ip secs; do
printf " %-18s 剩余 %s\n" "$ip" "$(fmt_duration "$secs")"
done
}
cmd_status() {
local banned watching
banned=$(parse_set_json | wc -l)
watching=$(ls "$COUNTER_DIR" 2>/dev/null | wc -l)
echo "已封禁 IP 数: $banned"
echo "失败计数中的 IP: $watching"
if [ "$banned" -gt 0 ]; then
echo "--- 已封禁 ---"
cmd_list | tail -n +2
fi
if [ "$watching" -gt 0 ]; then
echo "--- 失败计数详情 ---"
for f in "$COUNTER_DIR"/*; do
[ -f "$f" ] || continue
local ip count first now age
ip=$(basename "$f")
read -r first count < "$f"
now=$(date +%s)
age=$((now - first))
printf " %-18s %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
grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true
mv "${BANS_FILE}.tmp" "$BANS_FILE"
rm -f "$COUNTER_DIR/$ip"
echo "✓ 已解封 $ip"
log "MANUAL unban $ip"
}
cmd_ban() {
local ip="$1"
[ -z "$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"
echo "✓ 已封禁 $ip"
log "MANUAL ban $ip"
}
cmd_flush() {
nft flush set $TABLE $SET 2>&1 || true
> "$BANS_FILE"
rm -rf "$COUNTER_DIR"/*
echo "✓ 已清空所有封禁"
log "MANUAL flush all bans"
}
cmd_log() {
local n="${1:-30}"
if [ -f "$LOG_FILE" ]; then
tail -n "$n" "$LOG_FILE"
else
logread -e minigate-lg | tail -n "$n"
fi
}
# === 入口 ===
case "${1:-help}" in
run) run_watcher ;;
list|ls) cmd_list ;;
status) cmd_status ;;
ban) cmd_ban "$2" ;;
unban) cmd_unban "$2" ;;
flush) cmd_flush ;;
log|logs) cmd_log "$2" ;;
*)
cat <<EOH
MiniGate 登录防护
$0 run 后台监控(init.d 用)
$0 list 列出已封禁 IP
$0 status 状态总览
$0 ban <IP> 手动封禁
$0 unban <IP> 解封
$0 flush 清空所有封禁
$0 log [N] 最近 N 条日志(默认 30)
EOH
;;
esac
+436
View File
@@ -0,0 +1,436 @@
#!/bin/sh
NGINX_CONF="${MINIGATE_NGINX_CONF:-/etc/minigate/nginx/minigate.conf}"
SITES_DIR="${MINIGATE_SITES_DIR:-/etc/minigate/nginx/sites}"
STREAMS_DIR="${MINIGATE_STREAMS_DIR:-/etc/minigate/nginx/streams}"
CERT_DIR="${MINIGATE_CERT_DIR:-/etc/minigate/certs}"
DEFAULT_CERT_DIR="${MINIGATE_DEFAULT_CERT_DIR:-${CERT_DIR}/_default}"
LOGFILE="${MINIGATE_LOGFILE:-/var/log/minigate-proxy.log}"
PID_FILE="${MINIGATE_PID_FILE:-/var/run/minigate-nginx.pid}"
NGINX_ERROR_LOG="${MINIGATE_NGINX_ERROR_LOG:-/var/log/minigate-nginx-error.log}"
ACCESS_LOG="${MINIGATE_ACCESS_LOG:-/var/log/minigate-access.log}"
SOCKET_DIR="${MINIGATE_SOCKET_DIR:-/var/run/minigate}"
STREAM_MODULE="${MINIGATE_STREAM_MODULE:-/usr/lib/nginx/modules/ngx_stream_module.so}"
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [PROXY] $*" >> "$LOGFILE"; }
generate_main_conf() {
local http_redirect=$(get_http_redirect)
local use_stream=0
mkdir -p "$(dirname "$NGINX_CONF")" "$SITES_DIR" "$STREAMS_DIR" "$SOCKET_DIR"
if [ "$http_redirect" = "1" ] && ls "$STREAMS_DIR"/*.conf >/dev/null 2>&1; then
use_stream=1
fi
if [ "$use_stream" = "1" ] && [ ! -f "$STREAM_MODULE" ]; then
log "同端口HTTP跳转需要 nginx-mod-stream"
return 1
fi
: > "$NGINX_CONF"
[ "$use_stream" = "1" ] && echo "load_module ${STREAM_MODULE};" >> "$NGINX_CONF"
cat >> "$NGINX_CONF" <<EOF
worker_processes auto;
pid ${PID_FILE};
error_log ${NGINX_ERROR_LOG} warn;
events { worker_connections 512; }
EOF
if [ "$use_stream" = "1" ]; then
cat >> "$NGINX_CONF" <<EOF
stream {
include ${STREAMS_DIR}/*.conf;
}
EOF
fi
cat >> "$NGINX_CONF" <<'EOF'
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_names_hash_bucket_size 64;
sendfile on; keepalive_timeout 65; client_max_body_size 100m;
gzip on; gzip_types text/plain text/css application/json application/javascript text/xml;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
map $proxy_protocol_addr $minigate_client_addr {
default $proxy_protocol_addr;
"" $remote_addr;
}
log_format minigate_json escape=json
'{"time":"$time_iso8601",'
'"domain":"$server_name",'
'"client":"$minigate_client_addr",'
'"method":"$request_method",'
'"uri":"$request_uri",'
'"status":$status,'
'"size":$body_bytes_sent,'
'"referer":"$http_referer",'
'"ua":"$http_user_agent"}';
EOF
echo " access_log ${ACCESS_LOG} minigate_json;" >> "$NGINX_CONF"
echo " include ${SITES_DIR}/*.conf;" >> "$NGINX_CONF"
cat >> "$NGINX_CONF" <<'EOF'
}
EOF
}
find_cert() {
local domain="$1"
local parent=$(echo "$domain" | sed 's/^[^.]*\.//')
for try in "$domain" "_wildcard_.${parent}" "$parent"; do
[ -f "${CERT_DIR}/${try}/fullchain.pem" ] && { echo "${CERT_DIR}/${try}"; return 0; }
done
for try in "$parent" "$domain"; do
[ -L "${CERT_DIR}/${try}" ] && {
local t=$(readlink -f "${CERT_DIR}/${try}")
[ -f "${t}/fullchain.pem" ] && { echo "$t"; return 0; }
}
done
return 1
}
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"
}
# 读取全局 IPv6 监听设置
get_ipv6_listen() {
local v6=$(uci -q get minigate.global.ipv6_listen)
echo "${v6:-0}"
}
get_http_redirect() {
local enabled=$(uci -q get minigate.global.http_redirect)
echo "${enabled:-0}"
}
ensure_default_cert() {
mkdir -p "$DEFAULT_CERT_DIR"
[ -f "${DEFAULT_CERT_DIR}/fullchain.pem" ] && [ -f "${DEFAULT_CERT_DIR}/key.pem" ] && return 0
openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \
-subj "/CN=minigate.invalid" \
-keyout "${DEFAULT_CERT_DIR}/key.pem" \
-out "${DEFAULT_CERT_DIR}/fullchain.pem" >/dev/null 2>&1
}
write_default_server() {
local conf="$1" lport="$2" ssl="$3" h2s="$4" ipv6_listen="$5" mux="$6"
local ll="listen ${lport} default_server"
local ll6=""
local ex=""
if [ "$mux" = "1" ]; then
if [ "$ssl" = "1" ]; then
ll="listen unix:${SOCKET_DIR}/https_${lport}.sock default_server ssl proxy_protocol"
else
ll="listen unix:${SOCKET_DIR}/http_${lport}.sock default_server proxy_protocol"
fi
elif [ "$ssl" = "1" ]; then
ll="${ll} ssl"
fi
if [ "$mux" != "1" ] && [ "$ipv6_listen" = "1" ]; then
ll6="listen [::]:${lport} default_server"
[ "$ssl" = "1" ] && ll6="${ll6} ssl"
fi
if [ "$ssl" = "1" ]; then
if [ "$h2s" = "new" ]; then
ex=" http2 on;"
else
ll="${ll} http2"
[ -n "$ll6" ] && ll6="${ll6} http2"
fi
ensure_default_cert
fi
cat > "$conf" <<SEOF
server {
${ll};
SEOF
[ -n "$ll6" ] && echo " ${ll6};" >> "$conf"
cat >> "$conf" <<SEOF
server_name _;
${ex}
SEOF
if [ "$ssl" = "1" ]; then
cat >> "$conf" <<SEOF
ssl_certificate ${DEFAULT_CERT_DIR}/fullchain.pem;
ssl_certificate_key ${DEFAULT_CERT_DIR}/key.pem;
SEOF
fi
cat >> "$conf" <<'SEOF'
return 444;
}
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"
[ -f "$mark" ] && return 0
: > "$mark"
write_default_server "${SITES_DIR}/000_default_${key}.conf" "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux"
}
write_redirect_server() {
local conf="$1" domain="$2" lport="$3"
local authority="$domain"
[ "$lport" = "443" ] || authority="${domain}:${lport}"
cat >> "$conf" <<SEOF
server {
listen unix:${SOCKET_DIR}/http_${lport}.sock proxy_protocol;
server_name ${domain};
return 308 https://${authority}\$request_uri;
}
SEOF
}
ensure_stream_server() {
local lport="$1" ipv6_listen="$2"
local mark="/tmp/minigate_stream_${lport}.tmp"
local conf="${STREAMS_DIR}/stream_${lport}.conf"
[ -f "$mark" ] && return 0
: > "$mark"
cat > "$conf" <<SEOF
map \$ssl_preread_protocol \$minigate_backend_${lport} {
"" unix:${SOCKET_DIR}/http_${lport}.sock;
default unix:${SOCKET_DIR}/https_${lport}.sock;
}
server {
listen ${lport};
SEOF
[ "$ipv6_listen" = "1" ] && echo " listen [::]:${lport};" >> "$conf"
cat >> "$conf" <<SEOF
ssl_preread on;
proxy_protocol on;
proxy_pass \$minigate_backend_${lport};
}
SEOF
}
# 写一个完整的 server block(带 proxy_pass),同时支持 IPv6
write_server() {
local conf="$1" domain="$2" lport="$3" taddr="$4" tport="$5" ssl="$6" h2="$7" ws="$8" h2s="$9"
shift 9
local ipv6_listen="${1:-0}"
local mux="${2:-0}"
log "生成: $domain:${lport} -> ${taddr}:${tport} (ipv6=$ipv6_listen)"
local ll="listen ${lport}"; local ll6=""; local ex=""
if [ "$mux" = "1" ]; then
ll="listen unix:${SOCKET_DIR}/https_${lport}.sock ssl proxy_protocol"
elif [ "$ssl" = "1" ]; then
ll="${ll} ssl"
fi
# IPv6 listen
if [ "$mux" != "1" ] && [ "$ipv6_listen" = "1" ]; then
ll6="listen [::]:${lport}"
[ "$ssl" = "1" ] && ll6="${ll6} ssl"
fi
# HTTPS 模式自动启用 HTTP/2
if [ "$ssl" = "1" ]; then
if [ "$h2s" = "new" ]; then
ex=" http2 on;"
else
ll="${ll} http2"
[ -n "$ll6" ] && ll6="${ll6} http2"
fi
fi
cat >> "$conf" <<SEOF
server {
${ll};
SEOF
[ -n "$ll6" ] && echo " ${ll6};" >> "$conf"
cat >> "$conf" <<SEOF
server_name ${domain};
${ex}
SEOF
if [ "$ssl" = "1" ]; then
local cp=$(find_cert "$domain")
[ -n "$cp" ] && 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
fi
# 判断目标地址是否为 IPv6
local target_host="$taddr"
echo "$taddr" | grep -q ':' && target_host="[${taddr}]"
cat >> "$conf" <<SEOF
location / {
# 拒绝直接 IP 访问(非域名)
if (\$host ~* "^\d+\.\d+\.\d+\.\d+") {
return 444;
}
proxy_pass http://${target_host}:${tport};
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$minigate_client_addr;
proxy_set_header X-Forwarded-For \$minigate_client_addr;
proxy_set_header X-Forwarded-Proto \$scheme;
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";
SEOF
echo " }" >> "$conf"; echo "}" >> "$conf"
}
generate_sites() {
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)
# === 通配符域名设置 (proxy_wildcard sections) ===
local wc_sections=$(uci -q show minigate | grep '=proxy_wildcard$' | cut -d. -f2 | cut -d= -f1)
for sec in $wc_sections; do
local enabled=$(uci -q get minigate.${sec}.enabled)
[ "$enabled" = "1" ] || continue
local domain=$(uci -q get minigate.${sec}.domain)
[ -z "$domain" ] && continue
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"
log "通配符: $domain (端口:$lport https:$ssl ipv6:$ipv6_listen)"
done
# === 非通配符代理规则 (proxy sections) ===
local sections=$(uci -q show minigate | grep '=proxy$' | cut -d. -f2 | cut -d= -f1)
for sec in $sections; do
local enabled=$(uci -q get minigate.${sec}.enabled)
[ "$enabled" = "1" ] || continue
local domain=$(uci -q get minigate.${sec}.domain)
local lport=$(uci -q get minigate.${sec}.listen_port); lport=${lport:-443}
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"
done
# === 子域名规则:继承通配符主域名设置 ===
local subs=$(uci -q show minigate | grep '=subproxy$' | cut -d. -f2 | cut -d= -f1)
for sec in $subs; do
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)
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"
done
rm -f /tmp/minigate_proxy_*.tmp
rm -f /tmp/minigate_default_*.tmp
rm -f /tmp/minigate_stream_*.tmp
}
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 "已停止"
fi
}
prepare_config() {
generate_sites || return 1
generate_main_conf || return 1
nginx -t -c "$NGINX_CONF" >> "$LOGFILE" 2>&1 || { log "配置错误"; return 1; }
}
start_prepared() {
do_stop 2>/dev/null
sleep 1
mkdir -p "$SOCKET_DIR"
rm -f "$SOCKET_DIR"/*.sock
nginx -c "$NGINX_CONF" >> "$LOGFILE" 2>&1 && log "已启动" || { log "启动失败"; return 1; }
}
do_start() {
[ -z "$(which nginx 2>/dev/null)" ] && { log "nginx未安装"; return 1; }
prepare_config || return 1
start_prepared
}
do_reload() {
local old_mux=0 new_mux=0
grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && old_mux=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
else
kill -HUP $(cat "$PID_FILE") && log "已重载"
fi
else
start_prepared
fi
}
case "$1" in
start) do_start ;;
stop) do_stop ;;
reload) do_reload ;;
test) prepare_config ;;
*) echo "用法: $0 {start|stop|reload|test}" ;;
esac
+266
View File
@@ -0,0 +1,266 @@
#!/bin/sh
set -u
PURGE="${1:-0}"
ROOT="${MINIGATE_ROOT:-}"
SKIP_PACKAGES="${MINIGATE_SKIP_PACKAGES:-0}"
SKIP_SERVICES="${MINIGATE_SKIP_SERVICES:-0}"
PRESERVE_DIR=""
LOCK_HELD=0
[ "$PURGE" = "0" ] || [ "$PURGE" = "1" ] || {
echo "用法: $0 0|1" >&2
exit 2
}
root_path() {
printf '%s%s\n' "$ROOT" "$1"
}
LOG_FILE="$(root_path /tmp/minigate-uninstall.log)"
log() {
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE"
[ -n "$ROOT" ] || logger -t minigate-uninstall "$*" 2>/dev/null || true
}
cleanup() {
if [ "$LOCK_HELD" = "1" ]; then
flock -u 9 2>/dev/null || true
fi
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
if [ -z "$ROOT" ] && command -v flock >/dev/null 2>&1; then
exec 9>"$(root_path /var/lock/minigate-update.lock)"
if ! flock -n 9; then
log "已有更新或卸载任务运行,取消本次卸载"
exit 1
fi
LOCK_HELD=1
fi
preserve_data() {
local config data etc_dir
config="$(root_path /etc/config/minigate)"
data="$(root_path /etc/minigate)"
etc_dir="$(root_path /etc)"
PRESERVE_DIR="$(root_path /tmp)/minigate-uninstall-preserve.$$"
mkdir -p "$PRESERVE_DIR" || return 1
if [ -f "$config" ]; then
cp -p "$config" "$PRESERVE_DIR/config" || return 1
fi
if [ -d "$data" ]; then
tar -cf "$PRESERVE_DIR/data.tar" -C "$etc_dir" minigate || return 1
fi
}
restore_data() {
local config etc_dir
[ -n "$PRESERVE_DIR" ] || return 0
config="$(root_path /etc/config/minigate)"
etc_dir="$(root_path /etc)"
if [ -f "$PRESERVE_DIR/config" ]; then
mkdir -p "$(dirname "$config")" || return 1
cp -p "$PRESERVE_DIR/config" "$config" || return 1
fi
if [ -f "$PRESERVE_DIR/data.tar" ]; then
mkdir -p "$etc_dir" || return 1
tar -xf "$PRESERVE_DIR/data.tar" -C "$etc_dir" || return 1
fi
}
restore_or_keep_backup() {
if restore_data; then
rm -rf "$PRESERVE_DIR"
PRESERVE_DIR=""
return 0
fi
log "保留数据恢复失败,临时备份位于 ${PRESERVE_DIR}"
PRESERVE_DIR=""
return 1
}
restart_after_failure() {
local init
[ "$SKIP_SERVICES" = "1" ] && return 0
init="$(root_path /etc/init.d/minigate)"
if [ -x "$init" ]; then
"$init" enable >/dev/null 2>&1 || true
"$init" start 9>&- >/dev/null 2>&1 || true
fi
}
stop_process_tree() {
local pid child
pid="$1"
if command -v pgrep >/dev/null 2>&1; then
for child in $(pgrep -P "$pid" 2>/dev/null); do
stop_process_tree "$child"
done
fi
kill "$pid" 2>/dev/null || true
}
stop_services() {
local init pid
[ "$SKIP_SERVICES" = "1" ] && return 0
if [ -z "$ROOT" ] && command -v pgrep >/dev/null 2>&1; then
for pid in $(pgrep -f '/usr/lib/minigate/login_guard.sh run' 2>/dev/null); do
stop_process_tree "$pid"
done
fi
init="$(root_path /etc/init.d/minigate)"
if [ -x "$init" ]; then
"$init" stop 9>&- >/dev/null 2>&1 || true
"$init" disable >/dev/null 2>&1 || true
fi
if [ -z "$ROOT" ]; then
pid="$(cat "$(root_path /var/run/minigate-nginx.pid)" 2>/dev/null || true)"
case "$pid" in
''|*[!0-9]*) ;;
*) kill "$pid" 2>/dev/null || true ;;
esac
if command -v pgrep >/dev/null 2>&1; then
for pid in $(pgrep -f '/usr/lib/minigate/' 2>/dev/null); do
stop_process_tree "$pid"
done
fi
fi
}
remove_nft_rule_for_set() {
local set_name handle
set_name="$1"
command -v nft >/dev/null 2>&1 || return 0
for handle in $(nft -a list chain inet fw4 input 2>/dev/null |
awk -v marker="@${set_name}" 'index($0, marker) { 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 "$set_name" 2>/dev/null || true
}
remove_firewall_state() {
[ "$SKIP_SERVICES" = "1" ] && return 0
[ -z "$ROOT" ] || return 0
remove_nft_rule_for_set login_banned_v4
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
fi
command -v ipset >/dev/null 2>&1 && ipset destroy minigate_blocked >/dev/null 2>&1 || true
}
remove_cron() {
local cron_file cron_tmp
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
cat "$cron_tmp" > "$cron_file"
fi
rm -f "$cron_tmp"
fi
if [ "$SKIP_SERVICES" != "1" ] && [ -z "$ROOT" ] && [ -x /etc/init.d/cron ]; then
/etc/init.d/cron restart 9>&- >/dev/null 2>&1 || true
fi
}
remove_registered_packages() {
local package
[ "$SKIP_PACKAGES" = "1" ] && return 0
[ -z "$ROOT" ] || return 0
if command -v opkg >/dev/null 2>&1; then
for package in minigate luci-app-minigate; do
if opkg list-installed 2>/dev/null | awk -v name="$package" '$1 == name { found=1 } END { exit !found }'; then
opkg remove "$package" 9>&- >> "$LOG_FILE" 2>&1 || return 1
fi
done
fi
if command -v apk >/dev/null 2>&1; then
for package in minigate luci-app-minigate; do
if apk info -e "$package" >/dev/null 2>&1; then
apk del "$package" 9>&- >> "$LOG_FILE" 2>&1 || return 1
fi
done
fi
}
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)"
rm -rf \
"$(root_path /usr/lib/minigate)" \
"$(root_path /usr/lib/lua/luci/model/cbi/minigate)" \
"$(root_path /usr/lib/lua/luci/view/minigate)"
}
remove_runtime_files() {
local tmp_dir log_dir
tmp_dir="$(root_path /tmp)"
log_dir="$(root_path /var/log)"
rm -rf \
"$(root_path /var/run/minigate)" \
"$(root_path /tmp/minigate-update)" \
"$(root_path /tmp/minigate-geo-cache)" \
"$(root_path /tmp/minigate_geo)"
rm -f \
"$(root_path /var/run/minigate-nginx.pid)" \
"$(root_path /tmp/minigate-update-state.json)" \
"$(root_path /tmp/minigate-update-release.json)" \
"$(root_path /tmp/minigate-update-last-check)" \
"$tmp_dir"/minigate_proxy_*.tmp \
"$tmp_dir"/minigate_default_*.tmp \
"$tmp_dir"/minigate_stream_*.tmp \
"$tmp_dir"/minigate_ddns_*.cache
if [ "$PURGE" = "1" ]; then
rm -f "$(root_path /etc/config/minigate)" "$log_dir"/minigate-*.log
rm -rf "$(root_path /etc/minigate)"
fi
}
refresh_luci() {
rm -f "$(root_path /tmp/luci-indexcache)" "$(root_path /tmp/luci-modulecache)" 2>/dev/null || true
if [ "$SKIP_SERVICES" != "1" ] && [ -z "$ROOT" ] && [ -x /etc/init.d/uhttpd ]; then
/etc/init.d/uhttpd restart 9>&- >/dev/null 2>&1 || true
fi
}
log "开始卸载(清除数据=${PURGE})"
if [ "$PURGE" != "1" ] && ! preserve_data; then
log "无法创建保留数据的临时备份,已取消卸载"
[ -z "$PRESERVE_DIR" ] || rm -rf "$PRESERVE_DIR"
PRESERVE_DIR=""
exit 1
fi
stop_services
remove_firewall_state
remove_cron
if ! remove_registered_packages; then
log "包管理器卸载失败,已停止清理"
[ "$PURGE" = "1" ] || restore_or_keep_backup
restart_after_failure
exit 1
fi
remove_program_files
remove_runtime_files
if [ "$PURGE" != "1" ] && ! restore_or_keep_backup; then
exit 1
fi
refresh_luci
log "卸载完成(清除数据=${PURGE})"
+357
View File
@@ -0,0 +1,357 @@
#!/bin/sh
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
CURRENT_VERSION="2026.9.18-2"
REPOSITORY="tpxcer/minigate"
API_URL="https://api.github.com/repos/${REPOSITORY}/releases/latest"
DOWNLOAD_ROOT="https://github.com/${REPOSITORY}/releases/download"
STATE_FILE="/tmp/minigate-update-state.json"
RELEASE_FILE="/tmp/minigate-update-release.json"
LAST_CHECK_FILE="/tmp/minigate-update-last-check"
LOCK_FILE="/var/lock/minigate-update.lock"
TMP_ROOT="/tmp/minigate-update"
RUN_LOG="/var/log/minigate-update.log"
STATE_CURRENT="$CURRENT_VERSION"
umask 077
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\r/ /g'
}
write_state() {
local status="$1"
local progress="$2"
local latest="$3"
local available="$4"
local running="$5"
local success="$6"
local message="$7"
local tmp="${STATE_FILE}.$$"
mkdir -p "$TMP_ROOT" "$(dirname "$LOCK_FILE")"
printf '{"status":"%s","progress":%s,"current":"%s","latest":"%s","available":%s,"running":%s,"success":%s,"message":"%s"}\n' \
"$status" "$progress" "$STATE_CURRENT" "$latest" "$available" "$running" "$success" "$(json_escape "$message")" > "$tmp"
mv -f "$tmp" "$STATE_FILE"
}
emit_state() {
if [ ! -s "$STATE_FILE" ]; then
write_state "idle" 0 "" false false true "点击检查更新获取最新版本"
fi
cat "$STATE_FILE"
}
download_file() {
local url="$1"
local output="$2"
curl -fL --proto '=https' --tlsv1.2 \
--connect-timeout 10 --max-time 120 --retry 2 --retry-delay 1 \
-H 'Accept: application/vnd.github+json' \
-H 'User-Agent: MiniGate-Updater/1' \
-o "$output" "$url"
}
valid_version() {
printf '%s' "$1" | grep -Eq '^[0-9]{4}\.(0?[1-9]|1[0-2])\.(0?[1-9]|[12][0-9]|3[01])(-[1-9][0-9]*)?$'
}
version_newer() {
awk -v left="$1" -v right="$2" 'BEGIN {
split(left, left_parts, "-");
split(right, right_parts, "-");
split(left_parts[1], a, ".");
split(right_parts[1], b, ".");
a[4] = left_parts[2] == "" ? 0 : left_parts[2] + 0;
b[4] = right_parts[2] == "" ? 0 : right_parts[2] + 0;
for (i = 1; i <= 4; i++) {
if ((a[i] + 0) > (b[i] + 0)) exit 0;
if ((a[i] + 0) < (b[i] + 0)) exit 1;
}
exit 1;
}'
}
fetch_latest() {
local metadata="${TMP_ROOT}/latest.$$.json"
local tag
mkdir -p "$TMP_ROOT"
if ! download_file "$API_URL" "$metadata" >> "$RUN_LOG" 2>&1; then
rm -f "$metadata"
return 1
fi
tag=$(jsonfilter -q -i "$metadata" -e '@.tag_name' 2>/dev/null)
tag=${tag#v}
if ! valid_version "$tag"; then
rm -f "$metadata"
return 1
fi
LATEST_VERSION="$tag"
chmod 600 "$metadata"
mv -f "$metadata" "$RELEASE_FILE"
return 0
}
run_check() {
local checked_file
mkdir -p "$TMP_ROOT" "$(dirname "$LOCK_FILE")"
exec 8>"$LOCK_FILE" || return 1
if ! flock -n 8; then
emit_state
return 0
fi
write_state "checking" 10 "" false true true "正在检查 GitHub Release"
if ! fetch_latest; then
write_state "error" 0 "" false false false "检查失败,请确认路由器可以访问 GitHub"
emit_state
return 1
fi
checked_file=$(mktemp "${TMP_ROOT}/last-check.XXXXXX")
if [ -n "$checked_file" ]; then
date +%s > "$checked_file"
mv -f "$checked_file" "$LAST_CHECK_FILE"
fi
if version_newer "$LATEST_VERSION" "$CURRENT_VERSION"; then
write_state "available" 100 "$LATEST_VERSION" true false true "发现新版本,可直接更新"
elif [ "$LATEST_VERSION" = "$CURRENT_VERSION" ]; then
write_state "up_to_date" 100 "$LATEST_VERSION" false false true "当前已经是最新版本"
else
write_state "up_to_date" 100 "$LATEST_VERSION" false false true "当前版本高于 GitHub 最新正式版"
fi
emit_state
}
run_auto_check() {
local now last age current status
current=$(jsonfilter -q -i "$STATE_FILE" -e '@.current' 2>/dev/null)
status=$(jsonfilter -q -i "$STATE_FILE" -e '@.status' 2>/dev/null)
if [ "$current" = "$CURRENT_VERSION" ] && [ -s "$LAST_CHECK_FILE" ] && \
{ [ "$status" = "available" ] || [ "$status" = "up_to_date" ]; }; then
now=$(date +%s)
last=$(cat "$LAST_CHECK_FILE" 2>/dev/null)
case "$last" in
''|*[!0-9]*) last=0 ;;
esac
age=$((now - last))
if [ "$age" -ge 0 ] && [ "$age" -lt 600 ]; then
emit_state
return 0
fi
fi
run_check
}
backup_program() {
local output="$1"
local paths=""
local path
for path in \
etc/init.d/minigate \
etc/config/minigate \
usr/lib/minigate \
usr/lib/lua/luci/controller/minigate.lua \
usr/lib/lua/luci/model/cbi/minigate \
usr/lib/lua/luci/view/minigate; do
[ -e "/$path" ] && paths="$paths $path"
done
[ -n "$paths" ] || return 1
tar -czf "${output}.new" -C / $paths || return 1
tar -tzf "${output}.new" >/dev/null 2>&1 || return 1
chmod 600 "${output}.new"
mv -f "${output}.new" "$output"
}
restore_program() {
local backup="$1"
/etc/init.d/minigate stop >> "$RUN_LOG" 2>&1 || true
rm -f /etc/init.d/minigate /usr/lib/lua/luci/controller/minigate.lua
rm -rf /usr/lib/minigate \
/usr/lib/lua/luci/model/cbi/minigate \
/usr/lib/lua/luci/view/minigate
tar -xzf "$backup" -C / >> "$RUN_LOG" 2>&1 || return 1
chmod +x /etc/init.d/minigate /usr/lib/minigate/*.sh 2>/dev/null || true
rm -f /tmp/luci-indexcache /tmp/luci-modulecache
/etc/init.d/minigate start >> "$RUN_LOG" 2>&1 9>&- || true
/etc/init.d/uhttpd restart >> "$RUN_LOG" 2>&1 9>&- || true
if [ "$(uci -q get minigate.global.enabled)" = "1" ]; then
local pid
pid=$(cat /var/run/minigate-nginx.pid 2>/dev/null)
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null || return 1
nginx -t -c /etc/minigate/nginx/minigate.conf >> "$RUN_LOG" 2>&1 || return 1
fi
return 0
}
verify_install() {
grep -Fxq "CURRENT_VERSION=\"${LATEST_VERSION}\"" /usr/lib/minigate/update.sh || return 1
[ -x /etc/init.d/minigate ] || return 1
[ -x /usr/lib/minigate/proxy.sh ] || return 1
if [ "$(uci -q get minigate.global.enabled)" = "1" ]; then
nginx -t -c /etc/minigate/nginx/minigate.conf >> "$RUN_LOG" 2>&1 || return 1
local pid
pid=$(cat /var/run/minigate-nginx.pid 2>/dev/null)
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null || return 1
fi
return 0
}
run_apply_locked() {
local work_dir source_name source_file sums_file expected actual valid_hash backup
local confirmed_version="$1"
if ! valid_version "$confirmed_version"; then
write_state "error" 0 "" false false false "请先检查更新并确认本次更新内容"
return 1
fi
: > "$RUN_LOG"
write_state "checking" 5 "" false true true "正在检查最新版本"
if ! fetch_latest; then
write_state "error" 0 "" false false false "检查失败,请确认路由器可以访问 GitHub"
return 1
fi
if [ "$LATEST_VERSION" != "$confirmed_version" ]; then
write_state "error" 0 "$LATEST_VERSION" true false false "最新版本已变化,请重新查看更新内容并确认"
return 1
fi
if ! version_newer "$LATEST_VERSION" "$CURRENT_VERSION"; then
write_state "up_to_date" 100 "$LATEST_VERSION" false false true "当前已经是最新版本"
return 0
fi
work_dir=$(mktemp -d "${TMP_ROOT}/work.XXXXXX") || {
write_state "error" 0 "$LATEST_VERSION" true false false "无法创建更新临时目录"
return 1
}
source_name="minigate-v${LATEST_VERSION}-src.tar.gz"
source_file="${work_dir}/${source_name}"
sums_file="${work_dir}/SHA256SUMS"
write_state "downloading" 20 "$LATEST_VERSION" true true true "正在下载版本清单"
if ! download_file "${DOWNLOAD_ROOT}/v${LATEST_VERSION}/SHA256SUMS" "$sums_file" >> "$RUN_LOG" 2>&1; then
write_state "error" 0 "$LATEST_VERSION" true false false "下载校验清单失败,未修改当前版本"
rm -rf "$work_dir"
return 1
fi
write_state "downloading" 40 "$LATEST_VERSION" true true true "正在下载更新包"
if ! download_file "${DOWNLOAD_ROOT}/v${LATEST_VERSION}/${source_name}" "$source_file" >> "$RUN_LOG" 2>&1; then
write_state "error" 0 "$LATEST_VERSION" true false false "下载更新包失败,未修改当前版本"
rm -rf "$work_dir"
return 1
fi
write_state "verifying" 55 "$LATEST_VERSION" true true true "正在校验 SHA-256 和版本信息"
expected=$(awk -v file="$source_name" '$2 == file || $2 == ("*" file) { print $1; exit }' "$sums_file")
valid_hash=$(printf '%s' "$expected" | grep -Ec '^[0-9a-fA-F]{64}$')
actual=$(sha256sum "$source_file" | awk '{print $1}')
if [ "$valid_hash" != "1" ] || [ "$(printf '%s' "$expected" | tr 'A-F' 'a-f')" != "$actual" ]; then
write_state "error" 0 "$LATEST_VERSION" true false false "SHA-256 校验失败,未修改当前版本"
rm -rf "$work_dir"
return 1
fi
if tar -tzf "$source_file" | awk '{
path=$0; sub(/^\.\//, "", path);
if (path ~ /^\// || path ~ /(^|\/)\.\.(\/|$)/) bad=1;
} END { exit bad ? 0 : 1 }'; then
write_state "error" 0 "$LATEST_VERSION" true false false "更新包路径不安全,已拒绝安装"
rm -rf "$work_dir"
return 1
fi
mkdir -p "${work_dir}/source"
if ! tar -xzf "$source_file" -C "${work_dir}/source" >> "$RUN_LOG" 2>&1; then
write_state "error" 0 "$LATEST_VERSION" true false false "更新包无法解压,未修改当前版本"
rm -rf "$work_dir"
return 1
fi
if ! grep -Fxq "PKG_VERSION:=${LATEST_VERSION}" "${work_dir}/source/Makefile" || \
! grep -Fxq "PKG_VERSION=\"${LATEST_VERSION}\"" "${work_dir}/source/scripts/build-ipk.sh" || \
! grep -Fxq "CURRENT_VERSION=\"${LATEST_VERSION}\"" "${work_dir}/source/root/usr/lib/minigate/update.sh"; then
write_state "error" 0 "$LATEST_VERSION" true false false "更新包版本信息不一致,已拒绝安装"
rm -rf "$work_dir"
return 1
fi
backup="${TMP_ROOT}/backup-before-${CURRENT_VERSION}.tar.gz"
write_state "backing_up" 65 "$LATEST_VERSION" true true true "正在备份当前程序和配置"
if ! backup_program "$backup"; then
write_state "error" 0 "$LATEST_VERSION" true false false "备份失败,未修改当前版本"
rm -rf "$work_dir"
return 1
fi
write_state "installing" 75 "$LATEST_VERSION" true true true "正在安装新版本,请勿断电"
if ! /bin/sh "${work_dir}/source/install.sh" >> "$RUN_LOG" 2>&1; then
write_state "rolling_back" 85 "$LATEST_VERSION" true true false "安装失败,正在恢复原版本"
if restore_program "$backup"; then
write_state "rolled_back" 100 "$LATEST_VERSION" true false false "安装失败,已恢复原版本"
else
write_state "rollback_failed" 100 "$LATEST_VERSION" true false false "安装失败且自动恢复未完成,请查看更新日志"
fi
rm -rf "$work_dir"
return 1
fi
write_state "restarting" 90 "$LATEST_VERSION" true true true "正在验证并重启服务"
sleep 2
if ! verify_install; then
write_state "rolling_back" 95 "$LATEST_VERSION" true true false "服务验证失败,正在恢复原版本"
if restore_program "$backup"; then
write_state "rolled_back" 100 "$LATEST_VERSION" true false false "服务验证失败,已恢复原版本"
else
write_state "rollback_failed" 100 "$LATEST_VERSION" true false false "服务验证失败且自动恢复未完成,请查看更新日志"
fi
rm -rf "$work_dir"
return 1
fi
rm -f /tmp/luci-indexcache /tmp/luci-modulecache
/etc/init.d/uhttpd restart >> "$RUN_LOG" 2>&1 9>&- || true
STATE_CURRENT="$LATEST_VERSION"
write_state "success" 100 "$LATEST_VERSION" false false true "更新成功,服务已恢复"
rm -rf "$work_dir"
return 0
}
run_apply() {
local result
mkdir -p "$TMP_ROOT" "$(dirname "$LOCK_FILE")"
exec 9>"$LOCK_FILE" || return 1
if ! flock -n 9; then
[ -s "$STATE_FILE" ] || write_state "busy" 0 "" false true false "已有更新任务正在运行"
exec 9>&-
return 2
fi
run_apply_locked "$1"
result=$?
flock -u 9 2>/dev/null || true
exec 9>&-
return "$result"
}
case "${1:-status}" in
status) emit_state ;;
auto) run_auto_check ;;
check) run_check ;;
apply) run_apply "${2:-}" ;;
*)
write_state "error" 0 "" false false false "不支持的操作"
emit_state
exit 2
;;
esac
+123
View File
@@ -0,0 +1,123 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
OUT_DIR="${1:-$ROOT_DIR/dist}"
PKG_NAME="minigate"
PKG_VERSION="2026.9.18-2"
PKG_ARCH="all"
PKG_FILE="${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.ipk"
DEPS="libc, luci-base, nginx-ssl, nginx-mod-stream, openssl-util, wget, curl, jsonfilter, coreutils-stat, nftables"
WORK_DIR="$(mktemp -d)"
CONTROL_DIR="$WORK_DIR/control"
DATA_DIR="$WORK_DIR/data"
PKG_DIR="$WORK_DIR/pkg"
mkdir -p "$CONTROL_DIR" "$DATA_DIR" "$PKG_DIR" "$OUT_DIR"
mkdir -p \
"$DATA_DIR/usr/lib/lua/luci/controller" \
"$DATA_DIR/usr/lib/lua/luci/model/cbi/minigate" \
"$DATA_DIR/usr/lib/lua/luci/view/minigate" \
"$DATA_DIR/usr/lib/minigate" \
"$DATA_DIR/etc/config" \
"$DATA_DIR/etc/init.d" \
"$DATA_DIR/etc/minigate/acme" \
"$DATA_DIR/etc/minigate/certs" \
"$DATA_DIR/etc/minigate/nginx/sites" \
"$DATA_DIR/etc/minigate/nginx/streams" \
"$DATA_DIR/etc/minigate/login-guard"
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/"
install -m 0644 "$ROOT_DIR"/luasrc/view/minigate/*.htm "$DATA_DIR/usr/lib/lua/luci/view/minigate/"
install -m 0755 "$ROOT_DIR"/root/usr/lib/minigate/*.sh "$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"
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"
po2lmo "$ROOT_DIR/po/zh-cn/minigate.po" "$DATA_DIR/usr/lib/lua/luci/i18n/minigate.zh-cn.lmo"
fi
cat > "$CONTROL_DIR/control" <<EOF
Package: $PKG_NAME
Version: $PKG_VERSION
Provides: luci-app-minigate
Conflicts: luci-app-minigate
Replaces: luci-app-minigate
Depends: $DEPS
Source: feeds/luci/applications/$PKG_NAME
SourceName: $PKG_NAME
Section: luci
Architecture: $PKG_ARCH
Installed-Size: $(du -sk "$DATA_DIR" | awk '{print $1}')
Maintainer: MiniGate
License: MIT
Description: LuCI - MiniGate (DDNS + ACME + Reverse Proxy + Login Guard)
Lightweight gateway management for OpenWrt: Cloudflare DDNS, Let's Encrypt SSL certificates,
Nginx reverse proxy, and SSH/LuCI brute-force ban (Login Guard) with LuCI web interface.
EOF
cat > "$CONTROL_DIR/conffiles" <<'EOF'
/etc/config/minigate
EOF
cat > "$CONTROL_DIR/postinst" <<'EOF'
#!/bin/sh
[ -n "${IPKG_INSTROOT}" ] || {
chmod +x /etc/init.d/minigate
chmod +x /usr/lib/minigate/*.sh
/etc/init.d/minigate enable
echo "MiniGate installed. Configure at: LuCI -> Services -> MiniGate"
}
EOF
cat > "$CONTROL_DIR/prerm" <<'EOF'
#!/bin/sh
[ -n "${IPKG_INSTROOT}" ] || {
/etc/init.d/minigate stop
/etc/init.d/minigate disable
}
EOF
chmod 0755 "$CONTROL_DIR/postinst" "$CONTROL_DIR/prerm"
printf '2.0\n' > "$PKG_DIR/debian-binary"
tar_create_root() {
out_file="$1"
shift
if tar --version 2>/dev/null | grep -qi 'bsdtar'; then
COPYFILE_DISABLE=1 tar --format ustar --no-xattrs --no-mac-metadata \
--uid 0 --gid 0 --uname root --gname root -czf "$out_file" "$@"
elif tar --help 2>/dev/null | grep -q -- '--owner'; then
tar --format=ustar --owner=0 --group=0 --numeric-owner -czf "$out_file" "$@"
else
tar -czf "$out_file" "$@"
fi
}
(
cd "$CONTROL_DIR"
tar_create_root "$PKG_DIR/control.tar.gz" control conffiles postinst prerm
)
(
cd "$DATA_DIR"
tar_create_root "$PKG_DIR/data.tar.gz" .
)
(
cd "$PKG_DIR"
# OpenWrt opkg on newer 24.10 builds expects this outer archive to be tar.gz,
# not the Debian-style ar container accepted by some older opkg versions. Use
# ustar to avoid pax extended headers that opkg reports as typeflag 0x78.
tar_create_root "$OUT_DIR/$PKG_FILE" debian-binary control.tar.gz data.tar.gz
)
echo "$OUT_DIR/$PKG_FILE"
+162
View File
@@ -0,0 +1,162 @@
#!/bin/sh
set -eu
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
REPOSITORY="tpxcer/minigate"
LATEST_ROOT="https://github.com/${REPOSITORY}/releases/latest/download"
TMP_DIR=""
SOURCE_NAME=""
RELEASE_VERSION=""
EXPECTED_HASH=""
DOWNLOADER=""
info() {
printf '[minigate] %s\n' "$*"
}
die() {
printf '[minigate] 安装失败:%s\n' "$*" >&2
exit 1
}
cleanup() {
case "$TMP_DIR" in
/tmp/minigate.*) rm -rf -- "$TMP_DIR" ;;
esac
}
check_system() {
[ "$(id -u)" = "0" ] || die "请使用 root 用户运行"
[ -r /etc/openwrt_release ] || die "未检测到 OpenWrt / ImmortalWrt"
command -v tar >/dev/null 2>&1 || die "系统缺少 tar"
command -v awk >/dev/null 2>&1 || die "系统缺少 awk"
command -v sha256sum >/dev/null 2>&1 || die "系统缺少 sha256sum"
}
detect_downloader() {
if command -v curl >/dev/null 2>&1; then
DOWNLOADER="curl"
elif command -v wget >/dev/null 2>&1; then
DOWNLOADER="wget"
else
die "系统缺少 curl 和 wget"
fi
}
fetch_to_file() {
case "$DOWNLOADER" in
curl)
curl -fsSL --proto '=https' --tlsv1.2 \
--connect-timeout 10 --max-time 180 --retry 2 --retry-delay 1 \
-o "$2" "$1"
;;
wget)
wget -q -O "$2" "$1"
;;
esac
}
valid_version() {
printf '%s' "$1" | grep -Eq '^[0-9]{4}\.(0?[1-9]|1[0-2])\.(0?[1-9]|[12][0-9]|3[01])(-[1-9][0-9]*)?$'
}
read_release() {
release_data=$(awk '
$2 ~ /^minigate-v[0-9][0-9.]*(-[1-9][0-9]*)?-src\.tar\.gz$/ {
count++
hash=$1
file=$2
}
END {
if (count != 1) exit 1
print hash " " file
}
' "$1") || return 1
EXPECTED_HASH=${release_data%% *}
SOURCE_NAME=${release_data#* }
RELEASE_VERSION=${SOURCE_NAME#minigate-v}
RELEASE_VERSION=${RELEASE_VERSION%-src.tar.gz}
[ "${#EXPECTED_HASH}" = "64" ] || return 1
case "$EXPECTED_HASH" in
*[!0-9a-fA-F]*) return 1 ;;
esac
valid_version "$RELEASE_VERSION"
}
verify_source() {
actual_hash=$(sha256sum "$1" | awk '{print $1}')
expected_lower=$(printf '%s' "$EXPECTED_HASH" | tr 'A-F' 'a-f')
[ "$actual_hash" = "$expected_lower" ] || die "源码包 SHA-256 校验失败"
if tar -tzf "$1" | awk '{
path=$0
sub(/^\.\//, "", path)
if (path ~ /^\// || path ~ /(^|\/)\.\.(\/|$)/) bad=1
} END { exit bad ? 0 : 1 }'; then
die "源码包包含不安全路径"
fi
}
verify_metadata() {
source_dir="$1"
grep -Fxq "PKG_VERSION:=${RELEASE_VERSION}" "$source_dir/Makefile" || return 1
grep -Fxq "PKG_VERSION=\"${RELEASE_VERSION}\"" "$source_dir/scripts/build-ipk.sh" || return 1
grep -Fxq "CURRENT_VERSION=\"${RELEASE_VERSION}\"" "$source_dir/root/usr/lib/minigate/update.sh" || return 1
[ -f "$source_dir/install.sh" ] || return 1
}
download_release() {
attempt=1
while [ "$attempt" -le 3 ]; do
sums_before="$TMP_DIR/SHA256SUMS.before"
sums_after="$TMP_DIR/SHA256SUMS.after"
fetch_to_file "$LATEST_ROOT/SHA256SUMS" "$sums_before" || die "无法下载 SHA256SUMS"
read_release "$sums_before" || die "SHA256SUMS 中没有唯一、有效的 minigate 源码包"
info "正在下载 minigate v${RELEASE_VERSION}"
fetch_to_file "$LATEST_ROOT/$SOURCE_NAME" "$TMP_DIR/$SOURCE_NAME" || die "无法下载 $SOURCE_NAME"
fetch_to_file "$LATEST_ROOT/SHA256SUMS" "$sums_after" || die "无法再次确认 SHA256SUMS"
if cmp -s "$sums_before" "$sums_after"; then
return 0
fi
info "下载期间发现新版本,正在重新获取"
attempt=$((attempt + 1))
done
die "下载期间版本连续变化,请稍后重试"
}
restart_luci() {
rm -rf /tmp/luci-*cache* 2>/dev/null || true
if [ -x /etc/init.d/uhttpd ]; then
/etc/init.d/uhttpd restart
fi
}
main() {
check_system
detect_downloader
TMP_DIR=$(mktemp -d /tmp/minigate.XXXXXX) || die "无法创建临时目录"
trap cleanup 0
trap 'exit 1' 1 2 3 15
download_release
verify_source "$TMP_DIR/$SOURCE_NAME"
info "SHA-256 校验通过"
mkdir -p "$TMP_DIR/source"
tar -xzf "$TMP_DIR/$SOURCE_NAME" -C "$TMP_DIR/source" || die "源码包解压失败"
verify_metadata "$TMP_DIR/source" || die "源码包版本信息不一致"
/bin/sh "$TMP_DIR/source/install.sh"
restart_luci
info "minigate v${RELEASE_VERSION} 安装完成"
}
main "$@"
+117
View File
@@ -0,0 +1,117 @@
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 ddnsScript = path.join(root, 'root/usr/lib/minigate/ddns.sh');
const controller = fs.readFileSync(path.join(root, 'luasrc/controller/minigate.lua'), 'utf8');
const model = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/ddns.lua'), 'utf8');
const view = fs.readFileSync(path.join(root, 'luasrc/view/minigate/ddns_history.htm'), 'utf8');
function runHistory(script, options = {}) {
const historyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-ddns-history-'));
const command = [
'set -eu',
'export MINIGATE_DDNS_LIBRARY_ONLY=1',
`export MINIGATE_HISTORY_DIR=${JSON.stringify(historyDir)}`,
`export MINIGATE_HISTORY_WINDOW=${options.window || 86400}`,
`. ${JSON.stringify(ddnsScript)}`,
script
].join('\n');
const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr || result.stdout);
return { historyDir, output: result.stdout.trim() };
}
function readRows(file) {
return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean);
}
test('DDNS history records only actual IPv4 changes', () => {
const { historyDir, output } = runHistory(`
export MINIGATE_HISTORY_NOW=100000
record_ip_history cfg01 ipv4 198.51.100.10
printf sentinel > "$MINIGATE_HISTORY_DIR/cfg01.ipv4.tsv.lock"
export MINIGATE_HISTORY_NOW=100060
record_ip_history cfg01 ipv4 198.51.100.10
cat "$MINIGATE_HISTORY_DIR/cfg01.ipv4.tsv.lock"
export MINIGATE_HISTORY_NOW=100120
record_ip_history cfg01 ipv4 198.51.100.11
`);
assert.equal(output, 'sentinel', 'unchanged IP must not reopen the history lock file');
assert.deepEqual(readRows(path.join(historyDir, 'cfg01.ipv4.tsv')), [
'100000\t198.51.100.10',
'100120\t198.51.100.11'
]);
assert.equal(fs.statSync(path.join(historyDir, 'cfg01.ipv4.tsv')).mode & 0o777, 0o600);
});
test('DDNS history keeps one anchor before the rolling window', () => {
const { historyDir } = runHistory(`
export MINIGATE_HISTORY_NOW=100
record_ip_history cfg02 ipv4 192.0.2.1
export MINIGATE_HISTORY_NOW=150
record_ip_history cfg02 ipv4 192.0.2.2
export MINIGATE_HISTORY_NOW=250
record_ip_history cfg02 ipv4 192.0.2.3
export MINIGATE_HISTORY_NOW=400
record_ip_history cfg02 ipv4 192.0.2.4
`, { window: 100 });
assert.deepEqual(readRows(path.join(historyDir, 'cfg02.ipv4.tsv')), [
'250\t192.0.2.3',
'400\t192.0.2.4'
]);
});
test('IPv4 and IPv6 histories stay separate and domain names are safe', () => {
const { historyDir } = runHistory(`
export MINIGATE_HISTORY_NOW=200000
record_ip_history '*.example.com' ipv4 203.0.113.7
record_ip_history '*.example.com' ipv6 2001:db8::7
`);
assert.deepEqual(readRows(path.join(historyDir, '__example_com.ipv4.tsv')), ['200000\t203.0.113.7']);
assert.deepEqual(readRows(path.join(historyDir, '__example_com.ipv6.tsv')), ['200000\t2001:db8::7']);
});
test('DDNS writer and controller both use the domain history key', () => {
const shellKey = fs.readFileSync(ddnsScript, 'utf8');
assert.match(shellKey, /record_ip_history "\$domain" "ipv4" "\$ip4"/);
assert.match(shellKey, /record_ip_history "\$domain" "ipv6" "\$ip6"/);
assert.match(controller, /history_section_name\(section\.domain\) \.\. "\." \.\. family \.\. "\.tsv"/);
assert.doesNotMatch(controller, /history_section_name\(section\.name\)/);
});
test('controller exposes a read-only rolling 24-hour history endpoint', () => {
assert.match(controller, /entry\(\{"admin","services","minigate","ddns_history"\}, call\("action_ddns_history"\)\)/);
assert.match(controller, /local cutoff = now - 86400/);
assert.match(controller, /if end_time > cutoff and event\.timestamp <= now then/);
assert.match(controller, /duration=math\.max\(0, end_time - event\.timestamp\)/);
assert.match(controller, /active=\(next_event == nil\)/);
assert.doesNotMatch(controller, /ddns_history"\}, post\(/);
});
test('DDNS page includes responsive history controls and required columns', () => {
assert.match(model, /s\.template="minigate\/ddns_history"/);
for (const text of ['最近一天 IP 变化明细', 'IP 地址', '开始时间', '结束时间', '持续时间', '使用中']) {
assert.match(view, new RegExp(text));
}
assert.match(view, /@media\(max-width:640px\)/);
assert.match(view, /data-darkmode="true"/);
assert.match(view, /refresh\.disabled=true/);
assert.match(view, /textContent/);
});
test('DDNS page treats an empty JSON object as an empty array', () => {
const match = view.match(/function asArray\(value\)\{([\s\S]*?)\n \}/);
assert.ok(match, 'asArray helper must exist');
const asArray = Function('value', match[1]);
assert.deepEqual(asArray([]), []);
assert.deepEqual(asArray({}), []);
assert.equal(asArray({ unexpected: true }), null);
assert.equal(asArray(null), null);
assert.match(view, /nextRecords===null/);
assert.match(view, /records=nextRecords/);
});
+151
View File
@@ -0,0 +1,151 @@
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 installerPath = path.join(root, 'scripts/install.sh');
const source = fs.readFileSync(installerPath, 'utf8');
const sourceInstaller = fs.readFileSync(path.join(root, 'install.sh'), 'utf8');
const sourceDependencyFunctions = sourceInstaller.slice(
sourceInstaller.indexOf('dependency_ready()'),
sourceInstaller.indexOf('\n# 检测包管理器')
);
const functions = source.slice(source.indexOf('info()'), source.lastIndexOf('\nmain "$@"'));
const program = source.slice(0, source.lastIndexOf('\nmain "$@"'));
const currentVersion = fs.readFileSync(path.join(root, 'Makefile'), 'utf8').match(/^PKG_VERSION:=(.+)$/m)[1];
function shell(body, env = {}) {
return spawnSync('/bin/sh', ['-c', body], {
encoding: 'utf8',
env: { ...process.env, ...env }
});
}
test('online installer is valid POSIX shell and uses the fixed repository', () => {
const result = spawnSync('/bin/sh', ['-n', installerPath], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.match(source, /^REPOSITORY="tpxcer\/minigate"$/m);
assert.match(source, /releases\/latest\/download/);
});
test('source installer skips package installation when a working OpenSSL CLI already exists', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-source-deps-'));
const calls = path.join(tempDir, 'apk-calls');
const openssl = path.join(tempDir, 'openssl');
const apk = path.join(tempDir, 'apk');
fs.writeFileSync(openssl, '#!/bin/sh\n[ "$1" = version ]\n', { mode: 0o755 });
fs.writeFileSync(apk, `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(calls)}\n[ "$1" = info ] && [ "$3" != openssl-util ]\n`, { mode: 0o755 });
const result = shell(`${sourceDependencyFunctions}\ninstall_dependencies`, { PATH: tempDir });
assert.equal(result.status, 0, result.stderr);
const log = fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : '';
assert.doesNotMatch(log, /openssl-util/);
});
test('source installer installs openssl-util when OpenSSL CLI is unavailable', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-source-deps-'));
const calls = path.join(tempDir, 'apk-calls');
const apk = path.join(tempDir, 'apk');
fs.writeFileSync(apk, `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(calls)}\nif [ "$1" = info ]; then [ "$3" != openssl-util ]; else exit 0; fi\n`, { mode: 0o755 });
const result = shell(`${sourceDependencyFunctions}\ninstall_dependencies`, { PATH: tempDir });
assert.equal(result.status, 0, result.stderr);
assert.match(fs.readFileSync(calls, 'utf8'), /add openssl-util/);
});
test('release manifest selects one valid minigate source asset', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-install-test-'));
const sumsPath = path.join(tempDir, 'SHA256SUMS');
const hash = 'a'.repeat(64);
fs.writeFileSync(sumsPath, [
`${'b'.repeat(64)} minigate_2026.9.14-3_all.ipk`,
`${hash} minigate-v2026.9.14-3-src.tar.gz`,
`${hash} luci-app-minigate-v2026.9.14-3-src.tar.gz`
].join('\n'));
const result = shell(`${functions}\nread_release "$SUMS" && printf '%s|%s|%s' "$RELEASE_VERSION" "$SOURCE_NAME" "$EXPECTED_HASH"`, { SUMS: sumsPath });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, `2026.9.14-3|minigate-v2026.9.14-3-src.tar.gz|${hash}`);
});
test('release manifest rejects duplicate or malformed source entries', () => {
for (const entries of [
[`${'a'.repeat(64)} minigate-v2026.9.14-3-src.tar.gz`, `${'b'.repeat(64)} minigate-v2026.9.14-4-src.tar.gz`],
[`${'a'.repeat(64)} minigate-v2026.9.14-0-src.tar.gz`],
[`not-a-hash minigate-v2026.9.14-3-src.tar.gz`]
]) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-install-test-'));
const sumsPath = path.join(tempDir, 'SHA256SUMS');
fs.writeFileSync(sumsPath, entries.join('\n'));
const result = shell(`${functions}\nread_release "$SUMS"`, { SUMS: sumsPath });
assert.notEqual(result.status, 0, entries.join(','));
}
});
test('source metadata must match the selected release version', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-install-test-'));
fs.mkdirSync(path.join(tempDir, 'scripts'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'root/usr/lib/minigate'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'Makefile'), 'PKG_VERSION:=2026.9.14-3\n');
fs.writeFileSync(path.join(tempDir, 'scripts/build-ipk.sh'), 'PKG_VERSION="2026.9.14-3"\n');
fs.writeFileSync(path.join(tempDir, 'root/usr/lib/minigate/update.sh'), 'CURRENT_VERSION="2026.9.14-3"\n');
fs.writeFileSync(path.join(tempDir, 'install.sh'), '#!/bin/sh\n');
const valid = shell(`${functions}\nRELEASE_VERSION=2026.9.14-3; verify_metadata "$SOURCE_DIR"`, { SOURCE_DIR: tempDir });
assert.equal(valid.status, 0, valid.stderr);
const invalid = shell(`${functions}\nRELEASE_VERSION=2026.9.14-4; verify_metadata "$SOURCE_DIR"`, { SOURCE_DIR: tempDir });
assert.notEqual(invalid.status, 0);
});
test('online installer verifies and installs a mocked latest release end to end', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-install-test-'));
const sourceDir = path.join(tempDir, 'source');
const archivePath = path.join(tempDir, `minigate-v${currentVersion}-src.tar.gz`);
const sumsPath = path.join(tempDir, 'SHA256SUMS');
const markerPath = path.join(tempDir, 'installed');
fs.mkdirSync(path.join(sourceDir, 'scripts'), { recursive: true });
fs.mkdirSync(path.join(sourceDir, 'root/usr/lib/minigate'), { recursive: true });
fs.writeFileSync(path.join(sourceDir, 'Makefile'), `PKG_VERSION:=${currentVersion}\n`);
fs.writeFileSync(path.join(sourceDir, 'scripts/build-ipk.sh'), `PKG_VERSION="${currentVersion}"\n`);
fs.writeFileSync(path.join(sourceDir, 'root/usr/lib/minigate/update.sh'), `CURRENT_VERSION="${currentVersion}"\n`);
fs.writeFileSync(path.join(sourceDir, 'install.sh'), '#!/bin/sh\nprintf installed > "$INSTALL_MARKER"\n');
const tar = spawnSync('tar', ['-czf', archivePath, '-C', sourceDir, '.'], { encoding: 'utf8' });
assert.equal(tar.status, 0, tar.stderr);
const digest = spawnSync('shasum', ['-a', '256', archivePath], { encoding: 'utf8' }).stdout.split(/\s+/)[0];
fs.writeFileSync(sumsPath, `${digest} ${path.basename(archivePath)}\n`);
const result = shell(`${program}
check_system() { :; }
detect_downloader() { DOWNLOADER=test; }
fetch_to_file() {
case "$1" in
*/SHA256SUMS) cp "$TEST_SUMS" "$2" ;;
*/minigate-v*-src.tar.gz) cp "$TEST_ARCHIVE" "$2" ;;
*) return 1 ;;
esac
}
restart_luci() { printf restarted >> "$INSTALL_MARKER"; }
main
`, { TEST_SUMS: sumsPath, TEST_ARCHIVE: archivePath, INSTALL_MARKER: markerPath });
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(markerPath, 'utf8'), 'installedrestarted');
assert.match(result.stdout, new RegExp(`minigate v${currentVersion.replaceAll('.', '\\.')} 安装完成`));
});
test('online installer stops before installation when SHA-256 does not match', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-install-test-'));
const archivePath = path.join(tempDir, 'source.tar.gz');
fs.writeFileSync(archivePath, 'not an archive');
const result = shell(`${functions}\nEXPECTED_HASH=${'0'.repeat(64)}; verify_source "$ARCHIVE"`, { ARCHIVE: archivePath });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /SHA-256/);
});
test('README exposes the short command and keeps a fixed-version fallback', () => {
const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8');
assert.match(readme, /curl -fsSL https:\/\/raw\.githubusercontent\.com\/tpxcer\/minigate\/main\/scripts\/install\.sh \| sh/);
assert.ok(readme.includes(`releases/download/v${currentVersion}/minigate-v${currentVersion}-src.tar.gz`));
});
+114
View File
@@ -0,0 +1,114 @@
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/uninstall.sh');
function write(base, relative, content = relative) {
const target = path.join(base, relative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, content);
return target;
}
function fixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-uninstall-test-'));
write(dir, 'etc/config/minigate', 'config-data');
write(dir, 'etc/minigate/certs/site.pem', 'certificate-data');
write(dir, 'etc/minigate/login-guard/bans.txt', '198.51.100.4 9999999999');
write(dir, 'etc/crontabs/root', '0 1 * * * /bin/other\n*/5 * * * * /bin/sh /usr/lib/minigate/ddns.sh\n');
write(dir, 'etc/init.d/minigate');
write(dir, 'usr/lib/minigate/update.sh');
write(dir, 'usr/lib/minigate/uninstall.sh');
write(dir, 'usr/lib/lua/luci/controller/minigate.lua');
write(dir, 'usr/lib/lua/luci/model/cbi/minigate/general.lua');
write(dir, 'usr/lib/lua/luci/view/minigate/log.htm');
write(dir, 'usr/lib/lua/luci/i18n/minigate.zh-cn.lmo');
write(dir, 'usr/lib/lua/luci/controller/keep.lua');
write(dir, 'var/log/minigate-access.log', 'access-log');
write(dir, 'var/run/minigate/socket');
write(dir, 'var/run/minigate-nginx.pid', '123');
write(dir, 'tmp/minigate-update-state.json', '{}');
write(dir, 'tmp/minigate_proxy_443.tmp');
write(dir, 'tmp/luci-indexcache');
return dir;
}
function run(dir, purge) {
return spawnSync('/bin/sh', [script, purge], {
encoding: 'utf8',
env: {
...process.env,
MINIGATE_ROOT: dir,
MINIGATE_SKIP_PACKAGES: '1',
MINIGATE_SKIP_SERVICES: '1'
}
});
}
test('default uninstall removes the app but preserves configuration, certificates, bans and logs', () => {
const dir = fixture();
const result = run(dir, '0');
assert.equal(result.status, 0, result.stderr);
for (const relative of [
'etc/init.d/minigate',
'usr/lib/minigate',
'usr/lib/lua/luci/controller/minigate.lua',
'usr/lib/lua/luci/model/cbi/minigate',
'usr/lib/lua/luci/view/minigate',
'usr/lib/lua/luci/i18n/minigate.zh-cn.lmo',
'var/run/minigate',
'var/run/minigate-nginx.pid',
'tmp/minigate-update-state.json',
'tmp/minigate_proxy_443.tmp',
'tmp/luci-indexcache'
]) assert.equal(fs.existsSync(path.join(dir, relative)), false, relative);
assert.equal(fs.readFileSync(path.join(dir, 'etc/config/minigate'), 'utf8'), 'config-data');
assert.equal(fs.readFileSync(path.join(dir, 'etc/minigate/certs/site.pem'), 'utf8'), 'certificate-data');
assert.equal(fs.readFileSync(path.join(dir, 'etc/minigate/login-guard/bans.txt'), 'utf8'), '198.51.100.4 9999999999');
assert.equal(fs.readFileSync(path.join(dir, 'var/log/minigate-access.log'), 'utf8'), 'access-log');
assert.equal(fs.readFileSync(path.join(dir, 'etc/crontabs/root'), 'utf8'), '0 1 * * * /bin/other\n');
assert.equal(fs.existsSync(path.join(dir, 'usr/lib/lua/luci/controller/keep.lua')), true);
});
test('purge uninstall also removes configuration, certificates, bans and logs', () => {
const dir = fixture();
const result = run(dir, '1');
assert.equal(result.status, 0, result.stderr);
for (const relative of ['etc/config/minigate', 'etc/minigate', 'var/log/minigate-access.log', 'usr/lib/minigate']) {
assert.equal(fs.existsSync(path.join(dir, relative)), false, relative);
}
assert.equal(fs.existsSync(path.join(dir, 'usr/lib/lua/luci/controller/keep.lua')), true);
});
test('invalid purge mode is rejected before files are changed', () => {
const dir = fixture();
const result = run(dir, 'yes');
assert.equal(result.status, 2);
assert.equal(fs.existsSync(path.join(dir, 'usr/lib/minigate/update.sh')), true);
assert.equal(fs.existsSync(path.join(dir, 'etc/config/minigate')), true);
});
test('LuCI exposes only a confirmed POST uninstall action and runs a temporary script copy', () => {
const controller = fs.readFileSync(path.join(root, 'luasrc/controller/minigate.lua'), 'utf8');
assert.match(controller, /\{"admin","services","minigate","uninstall"\}, post\("action_uninstall"\)/);
assert.match(controller, /confirm ~= "uninstall-minigate"/);
assert.match(controller, /purge ~= "0" and purge ~= "1"/);
assert.match(controller, /mktemp \/tmp\/minigate-uninstall\.XXXXXX/);
assert.match(controller, /sleep 1; \/bin\/sh/);
});
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(source, /opkg remove "\$package"/);
assert.match(source, /apk del "\$package"/);
assert.match(source, /for package in minigate luci-app-minigate/);
});
+292
View File
@@ -0,0 +1,292 @@
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 vm = require('node:vm');
const { spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const source = fs.readFileSync(path.join(root, 'root/usr/lib/minigate/update.sh'), 'utf8');
const functions = source.slice(source.indexOf('json_escape()'), source.lastIndexOf('\ncase "${1:-status}"'));
const version = source.match(/^CURRENT_VERSION="([^"]+)"/m)[1];
const quote = value => "'" + value.replaceAll("'", "'\"'\"'") + "'";
function shell(body, env = {}) {
const result = spawnSync('/bin/sh', ['-c', body], { encoding: 'utf8', env: { ...process.env, ...env } });
assert.equal(result.error, undefined);
return result;
}
function backend(body, options = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-test-'));
const fixture = { tag_name: `v${options.latest || version}`, name: 'minigate', body: 'Update notes' };
fs.writeFileSync(path.join(dir, 'fixture.json'), JSON.stringify(fixture));
fs.writeFileSync(path.join(dir, 'jsonfilter.cjs'), `
const fs = require('node:fs');
const args = process.argv.slice(2);
try {
const value = JSON.parse(fs.readFileSync(args[args.indexOf('-i')+1], 'utf8'));
const result = value[args[args.indexOf('-e')+1].slice(2)];
if (result !== undefined) console.log(result);
} catch (_) { process.exitCode = 1; }
`);
const result = shell(`
CURRENT_VERSION=${quote(version)}
STATE_CURRENT="$CURRENT_VERSION"
STATE_FILE="$TEST_DIR/state.json"
RELEASE_FILE="$TEST_DIR/release.json"
LAST_CHECK_FILE="$TEST_DIR/last-check"
LOCK_FILE="$TEST_DIR/lock"
TMP_ROOT="$TEST_DIR/work"
RUN_LOG="$TEST_DIR/log"
API_URL=https://example.invalid/latest
${functions}
jsonfilter() { "$NODE" "$TEST_DIR/jsonfilter.cjs" "$@"; }
flock() {
printf '%s\n' "$*" >> "$TEST_DIR/flocks"
[ "$1" = "-u" ] && return 0
test "\${FLOCK_BUSY:-0}" = 0
}
download_file() {
printf 'fetch\\n' >> "$TEST_DIR/fetches"
test "\${FETCH_FAIL:-0}" = 0 || return 1
cp "$TEST_DIR/fixture.json" "$2"
}
backup_program() { echo 'UNEXPECTED INSTALL' >&2; exit 99; }
${body}
`, { TEST_DIR: dir, NODE: process.execPath, ...options.env });
return {
...result,
state: fs.existsSync(path.join(dir, 'state.json')) ? JSON.parse(fs.readFileSync(path.join(dir, 'state.json'))) : null,
fetches: fs.existsSync(path.join(dir, 'fetches')) ? fs.readFileSync(path.join(dir, 'fetches'), 'utf8').trim().split('\n').length : 0,
flocks: fs.existsSync(path.join(dir, 'flocks')) ? fs.readFileSync(path.join(dir, 'flocks'), 'utf8').trim().split('\n') : []
};
}
test('date versions compare numeric day and same-day sequence, including legacy version', () => {
for (const [left, right, newer] of [
['2026.9.14', '1.3.11', true], ['2026.9.14', '2026.9.14', false],
['2026.9.14-1', '2026.9.14', true], ['2026.9.14-10', '2026.9.14-2', true],
['2026.9.14-2', '2026.9.14-10', false], ['2026.9.15', '2026.9.14-99', true],
['2026.10.1', '2026.9.30-9', true], ['2027.1.1', '2026.12.31', true],
['1.3.11', '2026.9.14', false]
]) {
assert.equal(shell(`${functions}\nversion_newer ${quote(left)} ${quote(right)}`).status, newer ? 0 : 1, `${left} > ${right}`);
}
});
test('reject malformed versions and shell syntax in tags', () => {
for (const value of ['2026.9.14', '2026.9.14-1', '2026.9.14-10']) {
assert.equal(shell(`${functions}\nvalid_version ${quote(value)}`).status, 0, value);
}
for (const value of ['', 'latest', '1.3.11', '2026.13.14', '2026.9.0', '2026.9.14-0', '2026.9.14-01', '2026.9.14;id', '../2026.9.14']) {
assert.equal(shell(`${functions}\nvalid_version ${quote(value)}`).status, 1, value);
}
});
test('opening overview checks once then reuses a successful ten-minute cache', () => {
const result = backend('run_auto_check >/dev/null; run_auto_check');
assert.equal(result.status, 0, result.stderr);
assert.equal(result.fetches, 1);
assert.equal(result.state.status, 'up_to_date');
});
test('manual check, expired cache and changed installed version force a fresh check', () => {
for (const next of ['run_check', 'echo 1 > "$LAST_CHECK_FILE"; run_auto_check', 'CURRENT_VERSION=2020.1.1; STATE_CURRENT=$CURRENT_VERSION; run_auto_check']) {
const result = backend(`run_check >/dev/null; ${next}`);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.fetches, 2);
}
});
test('success/error/running state is not reused as a successful check cache', () => {
for (const state of ['success', 'error', 'installing']) {
const result = backend(`run_check >/dev/null; write_state ${state} 100 "$CURRENT_VERSION" false false true done; run_auto_check`);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.state.status, 'up_to_date');
assert.equal(result.fetches, 2);
}
});
test('busy updater is reported without downloading or starting another update', () => {
const result = backend('write_state installing 75 "2027.1.1" true true true busy; FLOCK_BUSY=1; run_auto_check');
assert.equal(result.status, 0, result.stderr);
assert.equal(result.state.status, 'installing');
assert.equal(result.fetches, 0);
});
test('apply releases its lock even when the locked operation returns early', () => {
const result = backend('run_apply_locked() { return 7; }; run_apply "$CURRENT_VERSION"');
assert.equal(result.status, 7, result.stderr);
assert.deepEqual(result.flocks, ['-n 9', '-u 9']);
});
test('long-running services are started with the update lock descriptor closed', () => {
const installer = fs.readFileSync(path.join(root, 'install.sh'), 'utf8');
assert.match(installer, /\/etc\/init\.d\/minigate start 9>&-/);
assert.match(source, /\/etc\/init\.d\/minigate start >> "\$RUN_LOG" 2>&1 9>&-/);
assert.equal((source.match(/\/etc\/init\.d\/uhttpd restart >> "\$RUN_LOG" 2>&1 9>&-/g) || []).length, 2);
});
test('network failure is recoverable and never starts installation', () => {
const result = backend('run_auto_check', { env: { FETCH_FAIL: '1' } });
assert.equal(result.status, 1);
assert.equal(result.state.status, 'error');
assert.equal(result.state.running, false);
});
test('install requires an explicitly confirmed version and rejects a changed release', () => {
const missing = backend('run_apply ""');
assert.equal(missing.status, 1);
assert.equal(missing.fetches, 0);
const changed = backend('run_apply "2027.1.1"', { latest: '2027.1.2' });
assert.equal(changed.status, 1);
assert.equal(changed.state.latest, '2027.1.2');
assert.equal(changed.state.available, true);
assert.equal(changed.state.running, false);
assert.match(changed.state.message, /重新/);
});
function overview() {
const lua = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/general.lua'), 'utf8');
const js = lua.slice(lua.indexOf('var _updatePolling'), lua.indexOf('var limitSel')).replace(/\]\] \.\. (\w+) \.\. \[\[/g, '$1');
const elements = new Map();
const element = id => {
if (!elements.has(id)) elements.set(id, { style: {}, className: '', textContent: '', disabled: false, checked: false, hidden: false, setAttribute() {}, focus() { document.activeElement = this; } });
return elements.get(id);
};
const listeners = {};
const document = {
body: { style: { overflow: 'auto' } }, activeElement: null,
getElementById: element, querySelector: () => null, addEventListener: (name, handler) => { listeners[name] = handler; }
};
const buttons = ['close', 'mg-update-modal-notes', 'cancel', 'mg-update-confirm'].map(element);
const uninstallItems = ['mg-uninstall-close', 'mg-uninstall-purge', 'mg-uninstall-cancel', 'mg-uninstall-confirm'].map(element);
element('mg-update-modal').querySelectorAll = () => buttons;
element('mg-update-modal').contains = item => buttons.includes(item);
element('mg-uninstall-modal').querySelectorAll = () => uninstallItems;
element('mg-uninstall-modal').contains = item => uninstallItems.includes(item);
const requests = [], timers = [];
const context = vm.createContext({ document, mgColor: value => value, location: { reload() {} }, setTimeout: callback => timers.push(callback), XHR: {
get: (url, data, callback) => requests.push({ method: 'GET', url, data, callback }),
post: (url, data, callback) => requests.push({ method: 'POST', url, data, callback })
} });
vm.runInContext(js, context);
const available = { status: 'available', current: version, latest: '2027.1.1', available: true, success: true, release_title: 'New release', release_notes: '<script>bad()</script>\n中文说明' };
return { context, document, element, requests, timers, available, buttons, uninstallItems, listeners };
}
test('overview automatically checks but does not install', () => {
const page = overview();
const lua = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/general.lua'), 'utf8');
assert.match(lua, /mgAutoCheckUpdate\(\);\s*<\/script>/);
page.context.mgAutoCheckUpdate();
assert.equal(page.requests.length, 1);
assert.equal(page.requests[0].method, 'GET');
assert.equal(page.requests[0].url, 'xu');
page.requests[0].callback(null, page.available);
page.context.mgApplyUpdate();
assert.equal(page.requests.length, 1);
assert.equal(page.element('mg-update-modal-notes').textContent, page.available.release_notes);
page.context.mgCloseUpdateDialog();
page.context.mgStartUpdate();
assert.equal(page.requests.length, 1);
assert.equal(page.document.body.style.overflow, 'auto');
});
test('confirm installs exactly the shown version, ignores double clicks and preserves retry notes', () => {
const page = overview();
page.context.mgRenderUpdate(page.available);
page.context.mgApplyUpdate();
page.context.mgRenderUpdate({ ...page.available, latest: '2027.1.2' });
page.context.mgStartUpdate();
page.context.mgStartUpdate();
assert.equal(page.requests.length, 1);
assert.equal(page.requests[0].method, 'POST');
assert.equal(page.requests[0].data.version, '2027.1.1');
page.requests[0].callback(null, { success: false, message: 'Retry' });
page.context.mgApplyUpdate();
assert.equal(page.element('mg-update-modal-notes').textContent, page.available.release_notes);
});
test('dialog traps keyboard focus and Escape cancels without installation', () => {
const page = overview();
page.context.mgRenderUpdate(page.available);
page.context.mgApplyUpdate();
page.buttons.at(-1).focus();
page.listeners.keydown({ key: 'Tab', preventDefault() {} });
assert.equal(page.document.activeElement, page.buttons[0]);
page.listeners.keydown({ key: 'Tab', shiftKey: true, preventDefault() {} });
assert.equal(page.document.activeElement, page.buttons.at(-1));
page.listeners.keydown({ key: 'Escape', preventDefault() {} });
assert.equal(page.element('mg-update-modal').className, 'mg-modal');
assert.equal(page.requests.length, 0);
});
test('uninstall defaults to preserving data and cancel, Escape and backdrop never submit', () => {
const page = overview();
page.element('mg-uninstall-purge').checked = true;
page.context.mgOpenUninstallDialog();
assert.equal(page.element('mg-uninstall-purge').checked, false);
assert.equal(page.element('mg-uninstall-modal').className, 'mg-modal is-open');
assert.match(page.element('mg-uninstall-detail').textContent, /默认保留/);
page.listeners.keydown({ key: 'Escape', preventDefault() {} });
assert.equal(page.requests.length, 0);
page.context.mgOpenUninstallDialog();
page.element('mg-uninstall-modal').onclick({ target: page.element('mg-uninstall-modal') });
assert.equal(page.requests.length, 0);
page.context.mgOpenUninstallDialog();
page.context.mgCloseUninstallDialog();
assert.equal(page.requests.length, 0);
});
test('uninstall submits an explicit purge choice once and redirects only after acceptance', () => {
const page = overview();
page.context.mgOpenUninstallDialog();
page.element('mg-uninstall-purge').checked = true;
page.context.mgUpdateUninstallChoice();
assert.equal(page.element('mg-uninstall-confirm').textContent, '彻底卸载');
page.context.mgStartUninstall();
page.context.mgStartUninstall();
assert.equal(page.requests.length, 1);
assert.equal(page.requests[0].method, 'POST');
assert.equal(page.requests[0].url, 'du');
assert.equal(page.requests[0].data.confirm, 'uninstall-minigate');
assert.equal(page.requests[0].data.purge, '1');
page.requests[0].callback(null, { success: true });
assert.match(page.element('mg-uninstall-status').textContent, /返回服务页面/);
assert.equal(page.timers.length, 1);
page.timers[0]();
assert.equal(page.context.location.href, 'services_url');
});
test('a rejected uninstall remains open and can be retried', () => {
const page = overview();
page.context.mgOpenUninstallDialog();
page.context.mgStartUninstall();
page.requests[0].callback(null, { success: false, message: 'busy' });
assert.equal(page.element('mg-uninstall-modal').className, 'mg-modal is-open');
assert.equal(page.element('mg-uninstall-confirm').disabled, false);
assert.equal(page.element('mg-uninstall-status').textContent, 'busy');
page.context.mgStartUninstall();
assert.equal(page.requests.length, 2);
});
test('an old success state never reloads an overview that did not start the update', () => {
const page = overview();
page.context.mgRenderUpdate({ ...page.available, status: 'success', available: false });
assert.equal(page.timers.length, 0);
});
test('build metadata has a consistent version and the actual minigate package identity', () => {
const makefile = fs.readFileSync(path.join(root, 'Makefile'), 'utf8');
const build = fs.readFileSync(path.join(root, 'scripts/build-ipk.sh'), 'utf8');
assert.equal(makefile.match(/^PKG_VERSION:=(.+)$/m)[1], version);
assert.equal(build.match(/^PKG_VERSION="(.+)"$/m)[1], version);
assert.match(makefile, /^PKG_NAME:=minigate$/m);
assert.match(build, /^PKG_NAME="minigate"$/m);
assert.equal((build.match(/^Version:/gm) || []).length, 1);
assert.ok(fs.existsSync(path.join(root, `releases/v${version}.md`)));
});