mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-02 11:23:56 +08:00
💐 Sync 2026-10-06 12:33:44
This commit is contained in:
1 parent
8be03ce939
commit
7fe69dbb3a
246 files changed
+50330
-3414
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# Build artifacts
|
||||
*.ipk
|
||||
*.apk
|
||||
*.tar.gz
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)))
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 ""
|
||||
@@ -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("&","&"):gsub("<","<"):gsub(">",">")
|
||||
v=v:gsub('"',"""):gsub("'","'")
|
||||
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">✓ 已安装</span>'
|
||||
elseif fs.access("/etc/minigate/acme/data/acme.sh")then return'<span style="color:#ff9800">⚠ 缺DNS插件</span>'
|
||||
else return'<span style="color:#f44336">✗ 未安装</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">⏳</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
|
||||
@@ -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">✓ 正常</span>'
|
||||
elseif st=="partial"then
|
||||
h='<span style="color:#ff9800">⚠ 部分成功</span>'
|
||||
elseif st=="error"then
|
||||
h='<span style="color:#f44336">✗ '..(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()">×</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()">×</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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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("&", "&"):gsub("<", "<"):gsub(">", ">")
|
||||
v = v:gsub('"', """):gsub("'", "'")
|
||||
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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
@@ -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%>
|
||||
@@ -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."
|
||||
@@ -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 反代生成逻辑,不改变现有配置文件格式。
|
||||
@@ -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 配置格式。
|
||||
@@ -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 项目,不自动安装到软路由。
|
||||
@@ -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`,兼容旧更新器识别的源码包名。
|
||||
- 本次本地实现不会自动安装到软路由,也不会自动卸载现有实例。
|
||||
@@ -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 更新,不自动安装到任何软路由。
|
||||
@@ -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 中重新检查更新后手动更新。
|
||||
@@ -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 中手动更新后终验。
|
||||
@@ -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`,兼容旧更新器识别的源码包名。
|
||||
- 本次发布不会自动安装到软路由。
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
Executable
+266
@@ -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})"
|
||||
Executable
+357
@@ -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
|
||||
Executable
+123
@@ -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"
|
||||
Executable
+162
@@ -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 "$@"
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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`));
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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`)));
|
||||
});
|
||||
Reference in new issue
Block a user