mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-15 04:53:00 +08:00
🎁 Sync 2026-10-28 19:35:15
This commit is contained in:
1 parent
85209f310d
commit
390801c844
90 files changed
+5821
-1967
No files matched your search
+9
-10
@@ -1,8 +1,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=minigate
|
||||
PKG_VERSION:=2026.10.26
|
||||
PKG_RELEASE:=7
|
||||
PKG_VERSION:=2026.10.28
|
||||
PKG_RELEASE:=8
|
||||
PKG_LICENSE:=MIT
|
||||
PKG_MAINTAINER:=MiniGate
|
||||
|
||||
@@ -43,16 +43,15 @@ define Package/$(PKG_NAME)/install
|
||||
|
||||
# Backend scripts
|
||||
$(INSTALL_DIR) $(1)/usr/lib/minigate
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/ddns.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/acme.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/proxy.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/geofence.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/login_guard.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/update.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/uninstall.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/after_commit.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/*.sh $(1)/usr/lib/minigate/
|
||||
$(CP) ./root/usr/lib/minigate/geo $(1)/usr/lib/minigate/
|
||||
|
||||
# Login guard nftables sets and rules, loaded by fw4 on every reload
|
||||
$(INSTALL_DIR) $(1)/usr/share/nftables.d/table-pre
|
||||
$(INSTALL_DIR) $(1)/usr/share/nftables.d/chain-pre/input
|
||||
$(INSTALL_DATA) ./root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft $(1)/usr/share/nftables.d/table-pre/
|
||||
$(INSTALL_DATA) ./root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft $(1)/usr/share/nftables.d/chain-pre/input/
|
||||
|
||||
# Config
|
||||
$(INSTALL_DIR) $(1)/etc/config
|
||||
$(CP) ./root/etc/config/minigate $(1)/etc/config/
|
||||
|
||||
+79
-48
@@ -1,14 +1,14 @@
|
||||
# minigate - OpenWrt 轻量网关管理
|
||||
# MiniGate - OpenWrt 轻量网关管理
|
||||
|
||||
一个类似 Lucky 的轻量级 OpenWrt 应用,提供四大核心功能:DDNS、SSL 证书、反向代理、登录防护。
|
||||
|
||||
当前版本:**v2026.10.26**。项目名称、仓库名及 OpenWrt 内部包名均为 **`minigate`**。
|
||||
当前版本:**v2026.10.28**。界面名称为 **MiniGate**,仓库名及 OpenWrt 内部包名均为 **`minigate`**。
|
||||
|
||||
> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.26_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。
|
||||
> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.28_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。
|
||||
|
||||
## 项目定位
|
||||
|
||||
minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户,目标是在 LuCI 中集中完成域名解析、证书签发、反向代理和登录防护配置,减少在路由器上手动维护多组脚本、Nginx 配置和防火墙规则的成本。
|
||||
MiniGate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户,目标是在 LuCI 中集中完成域名解析、证书签发、反向代理和登录防护配置,减少在路由器上手动维护多组脚本、Nginx 配置和防火墙规则的成本。
|
||||
|
||||
项目重点关注以下场景:
|
||||
|
||||
@@ -36,54 +36,63 @@ minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户
|
||||
- 自动检测 WAN IP 变化并更新 DNS 记录
|
||||
- 公网记录变化成功后无重启刷新 sing-box 与 dnsmasq DNS 缓存,减少局域网继续命中旧地址
|
||||
- 同名同类型记录重复时,保留匹配当前 IP 的记录并清理其余旧记录,避免旧 IP 继续参与解析
|
||||
- 支持从网络接口或外部 URL 获取 IP
|
||||
- 支持从网络接口或外部 URL 获取 IP;外部查询默认用 HTTPS,失败时依次回退
|
||||
- 只向 Cloudflare 推送公网地址:软路由在上级路由 NAT 之后时,网卡上的 192.168.x 等内网地址会被拒绝并提示改用外部 URL
|
||||
- 运行状态保存在 `/var/run/minigate`,定时检查不再改写 `/etc/config/minigate`
|
||||
- 每条记录可配置检查间隔(最小 5 分钟,按 5 分钟取整)
|
||||
- 一键手动触发更新
|
||||
- 一键手动触发更新,在后台执行并原地刷新状态,不会丢掉表单里未保存的修改
|
||||
- 最近一天 IP 变化明细,显示 IPv4/IPv6 地址、开始时间、结束时间和持续时长
|
||||
|
||||
### 🔒 SSL/TLS 证书 (ACME)
|
||||
- **Let's Encrypt** 自动证书签发
|
||||
- 使用 **DNS-01** 挑战方式(Cloudflare)
|
||||
- 支持 ECC / RSA 多种密钥类型
|
||||
- 自动续期(通过 cron 每天检查)
|
||||
- 自动续期(通过 cron 每天检查);剩余超过 30 天时手动签发不会重复申请,避免触发频率限制
|
||||
- 证书列表显示到期日期、剩余天数,并区分测试证书和正式证书
|
||||
- acme.sh 固定为 3.1.6 版本,下载后校验 SHA-256 再安装
|
||||
- 签发在后台执行,页面轮询结果,不受网页请求超时影响
|
||||
- Staging 模式用于测试
|
||||
|
||||
### 🔄 反向代理
|
||||
- 基于 **Nginx** 的轻量反向代理
|
||||
- **IPv6 监听**支持(listen [::]:port 双栈)
|
||||
- HTTP/2、WebSocket 支持
|
||||
- HTTP/2;所有站点自动支持 WebSocket,无需逐条开启
|
||||
- 自动 SSL 证书关联
|
||||
- 已启用 HTTPS 的任意监听端口可自动识别明文 HTTP,并以 308 跳转到同域名、同端口的 HTTPS
|
||||
- 安全 Headers(HSTS 等)
|
||||
- 多站点管理
|
||||
- 多站点管理;子域名可单独停用,通配符主域名停用时其子域名一并停用
|
||||
- 同一端口只能用于一种协议(HTTP 或 HTTPS),冲突的规则会被跳过,并在总览页提示原因
|
||||
- 配置检查失败时自动恢复上一份可用配置,正在运行的站点不受影响;缺少证书的站点会在总览页提示
|
||||
- Nginx 由 procd 管理,异常退出后自动拉起
|
||||
- **直接 IP 访问拒绝**:只允许域名访问,扫描器直接断开
|
||||
- **未知 Host 拒绝**:同一监听端口下,未配置的域名/前缀不会落到第一个反代站点
|
||||
- **局域网直连解析**(可选):内网设备把代理域名直接解析到本机 LAN 地址,公网 IP 变化、上游 DNS 缓存过期或主路由不支持回流时,内网访问不受影响,流量也不进入代理
|
||||
- **公网访问范围**(可选):选择允许访问的省级地区,IPv4/IPv6 在首次转发前检查,未命中白名单直接断开;局域网私有地址继续可用
|
||||
|
||||
### 🛡 登录防护 (Login Guard)
|
||||
- 监控 **SSH(dropbear)** 和 **LuCI 网页** 登录失败
|
||||
- 监控 **SSH(dropbear)** 和 **LuCI 网页** 登录失败,支持 **IPv4 和 IPv6** 来源
|
||||
- 在「失败窗口」内累积达到阈值(默认 3 次/10 分钟)→ 自动用 nftables 封禁源 IP
|
||||
- 可配置封禁时长(1 小时 / 6 小时 / **12 小时(默认)** / 24 小时 / 1 周 / 30 天)
|
||||
- **LAN 私有段自动豁免**(192.168.x / 10.x / 127.x / 172.16-31.x),可加额外白名单
|
||||
- **LAN 私有段自动豁免**(192.168.x / 10.x / 127.x / 172.16-31.x、IPv6 ULA / 链路本地,以及 LAN 分配的 IPv6 子网),可加额外白名单(IPv4/IPv6,任意前缀长度)
|
||||
- **滑动时间窗口**:超过窗口未再失败则计数自动清零
|
||||
- 持久化存储 `/etc/minigate/login-guard/bans.txt`,重启/固件升级后已生效封禁自动恢复
|
||||
- LuCI 页面实时显示封禁列表、剩余时间、归属地(结合归属地查询 API)
|
||||
- 失败计数列表默认显示 5 条,支持切换 5 / 20 / 30 条,并复用访客追踪归属地查询;显示最近访问时间并按最新时间排序
|
||||
- 支持网页**一键解封**、**手动封禁**、**清空全部**
|
||||
- 后台 watchdog 每 5 分钟自检 nft set,防 fw4 reload 后规则丢失
|
||||
- 支持网页**一键解封**、**手动封禁**、**清空全部**(清空按钮在列表底部,避免误点)
|
||||
- 封禁集合与拦截规则随包放在 `/usr/share/nftables.d`,防火墙每次重载自动加载;重载清空的封禁 30 秒内从持久化文件恢复
|
||||
- 由 procd 管理,异常退出后自动拉起;停止时结束所有子进程
|
||||
|
||||
### 👁 访客追踪
|
||||
- 总览页面显示来源 IP、归属地、最近请求结果、HTTP 状态及转发/拦截次数;被拒绝的扫描也保留在记录中
|
||||
- 「近5分钟有请求」仅表示近期出现请求,不代表访客在线;已转发也不代表已登录。旧日志缺少上游响应信息时,只能标记为已响应、请求失败或结果未知,不推断为准入放行
|
||||
- IP 归属地后端查询(ip9.com.cn → ip-api.com → pconline 多源回退)
|
||||
- IP 归属地优先查随包的离线省级地址库,命中时不把访客 IP 发给外部服务;境外地址再用 HTTPS 接口(ip9.com.cn → pconline)查询,缓存最多保留 500 条
|
||||
|
||||
### 软件更新
|
||||
- 登录 LuCI 后打开 **服务 → minigate → 总览**,自动检查 GitHub 最新正式版;不会自动下载或安装
|
||||
- 登录 LuCI 后打开 **服务 → MiniGate → 总览**,自动检查 GitHub 最新正式版;不会自动下载或安装
|
||||
- 10 分钟内复用成功检查结果;「检查更新」可随时手动重新检查
|
||||
- 点击「一键更新」先显示本次 Release 更新内容,确认后才开始安装
|
||||
- 安装前校验 SHA-256 并备份程序和配置,安装或服务验证失败时尝试回滚
|
||||
- 总览标题区提供「卸载」入口;二次确认默认保留配置、证书、封禁记录和日志,也可主动选择彻底清理
|
||||
- 总览页底部「危险操作」区提供「卸载」入口;二次确认默认保留配置、证书、封禁记录和日志,也可主动选择彻底清理
|
||||
|
||||
---
|
||||
|
||||
@@ -93,20 +102,20 @@ minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户
|
||||
|
||||
从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载:
|
||||
|
||||
`minigate_2026.10.26_all.ipk`
|
||||
`minigate_2026.10.28_all.ipk`
|
||||
|
||||
**通过 LuCI 界面安装:**
|
||||
1. 打开 LuCI → **系统** → **软件包**
|
||||
2. 点击 **上传软件包**
|
||||
3. 选择 `minigate_2026.10.26_all.ipk`,点击安装
|
||||
3. 选择 `minigate_2026.10.28_all.ipk`,点击安装
|
||||
|
||||
**通过命令行安装:**
|
||||
|
||||
```bash
|
||||
cd /tmp
|
||||
wget -O minigate_2026.10.26_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate_2026.10.26_all.ipk
|
||||
wget -O minigate_2026.10.28_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.28/minigate_2026.10.28_all.ipk
|
||||
opkg update
|
||||
opkg install /tmp/minigate_2026.10.26_all.ipk
|
||||
opkg install /tmp/minigate_2026.10.28_all.ipk
|
||||
rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
|
||||
/etc/init.d/uhttpd restart
|
||||
/etc/init.d/minigate restart
|
||||
@@ -125,7 +134,7 @@ Release 用的 `.ipk` 请用仓库脚本生成,避免生成 Debian 风格 ar
|
||||
脚本会输出:
|
||||
|
||||
```text
|
||||
dist/minigate_2026.10.26_all.ipk
|
||||
dist/minigate_2026.10.28_all.ipk
|
||||
```
|
||||
|
||||
该文件外层是 OpenWrt/ImmortalWrt 24.10 兼容的 `tar.gz`,内部成员顺序为 `debian-binary`、`control.tar.gz`、`data.tar.gz`。
|
||||
@@ -153,7 +162,7 @@ wget -qO- https://raw.githubusercontent.com/tpxcer/minigate/main/scripts/install
|
||||
需要指定并核对具体版本时,执行:
|
||||
|
||||
```bash
|
||||
d="$(mktemp -d /tmp/minigate.XXXXXX)" && cd "$d" && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate-v2026.10.26-src.tar.gz && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.26/SHA256SUMS && grep 'minigate-v2026.10.26-src.tar.gz$' SHA256SUMS | sha256sum -c - && tar xzf minigate-v2026.10.26-src.tar.gz && sh install.sh && /etc/init.d/uhttpd restart && /etc/init.d/minigate restart
|
||||
d="$(mktemp -d /tmp/minigate.XXXXXX)" && cd "$d" && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.28/minigate-v2026.10.28-src.tar.gz && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.28/SHA256SUMS && grep 'minigate-v2026.10.28-src.tar.gz$' SHA256SUMS | sha256sum -c - && tar xzf minigate-v2026.10.28-src.tar.gz && sh install.sh && /etc/init.d/uhttpd restart && /etc/init.d/minigate restart
|
||||
```
|
||||
|
||||
其中 `minigate.XXXXXX` 是临时目录模板,系统会自动把六个 `X` 替换为随机字符,避免与已有目录重名。该命令会校验源码包的 SHA-256、保留现有 minigate 配置并重启 LuCI 与 minigate 服务。
|
||||
@@ -168,20 +177,20 @@ ssh root@192.168.1.1
|
||||
|
||||
# 2. 在路由器上直接下载 Release 源码包
|
||||
cd /tmp
|
||||
wget -O minigate-src.tar.gz https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate-v2026.10.26-src.tar.gz
|
||||
wget -O minigate-SHA256SUMS https://github.com/tpxcer/minigate/releases/download/v2026.10.26/SHA256SUMS
|
||||
awk '$2 == "minigate-v2026.10.26-src.tar.gz" {print $1 " minigate-src.tar.gz"}' minigate-SHA256SUMS | sha256sum -c - || exit 1
|
||||
wget -O minigate-src.tar.gz https://github.com/tpxcer/minigate/releases/download/v2026.10.28/minigate-v2026.10.28-src.tar.gz
|
||||
wget -O minigate-SHA256SUMS https://github.com/tpxcer/minigate/releases/download/v2026.10.28/SHA256SUMS
|
||||
awk '$2 == "minigate-v2026.10.28-src.tar.gz" {print $1 " minigate-src.tar.gz"}' minigate-SHA256SUMS | sha256sum -c - || exit 1
|
||||
|
||||
# 3. 解压到独立目录并安装
|
||||
mkdir -p /tmp/minigate-v2026.10.26
|
||||
tar xzf minigate-src.tar.gz -C /tmp/minigate-v2026.10.26
|
||||
cd /tmp/minigate-v2026.10.26
|
||||
mkdir -p /tmp/minigate-v2026.10.28
|
||||
tar xzf minigate-src.tar.gz -C /tmp/minigate-v2026.10.28
|
||||
cd /tmp/minigate-v2026.10.28
|
||||
sh install.sh
|
||||
|
||||
# 4. 启动服务
|
||||
/etc/init.d/minigate restart
|
||||
|
||||
# 5. 访问 LuCI → 服务 → minigate
|
||||
# 5. 访问 LuCI → 服务 → MiniGate
|
||||
```
|
||||
|
||||
### 方法 4:OpenWrt SDK 编译(适用所有版本,含 APK)
|
||||
@@ -207,7 +216,7 @@ APK 的内部版本格式要求将同日序号 `-N` 写为 `-rN`,SDK 会自动
|
||||
|
||||
### LuCI 一键更新
|
||||
|
||||
进入 **服务 → minigate → 总览 → 软件更新**:
|
||||
进入 **服务 → MiniGate → 总览 → 软件更新**:
|
||||
|
||||
1. 打开总览时自动检查,也可以点击「检查更新」立即重新检查
|
||||
2. 有新版本时点击「一键更新」,弹出该版本的更新内容
|
||||
@@ -225,7 +234,7 @@ SSH 登录后执行上方「方法 2」的一条命令,保留原有配置并
|
||||
### IPK 升级
|
||||
|
||||
```bash
|
||||
opkg install --force-reinstall /tmp/minigate_2026.10.26_all.ipk
|
||||
opkg install --force-reinstall /tmp/minigate_2026.10.28_all.ipk
|
||||
rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
|
||||
/etc/init.d/minigate restart
|
||||
```
|
||||
@@ -244,7 +253,7 @@ rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
|
||||
|
||||
### 方式 1:LuCI 一键卸载(推荐)
|
||||
|
||||
进入 **服务 → minigate → 总览**,点击标题右侧的「卸载」:
|
||||
进入 **服务 → MiniGate → 总览**,点击页面底部「危险操作」区的「卸载」:
|
||||
|
||||
1. 阅读中断提示并确认卸载范围
|
||||
2. 默认不勾选数据清理,保留 `/etc/config/minigate`、证书、封禁记录和日志
|
||||
@@ -287,28 +296,29 @@ opkg remove minigate 2>/dev/null || apk del minigate 2>/dev/null
|
||||
## 使用指南
|
||||
|
||||
### 第一步:基础配置
|
||||
1. 进入 **LuCI → 服务 → minigate**
|
||||
2. 在「总览」页面开启 minigate
|
||||
3. 如需 IPv6 反代监听,开启「反向代理监听 IPv6」
|
||||
1. 进入 **LuCI → 服务 → MiniGate**
|
||||
2. 在「总览」页面顶部勾选「启用 MiniGate」,点击「保存并应用」
|
||||
3. 如需 IPv6 反代监听,在「反向代理 → 高级设置」开启「监听 IPv6」
|
||||
|
||||
### 第二步:配置 DDNS
|
||||
1. 切换到 **DDNS** 标签页
|
||||
2. 启用 DDNS,填入 Cloudflare Zone ID 和 API Token
|
||||
3. 设置域名,选择协议版本(IPv4 / IPv6 / 双栈)
|
||||
4. 保存并应用
|
||||
4. 软路由在上级路由后面时,「IP 来源」选「从外部URL获取」
|
||||
5. 保存并应用
|
||||
|
||||
### 第三步:签发 SSL 证书
|
||||
1. 切换到 **SSL 证书** 标签页,启用 ACME
|
||||
2. 先用 Staging 模式测试
|
||||
3. 点击「立即签发 / 续期」
|
||||
4. 成功后关闭 Staging 重新签发正式证书
|
||||
2. 先用测试模式(Staging)验证
|
||||
3. 点击「立即签发」,签发在后台进行,可以离开页面
|
||||
4. 成功后关闭测试模式并保存,再签发正式证书;证书列表会标出「测试证书」和「正式证书」
|
||||
|
||||
### 第四步:配置反向代理
|
||||
1. 切换到 **反向代理** 标签页
|
||||
2. 添加规则,填写域名、目标地址和端口
|
||||
3. 启用 SSL(自动关联 ACME 证书)
|
||||
4. 如需输入 `http://域名:监听端口` 后自动进入 HTTPS,开启「HTTP 自动跳转」
|
||||
5. 保存并应用;minigate 会自动跟随每条 HTTPS 规则的监听端口,无需额外指定固定跳转端口
|
||||
4. 如需输入 `http://域名:监听端口` 后自动进入 HTTPS,在页面下方「高级设置」开启「HTTP 跳转」
|
||||
5. 保存并应用;MiniGate 会自动跟随每条 HTTPS 规则的监听端口,无需额外指定固定跳转端口
|
||||
6. 如需内网访问不绕公网,开启「局域网直连解析」(见下方说明)
|
||||
|
||||
### 局域网直连解析
|
||||
@@ -326,11 +336,11 @@ opkg remove minigate 2>/dev/null || apk del minigate 2>/dev/null
|
||||
- 关闭开关或停止 minigate 时会移除该文件;也可以手动执行 `/usr/lib/minigate/proxy.sh lan-dns` 重新同步
|
||||
|
||||
### 设置公网访问范围
|
||||
在 **反向代理 → 公网访问范围** 中启用「限制公网访问地区」,再添加允许的省级地区(支持多个)。例如仅保留「四川省」,保存应用后,只允许地址库判定为四川的公网 IP 访问。默认关闭,升级不会自动收紧现有访问范围。
|
||||
在 **反向代理 → 高级设置:公网访问范围** 中启用「限制公网访问地区」,再勾选允许的省级地区(支持多个)。例如仅保留「四川省」,保存应用后,只允许地址库判定为四川的公网 IP 访问。默认关闭,升级不会自动收紧现有访问范围。
|
||||
|
||||
- 所有普通域名、子域名、HTTP 跳转及 WebSocket 均在转发前检查;其他地区、境外及归属地未知来源返回 Nginx `444`(直接断开,无响应正文)
|
||||
- IPv4、IPv6 使用同一策略;RFC1918 私有 IPv4、回环、IPv6 ULA 和链路本地地址继续放行;LAN 接口已分配的全球 IPv6 子网也自动豁免。使用「局域网直连解析」可通过 LAN IPv4 访问
|
||||
- 不修改防火墙,不限制 SSH、RustDesk 等其他服务;启用此功能时不再运行旧版地区日志扫描封禁逻辑,已有登录防护独立生效
|
||||
- 不修改防火墙,不限制 SSH、RustDesk 等其他服务;旧版的地区日志扫描封禁已移除,升级时会把旧配置迁移到这里;登录防护独立生效
|
||||
- 使用实际连接来源(同端口分流时为本机 stream 提供的 PROXY protocol),不信任访客提交的 `X-Forwarded-For` / `X-Real-IP`;若前面还有 CDN 或隐藏来源的代理,看到的是该代理的出口 IP
|
||||
- 数据来自 [ip2region](https://github.com/lionsoul2014/ip2region),随安装包附带 34 个省级地区的离线 CIDR,不需要在线归属地接口或额外 Nginx 模块。版本、源文件 SHA-256 和网段数见 `root/usr/lib/minigate/geo/metadata.json`,原许可证随包保留
|
||||
- 地址归属可能误判或随运营商调整变化;外出、漫游或使用外省代理出口时可能被拦截。数据缺失或配置为空时仍拒绝公网,不自动退回全部放行。可从局域网关闭开关恢复访问
|
||||
@@ -358,7 +368,7 @@ python3 scripts/build-geo-data.py /path/to/ip2region/data
|
||||
安装后,反向代理自动拒绝直接使用 IP 地址访问的请求(返回 444 断开连接)。同时,每个监听端口都会生成 `default_server` 兜底规则,未配置的 Host / 子域名前缀不会落到同端口第一个反代站点。只有通过正确域名访问才能到达后端服务,这可以有效防止网络扫描器探测和域名串站。
|
||||
|
||||
### 访客追踪
|
||||
总览页面实时显示所有通过反向代理访问的 IP 地址、归属地和在线状态,帮助你发现异常访问。
|
||||
总览页面实时显示所有通过反向代理访问的 IP 地址、归属地和最近请求结果,帮助你发现异常访问。
|
||||
|
||||
---
|
||||
|
||||
@@ -380,16 +390,23 @@ minigate/
|
||||
│ │ ├── acme.lua # ACME 证书
|
||||
│ │ ├── proxy.lua # 反向代理
|
||||
│ │ └── login_guard.lua # 登录防护
|
||||
│ └── view/minigate/log.htm # 日志页面
|
||||
│ └── view/minigate/
|
||||
│ ├── log.htm # 日志页面(按时间合并各来源)
|
||||
│ ├── style.htm # 各配置页共用的深浅色样式
|
||||
│ ├── proxy_empty.htm # 反代页无域名时的提示
|
||||
│ └── ddns_history.htm # 最近一天 IP 变化明细
|
||||
└── root/
|
||||
├── etc/config/minigate # UCI 配置(含 login_guard 段)
|
||||
├── etc/init.d/minigate # 服务脚本(启停含 login_guard)
|
||||
├── etc/init.d/minigate # procd 服务脚本(Nginx、登录防护、配置变更自动重载)
|
||||
├── usr/share/nftables.d/ # 登录防护封禁集合与规则(随 fw4 加载)
|
||||
└── usr/lib/minigate/
|
||||
├── ddns.sh # DDNS(双栈)
|
||||
├── acme.sh # 证书管理
|
||||
├── proxy.sh # 反代(协议分流+IPv6+访问日志)
|
||||
├── login_guard.sh # 登录防护 watcher + CLI
|
||||
├── after_commit.sh # LuCI 保存后等配置提交再应用
|
||||
├── login_guard.sh # 登录防护 watcher + CLI(IPv4/IPv6)
|
||||
├── job.sh # 签发证书、手动同步等后台任务
|
||||
├── geo_lookup.sh # 离线省级归属地查询
|
||||
├── uninstall.sh # 卸载
|
||||
└── update.sh # GitHub Release 安全更新器
|
||||
```
|
||||
|
||||
@@ -408,6 +425,20 @@ minigate/
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v2026.10.28
|
||||
- 修复:通配符主域名停用或删除后,其子域名会退回成 443 端口的明文 HTTP 站点,并可能让 Nginx 因两个默认站点无法加载;现在子域名随主域名一起停用,并可单独停用
|
||||
- 修复:同一端口同时有 HTTP 和 HTTPS 规则时生成无法加载的配置;冲突规则会被跳过并在总览提示
|
||||
- 配置检查失败时恢复上一份可用配置;生成过程加锁,避免保存、续期和更新同时触发时互相覆盖
|
||||
- 服务改由 procd 管理:Nginx 与登录防护异常退出后自动拉起,LuCI「保存并应用」后只重载受影响的部分,不再轮询等待配置写入
|
||||
- 登录防护支持 IPv6 来源、任意前缀长度的白名单;封禁规则随防火墙重载自动加载,重载后的封禁 30 秒内恢复;封禁失败不再误记为已封禁
|
||||
- DDNS 只推送公网地址,外部查询默认用 HTTPS;运行状态移出 `/etc/config/minigate`,定时检查不再改写配置
|
||||
- 证书:固定 acme.sh 3.1.6 并校验 SHA-256;剩余超过 30 天不重复签发;签发和手动同步改为后台任务,不受网页超时影响
|
||||
- 所有站点自动支持 WebSocket;归属地优先查离线地址库,境外地址才用 HTTPS 接口
|
||||
- 移除旧版地区日志扫描(升级时迁移到请求前准入);定时任务只管理本插件脚本,不再误删用户任务
|
||||
- 界面:名称统一为 MiniGate;总开关移到总览顶部,未启用时有提示;DDNS 卡片列出全部记录并按最差状态着色;证书卡片显示到期日期、剩余天数和测试证书;反代地址可直接点击,并显示配置问题;卸载移到底部「危险操作」区
|
||||
- 界面:日志「全部」按时间合并各来源,新增登录防护、软件更新来源,向上翻看时不再被拉回底部;DDNS 手动同步不再刷新整页;反代页规则在前、高级设置在后,地区改为复选框,启用开关统一为勾选框;配置页跟随深色主题;表单增加域名、Zone ID、检查间隔、阈值和白名单校验
|
||||
- CI 对每次推送和 PR 运行测试,发布任务只在测试通过后执行
|
||||
|
||||
### v2026.10.26
|
||||
- 访问记录区分「已拦截、已转发、已响应、请求失败、结果未知」,显示最近请求的 HTTP 状态与拦截/转发/其他次数
|
||||
- 「在线/离线」改为近期请求说明,避免把被拒绝的外地扫描误认为访问成功;保留拦截记录便于核查
|
||||
|
||||
+55
-3
@@ -13,6 +13,9 @@ dependency_ready() {
|
||||
# 页面基于 CBI,由 luci-compat 提供;模块已存在时不依赖包数据库记录
|
||||
[ -f "${MINIGATE_LUCI_DIR:-/usr/lib/lua/luci}/cbi.lua" ]
|
||||
;;
|
||||
nginx-ssl)
|
||||
command -v nginx >/dev/null 2>&1
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
@@ -23,13 +26,13 @@ install_dependencies() {
|
||||
local p
|
||||
if command -v opkg >/dev/null 2>&1; then
|
||||
PKG="opkg"
|
||||
for p in curl jsonfilter nftables nginx-mod-stream openssl-util luci-compat; do
|
||||
for p in nginx-ssl curl jsonfilter nftables nginx-mod-stream openssl-util luci-compat; do
|
||||
dependency_ready "$p" && continue
|
||||
opkg list-installed 2>/dev/null | grep -q "^${p} " || { opkg update 2>/dev/null; opkg install "$p" 2>/dev/null; }
|
||||
done
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
PKG="apk"
|
||||
for p in curl jsonfilter nftables nginx-mod-stream openssl-util luci-compat; do
|
||||
for p in nginx-ssl curl jsonfilter nftables nginx-mod-stream openssl-util luci-compat; do
|
||||
dependency_ready "$p" && continue
|
||||
apk info -e "$p" >/dev/null 2>&1 || apk add "$p" 2>/dev/null
|
||||
done
|
||||
@@ -69,11 +72,28 @@ fi
|
||||
echo "[*] 已清理旧的独立 login-guard"
|
||||
}
|
||||
|
||||
# 旧版的事后地区扫描已由反代请求前准入取代:移除它的防火墙集合,避免继续全局封禁
|
||||
remove_legacy_geofence() {
|
||||
local handle
|
||||
command -v nft >/dev/null 2>&1 || return 0
|
||||
for handle in $(nft -a list chain inet fw4 input 2>/dev/null |
|
||||
awk 'index($0, "@minigate_blocked") { for (i=1; i<=NF; i++) if ($i=="handle") print $(i+1) }'); do
|
||||
nft delete rule inet fw4 input handle "$handle" 2>/dev/null || true
|
||||
done
|
||||
nft delete set inet fw4 minigate_blocked 2>/dev/null || true
|
||||
}
|
||||
remove_legacy_geofence
|
||||
|
||||
mkdir -p /usr/lib/minigate
|
||||
mkdir -p /etc/minigate/acme /etc/minigate/certs /etc/minigate/nginx/sites /etc/minigate/nginx/streams /etc/minigate/login-guard
|
||||
rm -f /usr/lib/minigate/after_commit.sh /usr/lib/minigate/geofence.sh
|
||||
cp "$D"/root/usr/lib/minigate/*.sh /usr/lib/minigate/; chmod +x /usr/lib/minigate/*.sh
|
||||
cp -R "$D"/root/usr/lib/minigate/geo /usr/lib/minigate/
|
||||
cp "$D"/root/etc/init.d/minigate /etc/init.d/; chmod +x /etc/init.d/minigate
|
||||
# 登录防护的封禁集合和规则随防火墙每次重载自动加载
|
||||
mkdir -p /usr/share/nftables.d/table-pre /usr/share/nftables.d/chain-pre/input
|
||||
cp "$D"/root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft /usr/share/nftables.d/table-pre/
|
||||
cp "$D"/root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft /usr/share/nftables.d/chain-pre/input/
|
||||
|
||||
# 配置:保留旧的,但确保有 login_guard 区段
|
||||
if [ -f /etc/config/minigate ]; then
|
||||
@@ -92,6 +112,38 @@ fi
|
||||
|
||||
# v1.3.11 起由每条 HTTPS 规则的监听端口直接承担 HTTP 跳转。
|
||||
uci -q delete minigate.global.http_redirect_port 2>/dev/null || true
|
||||
|
||||
# 旧版地区扫描的允许省份迁移到请求前准入(按名称前缀匹配地区代码)
|
||||
migrate_legacy_geofence() {
|
||||
local allowed codes name code
|
||||
[ "$(uci -q get minigate.global.geo_enabled)" = "1" ] || return 0
|
||||
[ "$(uci -q get minigate.global.access_enabled)" = "1" ] && return 0
|
||||
allowed=$(uci -q get minigate.global.geo_allowed)
|
||||
codes=""
|
||||
for name in $(echo "$allowed" | tr ',' ' '); do
|
||||
code=$(awk -v name="$name" 'index($2, name) == 1 { print $1; exit }' "$D"/root/usr/lib/minigate/geo/regions.txt)
|
||||
[ -n "$code" ] && codes="$codes $code"
|
||||
done
|
||||
[ -n "$codes" ] || return 0
|
||||
uci -q delete minigate.global.access_regions
|
||||
for code in $codes; do uci -q add_list minigate.global.access_regions="$code"; done
|
||||
uci -q set minigate.global.access_enabled=1
|
||||
echo "[*] 已将旧版地区限制迁移为反代请求前准入:$codes"
|
||||
}
|
||||
migrate_legacy_geofence
|
||||
uci -q delete minigate.global.geo_enabled 2>/dev/null || true
|
||||
uci -q delete minigate.global.geo_allowed 2>/dev/null || true
|
||||
uci -q delete minigate.global.blocked_ips 2>/dev/null || true
|
||||
|
||||
# 运行状态已移到 /var/run/minigate,清掉旧版写在配置文件里的状态字段
|
||||
for sec in $(uci -q -X show minigate 2>/dev/null | grep '=ddns$' | cut -d. -f2 | cut -d= -f1); do
|
||||
for opt in status status_msg last_ip last_ip6 last_update next_sync; do
|
||||
uci -q delete "minigate.${sec}.${opt}" 2>/dev/null || true
|
||||
done
|
||||
done
|
||||
for opt in status last_domain last_issue cert_expiry last_error; do
|
||||
uci -q delete "minigate.acme.${opt}" 2>/dev/null || true
|
||||
done
|
||||
uci -q commit minigate 2>/dev/null || true
|
||||
|
||||
mkdir -p /usr/lib/lua/luci/controller
|
||||
@@ -100,7 +152,7 @@ mkdir -p /usr/lib/lua/luci/view/minigate
|
||||
cp "$D"/luasrc/controller/minigate.lua /usr/lib/lua/luci/controller/
|
||||
cp "$D"/luasrc/model/cbi/minigate/*.lua /usr/lib/lua/luci/model/cbi/minigate/
|
||||
cp "$D"/luasrc/view/minigate/*.htm /usr/lib/lua/luci/view/minigate/
|
||||
rm -rf /tmp/luci-* 2>/dev/null
|
||||
rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache 2>/dev/null
|
||||
/etc/init.d/minigate enable 2>/dev/null
|
||||
/etc/init.d/minigate start 9>&- 2>/dev/null || true
|
||||
echo ""; echo "==== 安装完成 ===="
|
||||
|
||||
@@ -1,10 +1,173 @@
|
||||
module("luci.controller.minigate", package.seeall)
|
||||
|
||||
local RUN_DIR = "/var/run/minigate"
|
||||
local JOB_DIR = RUN_DIR .. "/jobs"
|
||||
local DDNS_STATE_DIR = RUN_DIR .. "/ddns"
|
||||
local ACME_STATE_FILE = RUN_DIR .. "/acme.state"
|
||||
local PROXY_STATUS_FILE = RUN_DIR .. "/proxy.status"
|
||||
local CERT_DIR = "/etc/minigate/certs"
|
||||
|
||||
local function shellquote(value)
|
||||
value = tostring(value or "")
|
||||
return "'" .. value:gsub("'", "'\"'\"'") .. "'"
|
||||
end
|
||||
|
||||
local function write_json(data)
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json(data)
|
||||
end
|
||||
|
||||
local function bad_request(message, extra)
|
||||
luci.http.status(400, "Bad Request")
|
||||
local data = extra or {}
|
||||
data.success = false
|
||||
data.message = message
|
||||
write_json(data)
|
||||
end
|
||||
|
||||
-- 读取 key=value 状态文件;warning 等可重复的键收集为数组
|
||||
function read_state(path)
|
||||
local data = {}
|
||||
local file = io.open(path, "r")
|
||||
if not file then return nil end
|
||||
for line in file:lines() do
|
||||
local key, value = line:match("^([%w_]+)=(.*)$")
|
||||
if key == "warning" then
|
||||
data.warnings = data.warnings or {}
|
||||
data.warnings[#data.warnings + 1] = value
|
||||
elseif key then
|
||||
data[key] = value
|
||||
end
|
||||
end
|
||||
file:close()
|
||||
return data
|
||||
end
|
||||
|
||||
-- 与 ddns.sh 的 state_key 和 IP 历史文件使用相同的域名清洗规则
|
||||
function state_key(domain)
|
||||
return (tostring(domain or ""):gsub("[^%w_%-]", "_"))
|
||||
end
|
||||
|
||||
function ddns_state(domain)
|
||||
if not domain or domain == "" then return {} end
|
||||
return read_state(DDNS_STATE_DIR .. "/" .. state_key(domain) .. ".state") or {}
|
||||
end
|
||||
|
||||
local MONTHS = {Jan=1, Feb=2, Mar=3, Apr=4, May=5, Jun=6, Jul=7, Aug=8, Sep=9, Oct=10, Nov=11, Dec=12}
|
||||
|
||||
-- 公历日期转 UTC 秒数,不依赖路由器时区
|
||||
local function utc_epoch(y, m, d, hh, mm, ss)
|
||||
y = (m <= 2) and (y - 1) or y
|
||||
local era = math.floor(y / 400)
|
||||
local yoe = y - era * 400
|
||||
local mp = (m + 9) % 12
|
||||
local doy = math.floor((153 * mp + 2) / 5) + d - 1
|
||||
local doe = yoe * 365 + math.floor(yoe / 4) - math.floor(yoe / 100) + doy
|
||||
local days = era * 146097 + doe - 719468
|
||||
return days * 86400 + hh * 3600 + mm * 60 + ss
|
||||
end
|
||||
|
||||
-- openssl 的 notAfter 形如 "Dec 16 12:00:00 2026 GMT"
|
||||
function parse_cert_time(value)
|
||||
local mon, d, hh, mm, ss, y = tostring(value or ""):match("^(%a+)%s+(%d+)%s+(%d+):(%d+):(%d+)%s+(%d+)")
|
||||
if not mon or not MONTHS[mon] then return nil end
|
||||
return utc_epoch(tonumber(y), MONTHS[mon], tonumber(d), tonumber(hh), tonumber(mm), tonumber(ss))
|
||||
end
|
||||
|
||||
-- 读取已签发证书:域名、到期日期、剩余天数、是否为测试证书
|
||||
function cert_list()
|
||||
local fs = require "nixio.fs"
|
||||
local certs = {}
|
||||
if not fs.dir(CERT_DIR) then return certs end
|
||||
local now = os.time()
|
||||
for entry in fs.dir(CERT_DIR) do
|
||||
local path = CERT_DIR .. "/" .. entry
|
||||
local st = fs.lstat(path)
|
||||
if entry ~= "_default" and st and st.type == "dir" and fs.access(path .. "/fullchain.pem") then
|
||||
local handle = io.popen("openssl x509 -in " .. shellquote(path .. "/fullchain.pem") .. " -noout -enddate -issuer 2>/dev/null")
|
||||
local output = handle and handle:read("*a") or ""
|
||||
if handle then handle:close() end
|
||||
local not_after = output:match("notAfter=([^\n]+)")
|
||||
local issuer = output:match("issuer=([^\n]+)") or ""
|
||||
local expires = parse_cert_time(not_after)
|
||||
local item = {
|
||||
domain = entry:gsub("^_wildcard_%.", "*."),
|
||||
path = path,
|
||||
staging = (issuer:lower():find("staging", 1, true) or issuer:lower():find("fake", 1, true)) and true or false
|
||||
}
|
||||
if expires then
|
||||
item.expires = expires
|
||||
item.expires_date = os.date("%Y-%m-%d", expires)
|
||||
item.days_left = math.floor((expires - now) / 86400)
|
||||
end
|
||||
certs[#certs + 1] = item
|
||||
end
|
||||
end
|
||||
table.sort(certs, function(a, b) return (a.expires or 0) < (b.expires or 0) end)
|
||||
return certs
|
||||
end
|
||||
|
||||
function index()
|
||||
entry({"admin","services","minigate"}, alias("admin","services","minigate","general"), "MiniGate", 60).dependent=false
|
||||
entry({"admin","services","minigate","general"}, cbi("minigate/general"), "总览", 10)
|
||||
entry({"admin","services","minigate","ddns"}, cbi("minigate/ddns"), "动态DNS", 20)
|
||||
entry({"admin","services","minigate","acme"}, cbi("minigate/acme"), "SSL 证书", 30)
|
||||
entry({"admin","services","minigate","proxy"}, cbi("minigate/proxy"), "反向代理", 40)
|
||||
entry({"admin","services","minigate","login_guard"}, cbi("minigate/login_guard"), "登录防护", 45)
|
||||
entry({"admin","services","minigate","log"}, template("minigate/log"), "日志", 50)
|
||||
entry({"admin","services","minigate","status"}, call("action_status")).leaf=true
|
||||
entry({"admin","services","minigate","get_log"}, call("action_get_log")).leaf=true
|
||||
entry({"admin","services","minigate","acme_install"}, post("action_acme_install")).leaf=true
|
||||
entry({"admin","services","minigate","acme_issue"}, post("action_acme_issue")).leaf=true
|
||||
entry({"admin","services","minigate","acme_job"}, call("action_acme_job")).leaf=true
|
||||
entry({"admin","services","minigate","ddns_sync"}, post("action_ddns_sync")).leaf=true
|
||||
entry({"admin","services","minigate","ddns_job"}, call("action_ddns_job")).leaf=true
|
||||
entry({"admin","services","minigate","ddns_history"}, call("action_ddns_history")).leaf=true
|
||||
entry({"admin","services","minigate","proxy_access"}, call("action_proxy_access")).leaf=true
|
||||
entry({"admin","services","minigate","geo_lookup"}, call("action_geo_lookup")).leaf=true
|
||||
entry({"admin","services","minigate","lg_status"}, call("action_lg_status")).leaf=true
|
||||
entry({"admin","services","minigate","lg_ban"}, post("action_lg_ban")).leaf=true
|
||||
entry({"admin","services","minigate","lg_unban"}, post("action_lg_unban")).leaf=true
|
||||
entry({"admin","services","minigate","lg_flush"}, post("action_lg_flush")).leaf=true
|
||||
entry({"admin","services","minigate","update_status"}, call("action_update_status")).leaf=true
|
||||
entry({"admin","services","minigate","update_auto"}, call("action_update_auto")).leaf=true
|
||||
entry({"admin","services","minigate","update_check"}, call("action_update_check")).leaf=true
|
||||
entry({"admin","services","minigate","update_apply"}, post("action_update_apply")).leaf=true
|
||||
entry({"admin","services","minigate","uninstall"}, post("action_uninstall")).leaf=true
|
||||
end
|
||||
|
||||
-- ============== 后台任务 ==============
|
||||
|
||||
local function job_state(name)
|
||||
return read_state(JOB_DIR .. "/" .. name .. ".state") or {}
|
||||
end
|
||||
|
||||
-- 同名任务正在运行时不重复启动;超过 15 分钟仍为 running 视为异常退出
|
||||
local function job_running(name)
|
||||
local state = job_state(name)
|
||||
local started = tonumber(state.started or "")
|
||||
return state.status == "running" and started ~= nil and os.time() - started < 900
|
||||
end
|
||||
|
||||
local function start_job(name, command)
|
||||
local sys = require "luci.sys"
|
||||
if job_running(name) then return true end
|
||||
return sys.call("( /bin/sh /usr/lib/minigate/job.sh " .. name .. " " .. command ..
|
||||
" ) </dev/null >/dev/null 2>&1 &") == 0
|
||||
end
|
||||
|
||||
local function job_response(name)
|
||||
local state = job_state(name)
|
||||
return {
|
||||
running = job_running(name),
|
||||
done = state.status == "done",
|
||||
code = tonumber(state.code or ""),
|
||||
finished = tonumber(state.finished or "")
|
||||
}
|
||||
end
|
||||
|
||||
-- ============== 软件更新与卸载 ==============
|
||||
|
||||
local function update_response(command)
|
||||
local sys = require "luci.sys"
|
||||
local jsonc = require "luci.jsonc"
|
||||
@@ -30,42 +193,14 @@ local function update_response(command)
|
||||
if release_ok and type(release) == "table" then
|
||||
local tag = tostring(release.tag_name or ""):gsub("^v", "")
|
||||
if tag == tostring(data.latest) then
|
||||
local title = type(release.name) == "string" and release.name or ("minigate v" .. tag)
|
||||
local title = type(release.name) == "string" and release.name or ("MiniGate v" .. tag)
|
||||
local notes = type(release.body) == "string" and release.body or ""
|
||||
data.release_title = title
|
||||
data.release_notes = notes
|
||||
end
|
||||
end
|
||||
end
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json(data)
|
||||
end
|
||||
|
||||
function index()
|
||||
entry({"admin","services","minigate"}, alias("admin","services","minigate","general"), "minigate", 60).dependent=false
|
||||
entry({"admin","services","minigate","general"}, cbi("minigate/general"), "总览", 10)
|
||||
entry({"admin","services","minigate","ddns"}, cbi("minigate/ddns"), "动态DNS", 20)
|
||||
entry({"admin","services","minigate","acme"}, cbi("minigate/acme"), "SSL 证书", 30)
|
||||
entry({"admin","services","minigate","proxy"}, cbi("minigate/proxy"), "反向代理", 40)
|
||||
entry({"admin","services","minigate","login_guard"}, cbi("minigate/login_guard"), "登录防护", 45)
|
||||
entry({"admin","services","minigate","log"}, template("minigate/log"), "日志", 50)
|
||||
entry({"admin","services","minigate","status"}, call("action_status")).leaf=true
|
||||
entry({"admin","services","minigate","get_log"}, call("action_get_log")).leaf=true
|
||||
entry({"admin","services","minigate","acme_install"}, post("action_acme_install")).leaf=true
|
||||
entry({"admin","services","minigate","acme_issue"}, post("action_acme_issue")).leaf=true
|
||||
entry({"admin","services","minigate","ddns_sync"}, post("action_ddns_sync")).leaf=true
|
||||
entry({"admin","services","minigate","ddns_history"}, call("action_ddns_history")).leaf=true
|
||||
entry({"admin","services","minigate","proxy_access"}, call("action_proxy_access")).leaf=true
|
||||
entry({"admin","services","minigate","geo_lookup"}, call("action_geo_lookup")).leaf=true
|
||||
entry({"admin","services","minigate","lg_status"}, call("action_lg_status")).leaf=true
|
||||
entry({"admin","services","minigate","lg_ban"}, post("action_lg_ban")).leaf=true
|
||||
entry({"admin","services","minigate","lg_unban"}, post("action_lg_unban")).leaf=true
|
||||
entry({"admin","services","minigate","lg_flush"}, post("action_lg_flush")).leaf=true
|
||||
entry({"admin","services","minigate","update_status"}, call("action_update_status")).leaf=true
|
||||
entry({"admin","services","minigate","update_auto"}, call("action_update_auto")).leaf=true
|
||||
entry({"admin","services","minigate","update_check"}, call("action_update_check")).leaf=true
|
||||
entry({"admin","services","minigate","update_apply"}, post("action_update_apply")).leaf=true
|
||||
entry({"admin","services","minigate","uninstall"}, post("action_uninstall")).leaf=true
|
||||
write_json(data)
|
||||
end
|
||||
|
||||
function action_update_status()
|
||||
@@ -88,9 +223,7 @@ function action_update_apply()
|
||||
|
||||
if type(version) ~= "string" or #version > 32 or not (
|
||||
version:match("^%d+%.%d+%.%d+$") or version:match("^%d+%.%d+%.%d+%-%d+$")) then
|
||||
luci.http.status(400, "Bad Request")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success = false, message = "请先查看更新内容并确认版本"})
|
||||
bad_request("请先查看更新内容并确认版本")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -101,8 +234,7 @@ function action_update_apply()
|
||||
"; rm -f \"$runner\" ) >/dev/null 2>&1 & )") == 0
|
||||
end
|
||||
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({
|
||||
write_json({
|
||||
success = ok,
|
||||
message = ok and "更新任务已启动" or "无法启动更新任务"
|
||||
})
|
||||
@@ -116,9 +248,7 @@ function action_uninstall()
|
||||
local ok = false
|
||||
|
||||
if confirm ~= "uninstall-minigate" or (purge ~= "0" and purge ~= "1") then
|
||||
luci.http.status(400, "Bad Request")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success = false, message = "卸载确认无效,请重新操作"})
|
||||
bad_request("卸载确认无效,请重新操作")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -129,136 +259,236 @@ function action_uninstall()
|
||||
"; rm -f \"$runner\" ) >/dev/null 2>&1 & )") == 0
|
||||
end
|
||||
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({
|
||||
write_json({
|
||||
success = ok,
|
||||
message = ok and "卸载任务已启动" or "无法启动卸载任务"
|
||||
})
|
||||
end
|
||||
|
||||
-- ============== 总览状态 ==============
|
||||
|
||||
local STATUS_RANK = {error=4, partial=3, unknown=2, stopped=1, ok=0}
|
||||
|
||||
function action_status()
|
||||
local uci=require"luci.model.uci".cursor()
|
||||
local pr=false
|
||||
local f=io.open("/var/run/minigate-nginx.pid","r")
|
||||
if f then local p=f:read("*l"); f:close()
|
||||
if p and tonumber(p) then pr=(os.execute("kill -0 "..p.." 2>/dev/null")==0) end
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local pr = false
|
||||
local f = io.open("/var/run/minigate-nginx.pid", "r")
|
||||
if f then
|
||||
local p = f:read("*l"); f:close()
|
||||
if p and tonumber(p) then pr = (os.execute("kill -0 " .. p .. " 2>/dev/null") == 0) end
|
||||
end
|
||||
local al={}
|
||||
for _,lf in ipairs({"/var/log/minigate-ddns.log","/var/log/minigate-acme.log","/var/log/minigate-proxy.log"}) do
|
||||
local fh=io.open(lf,"r"); if fh then for l in fh:lines() do al[#al+1]=l end; fh:close() end
|
||||
end
|
||||
local st=#al-19; if st<1 then st=1 end
|
||||
local ll={}; for i=st,#al do ll[#ll+1]=al[i] end
|
||||
local ca=false; local cf=io.open("/etc/crontabs/root","r")
|
||||
if cf then local c=cf:read("*a"); cf:close(); ca=c:find("minigate")~=nil end
|
||||
local dl={}
|
||||
uci:foreach("minigate","ddns",function(s)
|
||||
dl[#dl+1]={
|
||||
name=s[".name"],
|
||||
enabled=s.enabled or"0",
|
||||
domain=s.domain or"",
|
||||
ip_version=s.ip_version or"ipv4",
|
||||
status=s.status or"unknown",
|
||||
status_msg=s.status_msg or"",
|
||||
last_ip=s.last_ip or"",
|
||||
last_ip6=s.last_ip6 or"",
|
||||
last_update=s.last_update or"",
|
||||
next_sync=s.next_sync or""
|
||||
local enabled = uci:get("minigate", "global", "enabled") or "0"
|
||||
|
||||
-- DDNS:列出所有记录,并给出最差状态用于卡片颜色
|
||||
local dl = {}
|
||||
local worst = nil
|
||||
uci:foreach("minigate", "ddns", function(s)
|
||||
local st = ddns_state(s.domain)
|
||||
local status = st.status or "unknown"
|
||||
local on = s.enabled == "1"
|
||||
if not on then status = "disabled" end
|
||||
dl[#dl + 1] = {
|
||||
name = s[".name"],
|
||||
enabled = on and "1" or "0",
|
||||
domain = s.domain or "",
|
||||
ip_version = s.ip_version or "ipv4",
|
||||
status = status,
|
||||
status_msg = st.message or "",
|
||||
last_ip = st.ip or "",
|
||||
last_ip6 = st.ip6 or "",
|
||||
last_update = st.updated or "",
|
||||
next_sync = st.next or ""
|
||||
}
|
||||
if on and (worst == nil or (STATUS_RANK[status] or 2) > (STATUS_RANK[worst] or 2)) then worst = status end
|
||||
end)
|
||||
|
||||
-- 全局 IPv6 监听状态
|
||||
local ipv6_listen = uci:get("minigate","global","ipv6_listen") or "0"
|
||||
local ipv6_listen = uci:get("minigate", "global", "ipv6_listen") or "0"
|
||||
|
||||
-- 收集代理规则信息
|
||||
-- 反代规则:与 proxy.sh 一致,主域名未启用的子域名不显示
|
||||
local proxy_rules = {}
|
||||
local wc_ports = {}
|
||||
uci:foreach("minigate","proxy_wildcard",function(s)
|
||||
if (s.enabled == "1" or s.enabled == true) and s.domain then
|
||||
local base = (s.domain or ""):gsub("^%*%.","")
|
||||
local ssl_val = tostring(s.ssl or "0")
|
||||
wc_ports[base] = {port=s.listen_port or "443", ssl=ssl_val}
|
||||
uci:foreach("minigate", "proxy_wildcard", function(s)
|
||||
if s.enabled == "1" and s.domain then
|
||||
local base = (s.domain or ""):gsub("^%*%.", "")
|
||||
wc_ports[base] = {port = s.listen_port or "443", ssl = tostring(s.ssl or "0")}
|
||||
end
|
||||
end)
|
||||
uci:foreach("minigate","subproxy",function(s)
|
||||
uci:foreach("minigate", "subproxy", function(s)
|
||||
local parent = s.parent_domain or ""
|
||||
local prefix = s.prefix or ""
|
||||
local taddr = s.target_addr or ""
|
||||
local tport = s.target_port or "80"
|
||||
if prefix ~= "" and parent ~= "" then
|
||||
local wc = wc_ports[parent] or {}
|
||||
local lport = wc.port or "443"
|
||||
local ssl_val = wc.ssl or "0"
|
||||
local scheme = (ssl_val == "1" or ssl_val == "true") and "https" or "http"
|
||||
proxy_rules[#proxy_rules+1] = {domain=prefix.."."..parent, target=taddr..":"..tport, listen_port=lport, scheme=scheme, ipv6_listen=ipv6_listen}
|
||||
local wc = wc_ports[parent]
|
||||
if prefix ~= "" and wc and (s.enabled or "1") == "1" then
|
||||
proxy_rules[#proxy_rules + 1] = {
|
||||
domain = prefix .. "." .. parent,
|
||||
target = (s.target_addr or "") .. ":" .. (s.target_port or "80"),
|
||||
listen_port = wc.port,
|
||||
scheme = wc.ssl == "1" and "https" or "http",
|
||||
ipv6_listen = ipv6_listen
|
||||
}
|
||||
end
|
||||
end)
|
||||
uci:foreach("minigate","proxy",function(s)
|
||||
if (s.enabled == "1" or s.enabled == true) and s.domain and s.target_addr then
|
||||
local ssl_val = tostring(s.ssl or "0")
|
||||
local scheme = (ssl_val == "1" or ssl_val == "true") and "https" or "http"
|
||||
proxy_rules[#proxy_rules+1] = {domain=s.domain, target=(s.target_addr or "")..":"..(s.target_port or "80"), listen_port=s.listen_port or "443", scheme=scheme, ipv6_listen=ipv6_listen}
|
||||
uci:foreach("minigate", "proxy", function(s)
|
||||
if s.enabled == "1" and s.domain and s.target_addr then
|
||||
proxy_rules[#proxy_rules + 1] = {
|
||||
domain = s.domain,
|
||||
target = (s.target_addr or "") .. ":" .. (s.target_port or "80"),
|
||||
listen_port = s.listen_port or "443",
|
||||
scheme = tostring(s.ssl or "1") == "1" and "https" or "http",
|
||||
ipv6_listen = ipv6_listen
|
||||
}
|
||||
end
|
||||
end)
|
||||
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({
|
||||
enabled=uci:get("minigate","global","enabled")or"0",
|
||||
ipv6_listen=ipv6_listen,
|
||||
proxy_running=pr,
|
||||
cron_active=ca,
|
||||
recent_log=table.concat(ll,"\n"),
|
||||
ddns_list=dl,
|
||||
proxy_rules=proxy_rules,
|
||||
acme={
|
||||
enabled=uci:get("minigate","acme","enabled")or"0",
|
||||
status=uci:get("minigate","acme","status")or"unknown",
|
||||
last_domain=uci:get("minigate","acme","last_domain")or"",
|
||||
last_issue=uci:get("minigate","acme","last_issue")or"",
|
||||
cert_expiry=uci:get("minigate","acme","cert_expiry")or"",
|
||||
last_error=uci:get("minigate","acme","last_error")or""
|
||||
local proxy_state = read_state(PROXY_STATUS_FILE) or {}
|
||||
local acme_state = read_state(ACME_STATE_FILE) or {}
|
||||
|
||||
write_json({
|
||||
enabled = enabled,
|
||||
ipv6_listen = ipv6_listen,
|
||||
proxy_running = pr,
|
||||
proxy_check = proxy_state.result or "",
|
||||
proxy_message = proxy_state.message or "",
|
||||
proxy_time = proxy_state.time or "",
|
||||
proxy_warnings = proxy_state.warnings or {},
|
||||
ddns_status = worst or "",
|
||||
ddns_list = dl,
|
||||
proxy_rules = proxy_rules,
|
||||
certs = cert_list(),
|
||||
acme = {
|
||||
enabled = uci:get("minigate", "acme", "enabled") or "0",
|
||||
staging = uci:get("minigate", "acme", "staging") or "0",
|
||||
result = acme_state.result or "",
|
||||
domain = acme_state.domain or "",
|
||||
message = acme_state.message or "",
|
||||
time = acme_state.time or ""
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
function action_get_log()
|
||||
local sys=require"luci.sys"; local s=luci.http.formvalue("source")or"all"
|
||||
local fs={ddns="/var/log/minigate-ddns.log",acme="/var/log/minigate-acme.log",proxy="/var/log/minigate-proxy.log /var/log/minigate-nginx-error.log"}
|
||||
if not fs[s] then s="all" end
|
||||
local cmd=s=="all"and"cat /var/log/minigate-*.log 2>/dev/null|tail -200"or"cat "..(fs[s]or"").." 2>/dev/null|tail -200"
|
||||
luci.http.prepare_content("application/json"); luci.http.write_json({log=sys.exec(cmd)or""})
|
||||
-- ============== 日志 ==============
|
||||
|
||||
local LOG_SOURCES = {
|
||||
ddns = {{"DDNS", "/var/log/minigate-ddns.log"}},
|
||||
acme = {{"证书", "/var/log/minigate-acme.log"}},
|
||||
proxy = {{"反代", "/var/log/minigate-proxy.log"}, {"Nginx", "/var/log/minigate-nginx-error.log"}},
|
||||
login_guard = {{"登录防护", "/var/log/minigate-login-guard.log"}},
|
||||
update = {{"更新", "/var/log/minigate-update.log"}}
|
||||
}
|
||||
local LOG_ORDER = {"ddns", "acme", "proxy", "login_guard", "update"}
|
||||
|
||||
local function tail_lines(path, count)
|
||||
local lines = {}
|
||||
local file = io.open(path, "r")
|
||||
if not file then return lines end
|
||||
for line in file:lines() do
|
||||
lines[#lines + 1] = line
|
||||
if #lines > count then table.remove(lines, 1) end
|
||||
end
|
||||
file:close()
|
||||
return lines
|
||||
end
|
||||
|
||||
-- 统一时间戳便于合并:nginx 用 2026/10/28,其余脚本用 2026-10-28
|
||||
local function line_time(line)
|
||||
local stamp = line:match("^(%d%d%d%d[-/]%d%d[-/]%d%d %d%d:%d%d:%d%d)")
|
||||
return stamp and stamp:gsub("/", "-") or nil
|
||||
end
|
||||
|
||||
-- 「全部」按时间合并各来源,每行标出来源;没有时间戳的行(如 curl 输出)跟随上一行
|
||||
function merge_logs(sources, limit)
|
||||
local rows = {}
|
||||
for source_index, source in ipairs(sources) do
|
||||
local last = ""
|
||||
for line_index, line in ipairs(tail_lines(source[2], limit)) do
|
||||
last = line_time(line) or last
|
||||
rows[#rows + 1] = {time = last, source = source_index, index = line_index, text = "[" .. source[1] .. "] " .. line}
|
||||
end
|
||||
end
|
||||
table.sort(rows, function(a, b)
|
||||
if a.time ~= b.time then return a.time < b.time end
|
||||
if a.source ~= b.source then return a.source < b.source end
|
||||
return a.index < b.index
|
||||
end)
|
||||
local out = {}
|
||||
for i = math.max(1, #rows - limit + 1), #rows do out[#out + 1] = rows[i].text end
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
function action_get_log()
|
||||
local source = luci.http.formvalue("source") or "all"
|
||||
local sources = {}
|
||||
if LOG_SOURCES[source] then
|
||||
sources = LOG_SOURCES[source]
|
||||
else
|
||||
for _, name in ipairs(LOG_ORDER) do
|
||||
for _, item in ipairs(LOG_SOURCES[name]) do sources[#sources + 1] = item end
|
||||
end
|
||||
end
|
||||
write_json({log = merge_logs(sources, 300)})
|
||||
end
|
||||
|
||||
-- ============== 证书 ==============
|
||||
|
||||
function action_acme_install()
|
||||
local sys=require"luci.sys"; sys.call("/bin/sh /usr/lib/minigate/acme.sh install 2>&1")
|
||||
local fs=require"nixio.fs"; local ok=fs.access("/etc/minigate/acme/data/acme.sh")and fs.access("/etc/minigate/acme/data/dnsapi/dns_cf.sh")
|
||||
luci.http.prepare_content("application/json"); luci.http.write_json({success=ok,message=ok and"安装成功"or"安装失败"})
|
||||
local ok = start_job("acme", "/bin/sh /usr/lib/minigate/acme.sh install")
|
||||
write_json({success = ok, message = ok and "已开始安装" or "无法启动安装任务"})
|
||||
end
|
||||
|
||||
function action_acme_issue()
|
||||
local sys=require"luci.sys"; local d=luci.http.formvalue("domain")or""
|
||||
local cmd="/bin/sh /usr/lib/minigate/acme.sh issue"; if d~=""then cmd=cmd.." "..shellquote(d)end
|
||||
sys.call(cmd.." 2>&1")
|
||||
local uci=require"luci.model.uci".cursor(); local st=uci:get("minigate","acme","status")or"unknown"
|
||||
local le=uci:get("minigate","acme","last_error")or""
|
||||
local msg=(st=="ok" and le~="")and"现有证书有效;最近一次签发失败,已保留有效证书"or((st=="ok")and"签发成功!"or"签发失败,查看日志")
|
||||
luci.http.prepare_content("application/json"); luci.http.write_json({success=(st=="ok"),message=msg})
|
||||
local d = luci.http.formvalue("domain") or ""
|
||||
if d ~= "" and (#d > 253 or not d:match("^[%*%w][%w%.%-]*$")) then
|
||||
bad_request("域名无效")
|
||||
return
|
||||
end
|
||||
local ok = start_job("acme", "/bin/sh /usr/lib/minigate/acme.sh issue " .. shellquote(d))
|
||||
write_json({success = ok, message = ok and "已开始签发" or "无法启动签发任务"})
|
||||
end
|
||||
|
||||
function action_acme_job()
|
||||
local fs = require "nixio.fs"
|
||||
local data = job_response("acme")
|
||||
local state = read_state(ACME_STATE_FILE) or {}
|
||||
data.result = state.result or ""
|
||||
data.message = state.message or ""
|
||||
data.domain = state.domain or ""
|
||||
data.installed = fs.access("/etc/minigate/acme/data/acme.sh") and fs.access("/etc/minigate/acme/data/dnsapi/dns_cf.sh") and true or false
|
||||
write_json(data)
|
||||
end
|
||||
|
||||
-- ============== DDNS ==============
|
||||
|
||||
local function valid_ddns_section(sec)
|
||||
return sec ~= "" and #sec <= 64 and sec:match("^[%w_]+$") and
|
||||
require"luci.model.uci".cursor():get("minigate", sec) == "ddns"
|
||||
end
|
||||
|
||||
function action_ddns_sync()
|
||||
local sys=require"luci.sys"; local sec=luci.http.formvalue("section")or""
|
||||
if sec~="" and (#sec>64 or not sec:match("^[%w_]+$") or require"luci.model.uci".cursor():get("minigate",sec)~="ddns") then
|
||||
luci.http.status(400,"Bad Request")
|
||||
luci.http.prepare_content("application/json"); luci.http.write_json({success=false,status="invalid",message="DDNS 记录参数无效"})
|
||||
local sec = luci.http.formvalue("section") or ""
|
||||
if not valid_ddns_section(sec) then
|
||||
bad_request("DDNS 记录参数无效", {status = "invalid"})
|
||||
return
|
||||
end
|
||||
if sec~=""then sys.call("/bin/sh /usr/lib/minigate/ddns.sh single "..shellquote(sec).." 2>&1")
|
||||
else sys.call("/bin/sh /usr/lib/minigate/ddns.sh force 2>&1") end
|
||||
local uci=require"luci.model.uci".cursor()
|
||||
local st=sec~=""and(uci:get("minigate",sec,"status")or"unknown")or"done"
|
||||
local ip=sec~=""and(uci:get("minigate",sec,"last_ip")or"")or""
|
||||
local ip6=sec~=""and(uci:get("minigate",sec,"last_ip6")or"")or""
|
||||
luci.http.prepare_content("application/json"); luci.http.write_json({success=(st=="ok"or st=="partial"),status=st,ip=ip,ip6=ip6})
|
||||
local ok = start_job("ddns-" .. sec, "/bin/sh /usr/lib/minigate/ddns.sh single " .. shellquote(sec))
|
||||
write_json({success = ok, message = ok and "已开始同步" or "无法启动同步任务"})
|
||||
end
|
||||
|
||||
function action_ddns_job()
|
||||
local sec = luci.http.formvalue("section") or ""
|
||||
if not valid_ddns_section(sec) then
|
||||
bad_request("DDNS 记录参数无效", {status = "invalid"})
|
||||
return
|
||||
end
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local data = job_response("ddns-" .. sec)
|
||||
local st = ddns_state(uci:get("minigate", sec, "domain"))
|
||||
data.status = st.status or "unknown"
|
||||
data.message = st.message or ""
|
||||
data.ip = st.ip or ""
|
||||
data.ip6 = st.ip6 or ""
|
||||
data.updated = st.updated or ""
|
||||
data.next = st.next or ""
|
||||
data.success = data.status == "ok" or data.status == "partial"
|
||||
write_json(data)
|
||||
end
|
||||
|
||||
local function history_section_name(value)
|
||||
@@ -302,9 +532,7 @@ function action_ddns_history()
|
||||
local sections = {}
|
||||
|
||||
if #requested > 64 or (requested ~= "" and not requested:match("^[%w_%-]+$")) then
|
||||
luci.http.status(400, "Bad Request")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success=false, message="DDNS 记录参数无效"})
|
||||
bad_request("DDNS 记录参数无效")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -354,8 +582,7 @@ function action_ddns_history()
|
||||
return a.start_time > b.start_time
|
||||
end)
|
||||
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({
|
||||
write_json({
|
||||
success=true,
|
||||
generated_at=now,
|
||||
window_start=cutoff,
|
||||
@@ -365,6 +592,8 @@ function action_ddns_history()
|
||||
})
|
||||
end
|
||||
|
||||
-- ============== 访问记录 ==============
|
||||
|
||||
function action_proxy_access()
|
||||
local sys = require "luci.sys"
|
||||
local limit = tonumber(luci.http.formvalue("limit") or "5") or 5
|
||||
@@ -440,8 +669,25 @@ function action_proxy_access()
|
||||
}
|
||||
end
|
||||
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({ visitors = result })
|
||||
write_json({ visitors = result })
|
||||
end
|
||||
|
||||
-- ============== 归属地 ==============
|
||||
|
||||
local GEO_CACHE_DIR = "/tmp/minigate-geo-cache"
|
||||
local GEO_CACHE_MAX = 500
|
||||
|
||||
-- 缓存超过上限时删除最旧的一批,避免长期遭遇扫描时占满内存
|
||||
local function prune_geo_cache()
|
||||
local sys = require "luci.sys"
|
||||
local count = tonumber((sys.exec("ls " .. GEO_CACHE_DIR .. " 2>/dev/null | wc -l") or ""):match("%d+") or "0") or 0
|
||||
if count > GEO_CACHE_MAX then
|
||||
sys.call("cd " .. GEO_CACHE_DIR .. " && ls -tr | head -n 100 | xargs rm -f -- 2>/dev/null")
|
||||
end
|
||||
end
|
||||
|
||||
local function json_field(raw, key)
|
||||
return raw:match('"' .. key .. '"%s*:%s*"([^"]*)"') or ""
|
||||
end
|
||||
|
||||
function action_geo_lookup()
|
||||
@@ -449,73 +695,60 @@ function action_geo_lookup()
|
||||
local fs = require "nixio.fs"
|
||||
local ip = luci.http.formvalue("ip") or ""
|
||||
-- 安全校验:只允许 IP 地址字符
|
||||
if not ip:match("^[%d%.%:a-fA-F]+$") then
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({ ip = ip, geo = "无效IP" })
|
||||
if #ip > 39 or not ip:match("^[%d%.%:a-fA-F]+$") then
|
||||
write_json({ ip = ip, geo = "无效IP" })
|
||||
return
|
||||
end
|
||||
|
||||
local cache_dir = "/tmp/minigate-geo-cache"
|
||||
local cache_file = cache_dir .. "/" .. ip:gsub("[^%w%.%-_:]", "_")
|
||||
fs.mkdirr(cache_dir)
|
||||
local cache_file = GEO_CACHE_DIR .. "/" .. ip:gsub("[^%w%.%-_:]", "_")
|
||||
fs.mkdirr(GEO_CACHE_DIR)
|
||||
local cached = fs.readfile(cache_file)
|
||||
if cached and cached ~= "" then
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({ ip = ip, geo = cached:gsub("%s+$", "") })
|
||||
write_json({ ip = ip, geo = cached:gsub("%s+$", "") })
|
||||
return
|
||||
end
|
||||
|
||||
local geo = nil
|
||||
-- 先查随包的离线省级地址库,命中时不把访客 IP 发给任何外部服务
|
||||
local geo = (sys.exec("/bin/sh /usr/lib/minigate/geo_lookup.sh " .. shellquote(ip) .. " 2>/dev/null") or ""):gsub("%s+$", "")
|
||||
if geo == "" then geo = nil end
|
||||
|
||||
-- API 1: ip9.com.cn(UTF-8,国内快)
|
||||
-- 境外或离线库未覆盖的地址,再用 HTTPS 接口查询
|
||||
if not geo then
|
||||
local raw = sys.exec("curl -s --connect-timeout 1 --max-time 2 'https://ip9.com.cn/get?ip=" .. ip .. "' 2>/dev/null")
|
||||
if raw and raw ~= "" then
|
||||
local country = raw:match('"country":"([^"]*)"') or ""
|
||||
local prov = raw:match('"prov":"([^"]*)"') or ""
|
||||
local city = raw:match('"city":"([^"]*)"') or ""
|
||||
local isp = raw:match('"isp":"([^"]*)"') or ""
|
||||
local loc = (country .. prov .. city):gsub("中国", "")
|
||||
if isp ~= "" then loc = loc .. " " .. isp end
|
||||
loc = loc:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if loc ~= "" then geo = loc end
|
||||
end
|
||||
local raw = sys.exec("curl -s --proto '=https' --connect-timeout 1 --max-time 2 " .. shellquote("https://ip9.com.cn/get?ip=" .. ip) .. " 2>/dev/null") or ""
|
||||
local loc = (json_field(raw, "country") .. json_field(raw, "prov") .. json_field(raw, "city")):gsub("中国", "")
|
||||
local isp = json_field(raw, "isp")
|
||||
if isp ~= "" then loc = loc .. " " .. isp end
|
||||
loc = loc:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if loc ~= "" then geo = loc end
|
||||
end
|
||||
|
||||
-- API 2: ip-api.com(UTF-8,国外覆盖好)
|
||||
if not geo then
|
||||
local raw = sys.exec("curl -s --connect-timeout 1 --max-time 2 'http://ip-api.com/json/" .. ip .. "?lang=zh-CN&fields=status,country,regionName,city,isp' 2>/dev/null")
|
||||
if raw and raw ~= "" then
|
||||
local status = raw:match('"status":"([^"]*)"')
|
||||
if status == "success" then
|
||||
local country = raw:match('"country":"([^"]*)"') or ""
|
||||
local region = raw:match('"regionName":"([^"]*)"') or ""
|
||||
local city = raw:match('"city":"([^"]*)"') or ""
|
||||
local isp = raw:match('"isp":"([^"]*)"') or ""
|
||||
local loc = (country .. region .. city):gsub("中国", "")
|
||||
if isp ~= "" then loc = loc .. " " .. isp end
|
||||
loc = loc:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if loc ~= "" then geo = loc end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- API 3: pconline(GBK 需转码)
|
||||
if not geo then
|
||||
local raw = sys.exec("curl -s --connect-timeout 1 --max-time 2 'https://whois.pconline.com.cn/ipJson.jsp?ip=" .. ip .. "&json=true' 2>/dev/null | iconv -f gbk -t utf-8 2>/dev/null")
|
||||
if raw and raw ~= "" then
|
||||
local addr = raw:match('"addr":"([^"]*)"')
|
||||
if addr and addr ~= "" then geo = addr:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") end
|
||||
end
|
||||
local raw = sys.exec("curl -s --proto '=https' --connect-timeout 1 --max-time 2 " .. shellquote("https://whois.pconline.com.cn/ipJson.jsp?ip=" .. ip .. "&json=true") .. " 2>/dev/null | iconv -f gbk -t utf-8 2>/dev/null") or ""
|
||||
local addr = json_field(raw, "addr")
|
||||
if addr ~= "" then geo = addr:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") end
|
||||
end
|
||||
|
||||
fs.writefile(cache_file, geo or "未知")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({ ip = ip, geo = geo or "未知" })
|
||||
prune_geo_cache()
|
||||
write_json({ ip = ip, geo = geo or "未知" })
|
||||
end
|
||||
|
||||
-- ============== 登录防护 API ==============
|
||||
|
||||
local function valid_ban_ip(ip)
|
||||
if ip:match("^%d+%.%d+%.%d+%.%d+$") then return true end
|
||||
return #ip <= 39 and ip:match("^[%x:]+$") ~= nil and select(2, ip:gsub(":", "")) >= 2
|
||||
end
|
||||
|
||||
local function parse_ban_set(raw, banned)
|
||||
-- nft -j 在 key 和 value 之间有空格,要用 %s* 匹配;按 elem 块配对 val 与 expires
|
||||
for block in raw:gmatch('{[^{}]-"val"%s*:%s*"[^"]+"[^{}]-}') do
|
||||
local ip = block:match('"val"%s*:%s*"([%x:%.]+)"')
|
||||
local expires = tonumber(block:match('"expires"%s*:%s*(%d+)') or "")
|
||||
if ip and expires then banned[#banned + 1] = { ip = ip, remaining = expires } end
|
||||
end
|
||||
end
|
||||
|
||||
function action_lg_status()
|
||||
local sys = require "luci.sys"
|
||||
local fs = require "nixio.fs"
|
||||
@@ -535,28 +768,13 @@ function action_lg_status()
|
||||
local bantime = tonumber(uci:get("minigate","login_guard","bantime")) or 43200
|
||||
local window_s = tonumber(uci:get("minigate","login_guard","window")) or 600
|
||||
|
||||
-- 服务是否在跑
|
||||
-- 服务是否在跑(procd 以 /bin/sh login_guard.sh run 启动)
|
||||
local pid_out = sys.exec("pgrep -f '^/bin/sh /usr/lib/minigate/login_guard.sh run' 2>/dev/null")
|
||||
local running = (pid_out ~= nil and pid_out:match("%d") ~= nil)
|
||||
|
||||
-- 已封禁列表(解析 nft -j 输出)
|
||||
-- 注意:nft -j 在 key 和 value 之间有空格,要用 %s* 匹配
|
||||
-- 每个 elem 内 val 在前 expires 在后,分别提取后按序号配对(避免跨 elem 匹配错乱)
|
||||
local banned = {}
|
||||
local raw = sys.exec("nft -j list set inet fw4 login_banned_v4 2>/dev/null") or ""
|
||||
local ips = {}
|
||||
for v in raw:gmatch('"val"%s*:%s*"([0-9%.]+)"') do
|
||||
ips[#ips+1] = v
|
||||
end
|
||||
local exps = {}
|
||||
for e in raw:gmatch('"expires"%s*:%s*(%d+)') do
|
||||
exps[#exps+1] = tonumber(e)
|
||||
end
|
||||
for i = 1, #ips do
|
||||
if exps[i] then
|
||||
banned[#banned+1] = { ip = ips[i], remaining = exps[i] }
|
||||
end
|
||||
end
|
||||
parse_ban_set(sys.exec("nft -j list set inet fw4 login_banned_v4 2>/dev/null") or "", banned)
|
||||
parse_ban_set(sys.exec("nft -j list set inet fw4 login_banned_v6 2>/dev/null") or "", banned)
|
||||
-- 按剩余时间倒序
|
||||
table.sort(banned, function(a,b) return a.remaining > b.remaining end)
|
||||
local banned_total = #banned
|
||||
@@ -569,28 +787,29 @@ function action_lg_status()
|
||||
local watching = {}
|
||||
local counter_dir = "/var/run/minigate/login-guard/counters"
|
||||
local now = os.time()
|
||||
local list_out = sys.exec("ls " .. counter_dir .. " 2>/dev/null") or ""
|
||||
for ip in list_out:gmatch("[^\n]+") do
|
||||
local path = counter_dir .. "/" .. ip
|
||||
local fh = io.open(path, "r")
|
||||
if fh then
|
||||
local line = fh:read("*l") or ""
|
||||
fh:close()
|
||||
local first, count = line:match("(%d+)%s+(%d+)")
|
||||
if first and count then
|
||||
local first_time = tonumber(first)
|
||||
local stat = fs.stat(path)
|
||||
local last_time = (stat and stat.mtime) or first_time
|
||||
local age = now - first_time
|
||||
if age <= window_s then
|
||||
watching[#watching+1] = {
|
||||
ip = ip,
|
||||
count = tonumber(count),
|
||||
age = age,
|
||||
first_time = first_time,
|
||||
last_time = last_time,
|
||||
last_seen = os.date("%Y-%m-%d %H:%M:%S", last_time)
|
||||
}
|
||||
if fs.dir(counter_dir) then
|
||||
for ip in fs.dir(counter_dir) do
|
||||
local path = counter_dir .. "/" .. ip
|
||||
local fh = io.open(path, "r")
|
||||
if fh then
|
||||
local line = fh:read("*l") or ""
|
||||
fh:close()
|
||||
local first, count = line:match("(%d+)%s+(%d+)")
|
||||
if first and count then
|
||||
local first_time = tonumber(first)
|
||||
local stat = fs.stat(path)
|
||||
local last_time = (stat and stat.mtime) or first_time
|
||||
local age = now - first_time
|
||||
if age <= window_s then
|
||||
watching[#watching+1] = {
|
||||
ip = ip,
|
||||
count = tonumber(count),
|
||||
age = age,
|
||||
first_time = first_time,
|
||||
last_time = last_time,
|
||||
last_seen = os.date("%Y-%m-%d %H:%M:%S", last_time)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -604,8 +823,7 @@ function action_lg_status()
|
||||
watching_limited[#watching_limited + 1] = watching[i]
|
||||
end
|
||||
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({
|
||||
write_json({
|
||||
enabled = enabled,
|
||||
running = running,
|
||||
threshold = threshold,
|
||||
@@ -623,32 +841,27 @@ end
|
||||
function action_lg_ban()
|
||||
local sys = require "luci.sys"
|
||||
local ip = luci.http.formvalue("ip") or ""
|
||||
if not ip:match("^%d+%.%d+%.%d+%.%d+$") then
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success=false, error="无效 IP"})
|
||||
if not valid_ban_ip(ip) then
|
||||
write_json({success=false, error="无效 IP"})
|
||||
return
|
||||
end
|
||||
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh ban '" .. ip .. "' >/dev/null 2>&1")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success = (rc == 0), ip = ip})
|
||||
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh ban " .. shellquote(ip) .. " >/dev/null 2>&1")
|
||||
write_json({success = (rc == 0), ip = ip})
|
||||
end
|
||||
|
||||
function action_lg_unban()
|
||||
local sys = require "luci.sys"
|
||||
local ip = luci.http.formvalue("ip") or ""
|
||||
if not ip:match("^%d+%.%d+%.%d+%.%d+$") then
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success=false, error="无效 IP"})
|
||||
if not valid_ban_ip(ip) then
|
||||
write_json({success=false, error="无效 IP"})
|
||||
return
|
||||
end
|
||||
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh unban '" .. ip .. "' >/dev/null 2>&1")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success = (rc == 0), ip = ip})
|
||||
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh unban " .. shellquote(ip) .. " >/dev/null 2>&1")
|
||||
write_json({success = (rc == 0), ip = ip})
|
||||
end
|
||||
|
||||
function action_lg_flush()
|
||||
local sys = require "luci.sys"
|
||||
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh flush >/dev/null 2>&1")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success = (rc == 0)})
|
||||
write_json({success = (rc == 0)})
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
local m,s,o
|
||||
local sys=require"luci.sys"
|
||||
local fs=require"nixio.fs"
|
||||
local uc=require"luci.model.uci".cursor()
|
||||
local ctl=require"luci.controller.minigate"
|
||||
|
||||
local function esc(v)
|
||||
v=tostring(v or "")
|
||||
@@ -10,96 +10,99 @@ local function esc(v)
|
||||
return v
|
||||
end
|
||||
|
||||
local function shellquote(v)
|
||||
v=tostring(v or "")
|
||||
return "'"..v:gsub("'","'\"'\"'").."'"
|
||||
local function acme_installed()
|
||||
return fs.access("/etc/minigate/acme/data/acme.sh") and fs.access("/etc/minigate/acme/data/dnsapi/dns_cf.sh")
|
||||
end
|
||||
|
||||
local dd={}
|
||||
uc:foreach("minigate","ddns",function(sec) if sec.domain and sec.domain~=""then dd[#dd+1]=sec.domain end end)
|
||||
|
||||
m=Map("minigate","MiniGate - SSL 证书","ACME(Let's Encrypt)自动签发。域名和令牌自动关联「动态DNS」。")
|
||||
m=Map("minigate","MiniGate - SSL 证书","ACME(Let's Encrypt)自动签发,每天自动续期。域名和令牌自动关联「动态DNS」。")
|
||||
|
||||
s=m:section(SimpleSection); s.template="minigate/style"
|
||||
|
||||
s=m:section(NamedSection,"acme","acme","ACME 设置"); s.anonymous=true
|
||||
|
||||
o=s:option(Flag,"enabled","启用 ACME"); o.rmempty=false
|
||||
|
||||
o=s:option(Value,"email","账户邮箱"); o.placeholder="admin@example.com"; o.rmempty=true
|
||||
o.description="可选。"
|
||||
o.datatype="string"
|
||||
o.description="可选,用于接收 Let's Encrypt 到期提醒。"
|
||||
|
||||
o=s:option(ListValue,"key_type","密钥类型")
|
||||
o:value("ec-256","ECC P-256(推荐)"); o:value("ec-384","ECC P-384"); o:value("rsa-2048","RSA 2048"); o:value("rsa-4096","RSA 4096")
|
||||
o.default="ec-256"
|
||||
|
||||
o=s:option(Flag,"staging","测试模式"); o.rmempty=false; o.default="1"
|
||||
o.description="正式使用请关闭。"
|
||||
o.description="测试模式签发的证书浏览器不信任,只用于验证配置。确认能签发成功后,请关闭测试模式再签发正式证书。"
|
||||
|
||||
-- 证书列表
|
||||
-- 证书列表:到期日期、剩余天数和证书类型直接从证书文件读取
|
||||
o=s:option(DummyValue,"_certs","已签发证书"); o.rawhtml=true
|
||||
o.cfgvalue=function()
|
||||
local cd="/etc/minigate/certs"
|
||||
local h='<table class="table"><tr class="tr table-titles"><th class="th">域名</th><th class="th">过期时间</th><th class="th">路径</th></tr>'
|
||||
local found=false
|
||||
if fs.dir(cd)then
|
||||
for entry in fs.dir(cd)do
|
||||
local fp=cd.."/"..entry
|
||||
-- lstat 返回 table,检查 type 字段
|
||||
local st=fs.lstat(fp)
|
||||
if entry~="_default" and st and st.type=="dir"then
|
||||
local cert=fp.."/fullchain.pem"
|
||||
if fs.access(cert)then
|
||||
found=true
|
||||
local exp=""
|
||||
local f=io.popen("openssl x509 -in "..shellquote(cert).." -noout -enddate 2>/dev/null")
|
||||
if f then local l=f:read("*l"); if l then exp=l:gsub("notAfter=","") end; f:close() end
|
||||
local dn=entry:gsub("_wildcard_%.", "*.")
|
||||
h=h..'<tr class="tr"><td class="td"><strong>'..esc(dn)..'</strong></td><td class="td">'..esc(exp)..'</td><td class="td"><code>'..esc(fp)..'/</code></td></tr>'
|
||||
end
|
||||
end
|
||||
end
|
||||
local certs=ctl.cert_list()
|
||||
local h=''
|
||||
if uc:get("minigate","acme","staging")=="1" then
|
||||
h=h..'<div class="mgx-note">测试模式已开启:签发的证书浏览器不信任。正式使用请关闭测试模式并保存,再点击下方「立即签发」。</div>'
|
||||
end
|
||||
if not found then h=h..'<tr class="tr"><td class="td" colspan="3" style="text-align:center;color:#999;padding:15px">暂无证书</td></tr>' end
|
||||
h=h..'<table class="table"><tr class="tr table-titles"><th class="th">域名</th><th class="th">到期</th><th class="th">类型</th></tr>'
|
||||
for _,c in ipairs(certs) do
|
||||
local left=c.days_left
|
||||
local cls=(left==nil and "mgx-muted") or (left<0 and "mgx-err") or ((left<14 or c.staging) and "mgx-warn") or "mgx-ok"
|
||||
local when=c.expires_date and (c.expires_date..(left<0 and "(已过期)" or "(剩 "..left.." 天)")) or "未知"
|
||||
h=h..'<tr class="tr"><td class="td"><strong>'..esc(c.domain)..'</strong></td><td class="td"><span class="'..cls..'">'..esc(when)..'</span></td>'
|
||||
h=h..'<td class="td">'..(c.staging and '<span class="mgx-warn">测试证书(不受信任)</span>' or '<span class="mgx-ok">正式证书</span>')..'</td></tr>'
|
||||
end
|
||||
if #certs==0 then h=h..'<tr class="tr"><td class="td mgx-muted" colspan="3" style="text-align:center;padding:15px">暂无证书</td></tr>' end
|
||||
return h..'</table>'
|
||||
end
|
||||
|
||||
o=s:option(DummyValue,"_ast","acme.sh 状态"); o.rawhtml=true
|
||||
o.cfgvalue=function()
|
||||
if fs.access("/etc/minigate/acme/data/acme.sh")and fs.access("/etc/minigate/acme/data/dnsapi/dns_cf.sh")then return'<span style="color:#4caf50">✓ 已安装</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
|
||||
if acme_installed() then return'<span class="mgx-ok">✓ 已安装</span>'
|
||||
elseif fs.access("/etc/minigate/acme/data/acme.sh")then return'<span class="mgx-warn">⚠ 缺 DNS 插件,请重新安装</span>'
|
||||
else return'<span class="mgx-err">✗ 未安装(首次签发时会自动安装)</span>'end
|
||||
end
|
||||
|
||||
-- 操作
|
||||
-- 操作:签发在后台进行,页面轮询结果,不受网页请求超时影响
|
||||
s=m:section(NamedSection,"acme","acme","操作"); s.anonymous=true
|
||||
o=s:option(DummyValue,"_act"," "); o.rawhtml=true
|
||||
o.cfgvalue=function()
|
||||
local iu=luci.dispatcher.build_url("admin/services/minigate/acme_install")
|
||||
local su=luci.dispatcher.build_url("admin/services/minigate/acme_issue")
|
||||
local ju=luci.dispatcher.build_url("admin/services/minigate/acme_job")
|
||||
local opts=""
|
||||
for _,d in ipairs(dd)do opts=opts..'<option value="'..esc(d)..'">'..esc(d)..'</option>' end
|
||||
return[[
|
||||
<div style="margin-bottom:15px"><button class="cbi-button cbi-button-reload" id="bi" onclick="doI()" style="min-width:220px">下载并安装 acme.sh</button></div>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
|
||||
<label>签发域名:</label>
|
||||
<select id="sd" style="padding:5px 10px;border-radius:4px;border:1px solid #ccc;min-width:250px">]]..
|
||||
local install_button=acme_installed() and "" or
|
||||
'<div class="mgx-actions"><button type="button" class="cbi-button cbi-button-reload" id="bi" onclick="doI()" style="min-width:220px">下载并安装 acme.sh</button></div>'
|
||||
return install_button..[[
|
||||
<div class="mgx-actions">
|
||||
<label for="sd">签发域名:</label>
|
||||
<select id="sd" class="mgx-select">]]..
|
||||
(opts~=""and opts or'<option value="">请先配置动态DNS</option>')..[[</select>
|
||||
<button class="cbi-button cbi-button-apply" id="bs" onclick="doS()">立即签发 / 续期</button></div>
|
||||
<div id="ap" class="mg-acme-note" style="display:none"><span style="display:inline-block;animation:sp 1s linear infinite;margin-right:8px">⏳</span><span id="am">处理中...</span></div>
|
||||
<div id="ar" style="display:none"><div id="ai" class="mg-acme-note" role="status"></div></div>
|
||||
<style>@keyframes sp{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
|
||||
.mg-acme-note{margin-top:12px;border-radius:4px;padding:10px 15px;font-size:13px;line-height:1.6;background:rgba(127,127,127,.12);border:1px solid rgba(127,127,127,.2)}
|
||||
.mg-acme-note.ok{background:rgba(47,158,68,.12);border-color:rgba(47,158,68,.32);color:#2e7d32}
|
||||
.mg-acme-note.fail{background:rgba(224,49,49,.1);border-color:rgba(224,49,49,.3);color:#c62828}
|
||||
:root[data-darkmode="true"] .mg-acme-note.ok{color:#78c98d}
|
||||
:root[data-darkmode="true"] .mg-acme-note.fail{color:#e58a84}
|
||||
@media(prefers-color-scheme:dark){:root:not([data-darkmode]) .mg-acme-note.ok{color:#78c98d}:root:not([data-darkmode]) .mg-acme-note.fail{color:#e58a84}}
|
||||
</style>
|
||||
<button type="button" class="cbi-button cbi-button-apply" id="bs" onclick="doS()">立即签发</button></div>
|
||||
<div class="mgx-small mgx-muted">证书剩余超过 30 天时不会重复签发,避免触发 Let's Encrypt 频率限制;到期前 30 天内会自动续期。</div>
|
||||
<div id="ap" class="mgx-note is-info" style="display:none"><span class="mgx-spin">⏳</span><span id="am">处理中...</span></div>
|
||||
<div id="ar" style="display:none"><div id="ai" class="mgx-note" role="status"></div></div>
|
||||
<script type="text/javascript">
|
||||
function sP(m){document.getElementById('ap').style.display='block';document.getElementById('ar').style.display='none';document.getElementById('am').textContent=m}
|
||||
function sR(ok,m){document.getElementById('ap').style.display='none';document.getElementById('ar').style.display='block';var e=document.getElementById('ai');e.className='mg-acme-note '+(ok?'ok':'fail');e.textContent=m}
|
||||
function sR(ok,m){document.getElementById('ap').style.display='none';document.getElementById('ar').style.display='block';var e=document.getElementById('ai');e.className='mgx-note '+(ok?'is-ok':'is-err');e.textContent=m}
|
||||
function acmePost(url,args,cb){var done=false;function finish(x,d){if(done)return;done=true;cb(d)}var p=XHR.post(url,args,finish);if(p&&typeof p.catch==='function')p.catch(function(){finish(null,null)})}
|
||||
function doI(){document.getElementById('bi').disabled=true;sP('下载安装中(约30秒)...');acmePost(']]..iu..[[',null,function(d){document.getElementById('bi').disabled=false;d?sR(d.success,d.message):sR(false,'请求未完成:安装可能仍在后台进行,请稍后刷新页面查看 acme.sh 状态');if(d&&d.success)setTimeout(function(){location.reload()},2000)})}
|
||||
function doS(){var d=document.getElementById('sd').value;if(!d){sR(false,'请选择域名');return}document.getElementById('bs').disabled=true;sP('签发 '+d+'(约1-2分钟)...');acmePost(']]..su..[[',{domain:d},function(r){document.getElementById('bs').disabled=false;r?sR(r.success,r.message):sR(false,'请求超时:签发可能仍在后台进行,请稍后刷新页面查看证书状态');if(r&&r.success)setTimeout(function(){location.reload()},2000)})}
|
||||
function acmeGet(url,cb){var done=false;function finish(x,d){if(done)return;done=true;cb(d)}var p=XHR.get(url,null,finish);if(p&&typeof p.catch==='function')p.catch(function(){finish(null,null)})}
|
||||
function acmeWait(button,kind,tries){
|
||||
acmeGet(']]..ju..[[',function(d){
|
||||
if(d&&d.running&&tries<150){setTimeout(function(){acmeWait(button,kind,tries+1)},2000);return}
|
||||
if(button)button.disabled=false;
|
||||
if(!d){sR(false,'无法读取任务结果,请稍后刷新页面查看');return}
|
||||
if(d.running){sR(false,'任务仍在进行,请稍后刷新页面查看');return}
|
||||
var ok,msg;
|
||||
if(kind==='install'){ok=d.code===0&&d.installed;msg=ok?'安装成功':'安装失败,请查看 ACME 日志';}
|
||||
else{ok=d.code===0&&d.result==='ok';msg=d.message||(ok?'签发成功':'签发失败,请查看 ACME 日志');}
|
||||
sR(ok,msg);
|
||||
if(ok)setTimeout(function(){location.reload()},2500);
|
||||
});
|
||||
}
|
||||
function doI(){var b=document.getElementById('bi');b.disabled=true;sP('正在下载并校验 acme.sh(约30秒)...');acmePost(']]..iu..[[',null,function(d){if(!d||!d.success){b.disabled=false;sR(false,(d&&d.message)||'无法启动安装任务');return}setTimeout(function(){acmeWait(b,'install',0)},2000)})}
|
||||
function doS(){var d=document.getElementById('sd').value;if(!d){sR(false,'请选择域名');return}var b=document.getElementById('bs');b.disabled=true;sP('正在签发 '+d+'(约1-2分钟,可离开本页)...');acmePost(']]..su..[[',{domain:d},function(r){if(!r||!r.success){b.disabled=false;sR(false,(r&&r.message)||'无法启动签发任务');return}setTimeout(function(){acmeWait(b,'issue',0)},3000)})}
|
||||
</script>]]
|
||||
end
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
local m,s,o
|
||||
local sys=require"luci.sys"
|
||||
local ctl=require"luci.controller.minigate"
|
||||
|
||||
local function esc(v)
|
||||
v=tostring(v or "")
|
||||
@@ -8,23 +8,47 @@ local function esc(v)
|
||||
return v
|
||||
end
|
||||
|
||||
m=Map("minigate","MiniGate - 动态DNS","支持多域名、IPv4/IPv6双栈。保存后立即同步。需先启用全局开关。")
|
||||
|
||||
-- 只重新同步 DDNS,不重启反向代理和登录防护
|
||||
m.on_after_commit=function(self)
|
||||
sys.call("/bin/sh /usr/lib/minigate/after_commit.sh ddns >/dev/null 2>&1")
|
||||
-- 域名:可带 *. 前缀,每段 1-63 位字母数字或连字符,不以连字符开头或结尾
|
||||
local function valid_domain(value)
|
||||
local name=tostring(value or ""):gsub("^%*%.","")
|
||||
if #name==0 or #name>253 then return false end
|
||||
local count=0
|
||||
for label in (name.."."):gmatch("([^.]*)%.") do
|
||||
if #label<1 or #label>63 or not label:match("^[%w%-]+$") or label:match("^%-") or label:match("%-$") then return false end
|
||||
count=count+1
|
||||
end
|
||||
return count>=2
|
||||
end
|
||||
|
||||
-- 保存并应用后由 procd 的配置触发器只重新同步 DDNS,不重启反向代理和登录防护
|
||||
m=Map("minigate","MiniGate - 动态DNS","支持多域名、IPv4/IPv6双栈。保存并应用后立即同步。需先在「总览」启用 MiniGate。")
|
||||
|
||||
s=m:section(SimpleSection); s.template="minigate/style"
|
||||
|
||||
s=m:section(TypedSection,"ddns","DDNS 记录")
|
||||
s.anonymous=true; s.addremove=true; s.sortable=true
|
||||
|
||||
o=s:option(Flag,"enabled","启用"); o.rmempty=false
|
||||
|
||||
o=s:option(Value,"domain","域名"); o.rmempty=false; o.placeholder="*.example.com 或 home.example.com"
|
||||
o.validate=function(self,value)
|
||||
if valid_domain(value) then return value end
|
||||
return nil,"域名格式不正确,例如 home.example.com 或 *.example.com"
|
||||
end
|
||||
|
||||
o=s:option(Value,"cf_zone_id","Zone ID"); o.rmempty=false; o.placeholder="Cloudflare 区域 ID"
|
||||
o.description="在 Cloudflare 域名概览页右下角「API」区域可以找到。"
|
||||
o.validate=function(self,value)
|
||||
if tostring(value or ""):match("^%x+$") and #value==32 then return value end
|
||||
return nil,"Zone ID 应为 32 位十六进制字符"
|
||||
end
|
||||
|
||||
o=s:option(Value,"cf_api_token","API 令牌"); o.rmempty=false; o.password=true
|
||||
o.description="在 Cloudflare「我的个人资料 → API 令牌」创建,权限选「区域 → DNS → 编辑」,区域资源选本域名。"
|
||||
o.validate=function(self,value)
|
||||
if tostring(value or ""):match("^[%w%-_]+$") then return value end
|
||||
return nil,"API 令牌只包含字母、数字、- 和 _,请检查是否复制了多余的空格"
|
||||
end
|
||||
|
||||
-- ====== 协议版本 ======
|
||||
o=s:option(ListValue,"ip_version","协议版本")
|
||||
@@ -36,6 +60,7 @@ o.description="选择更新哪种 DNS 记录。双栈模式同时更新 A 和 AA
|
||||
|
||||
o=s:option(ListValue,"ip_source","IP 来源")
|
||||
o:value("interface","从网卡获取"); o:value("url","从外部URL获取"); o.default="interface"
|
||||
o.description="软路由在上级路由后面(网卡是 192.168.x.x 等内网地址)时,请选「从外部URL获取」。内网地址不会被推送到 Cloudflare。"
|
||||
|
||||
-- IPv4 网卡
|
||||
o=s:option(ListValue,"interface","IPv4 网络接口"); o.default="wan"
|
||||
@@ -45,17 +70,17 @@ o:depends("ip_source","interface")
|
||||
|
||||
-- IPv4 URL
|
||||
o=s:option(ListValue,"ip_url","IPv4 获取地址")
|
||||
o:value("http://ip.3322.net","ip.3322.net")
|
||||
o:value("http://members.3322.org/dyndns/getip","members.3322.org")
|
||||
o:value("http://ns1.dnspod.net:6666","ns1.dnspod.net (腾讯)")
|
||||
o:value("http://ip.tool.chinaz.com/getip","chinaz.com (站长工具)")
|
||||
o.default="http://ip.3322.net"
|
||||
o.description="旁路由/代理环境自动绕过代理直连获取真实公网IP"
|
||||
o:value("https://ddns.oray.com/checkip","ddns.oray.com(HTTPS,推荐)")
|
||||
o:value("http://ip.3322.net","ip.3322.net(HTTP)")
|
||||
o:value("http://members.3322.org/dyndns/getip","members.3322.org(HTTP)")
|
||||
o:value("http://ns1.dnspod.net:6666","ns1.dnspod.net(HTTP)")
|
||||
o.default="https://ddns.oray.com/checkip"
|
||||
o.description="所选地址失败时自动尝试其他地址;旁路由/代理环境会绕过代理直连获取真实公网 IP。"
|
||||
o:depends("ip_source","url")
|
||||
|
||||
-- IPv6 网卡
|
||||
o=s:option(ListValue,"interface6","IPv6 网络接口"); o.default="wan6"
|
||||
o.description="用于获取 IPv6 地址的接口(通常为 wan6)"
|
||||
o.description="用于获取 IPv6 地址的接口(通常为 wan6);只使用全球单播地址。"
|
||||
uci:foreach("network","interface",function(sec) if sec[".name"]~="loopback"then o:value(sec[".name"],sec[".name"])end end)
|
||||
o:depends({ip_source="interface",ip_version="ipv6"})
|
||||
o:depends({ip_source="interface",ip_version="dual"})
|
||||
@@ -66,45 +91,37 @@ o:value("https://api6.ipify.org","api6.ipify.org")
|
||||
o:value("https://6.ipw.cn","6.ipw.cn")
|
||||
o:value("https://v6.ident.me","v6.ident.me")
|
||||
o:value("https://ifconfig.co","ifconfig.co (v6)")
|
||||
o:value("http://v6.66666.host:66/ip","v6.66666.host")
|
||||
o:value("http://v6.66666.host:66/ip","v6.66666.host(HTTP)")
|
||||
o.default="https://api6.ipify.org"
|
||||
o.description="仅通过 IPv6 连接获取地址"
|
||||
o:depends({ip_source="url",ip_version="ipv6"})
|
||||
o:depends({ip_source="url",ip_version="dual"})
|
||||
|
||||
o=s:option(Value,"check_interval","间隔(秒)"); o.datatype="uinteger"; o.default="300"
|
||||
o.description="最小 300 秒。定时任务每 5 分钟检查一次,实际间隔按 5 分钟向上取整。"
|
||||
o=s:option(Value,"check_interval","检查间隔(秒)"); o.datatype="uinteger"; o.default="300"
|
||||
o:value("300","5 分钟"); o:value("600","10 分钟"); o:value("1800","30 分钟"); o:value("3600","1 小时")
|
||||
o.description="最少 300 秒。定时任务每 5 分钟检查一次,实际间隔按 5 分钟向上取整。"
|
||||
o.validate=function(self,value)
|
||||
local n=tonumber(value)
|
||||
if n and n>=300 then return value end
|
||||
return nil,"检查间隔不能少于 300 秒"
|
||||
end
|
||||
|
||||
-- 状态+手动同步
|
||||
-- 状态+手动同步(状态来自 /var/run,不写入配置文件)
|
||||
local STATE={ok={"✓ 正常","mgx-ok"},partial={"⚠ 部分成功","mgx-warn"},error={"✗ 异常","mgx-err"},stopped={"已停止","mgx-muted"}}
|
||||
o=s:option(DummyValue,"_info","运行状态"); o.rawhtml=true
|
||||
o.cfgvalue=function(self,section)
|
||||
local st=m.uci:get("minigate",section,"status")or"unknown"
|
||||
local msg=m.uci:get("minigate",section,"status_msg")or""
|
||||
local ip=m.uci:get("minigate",section,"last_ip")or""
|
||||
local ip6=m.uci:get("minigate",section,"last_ip6")or""
|
||||
local lu=m.uci:get("minigate",section,"last_update")or""
|
||||
local ns=m.uci:get("minigate",section,"next_sync")or""
|
||||
local ver=m.uci:get("minigate",section,"ip_version")or"ipv4"
|
||||
|
||||
local h=""
|
||||
if st=="ok"then
|
||||
h='<span style="color:#4caf50">✓ 正常</span>'
|
||||
elseif st=="partial"then
|
||||
h='<span style="color:#ff9800">⚠ 部分成功</span>'
|
||||
elseif st=="error"then
|
||||
h='<span style="color:#f44336">✗ '..esc(msg~=""and msg or"异常")..'</span>'
|
||||
else h='<span style="color:#999">未同步</span>' end
|
||||
|
||||
-- 显示 IP 地址
|
||||
if ip~=""then h=h..'<br><span style="font-size:11px;color:#888">A: '..esc(ip)..'</span>' end
|
||||
if ip6~=""then h=h..'<br><span style="font-size:11px;color:#2196f3">AAAA: '..esc(ip6)..'</span>' end
|
||||
if msg~=""and st~="error"then h=h..'<br><span style="font-size:11px;color:#888">'..esc(msg)..'</span>' end
|
||||
if lu~=""then h=h..'<br><span style="font-size:11px;color:#888">更新: '..esc(lu)..'</span>' end
|
||||
if ns~=""then h=h..'<br><span style="font-size:11px;color:#888">下次: '..esc(ns)..'</span>' end
|
||||
|
||||
h=h..'<br><button class="cbi-button cbi-button-action" style="margin-top:4px;font-size:12px;padding:2px 10px" '
|
||||
h=h..'onclick="doSync(\''..section..'\',this)">手动同步</button>'
|
||||
h=h..'<span id="sr-'..section..'" style="margin-left:8px;font-size:12px"></span>'
|
||||
local domain=m.uci:get("minigate",section,"domain")or""
|
||||
local st=ctl.ddns_state(domain)
|
||||
local label=STATE[st.status or ""] or {"未同步","mgx-muted"}
|
||||
local h='<span id="ss-'..section..'"><span class="'..label[2]..'">'..label[1]..'</span>'
|
||||
if st.status=="error" and (st.message or "")~="" then h=h..' <span class="mgx-err">'..esc(st.message)..'</span>' end
|
||||
if (st.ip or "")~="" then h=h..'<span class="mgx-small mgx-muted">A: '..esc(st.ip)..'</span>' end
|
||||
if (st.ip6 or "")~="" then h=h..'<span class="mgx-small mgx-info">AAAA: '..esc(st.ip6)..'</span>' end
|
||||
if (st.message or "")~="" and st.status~="error" then h=h..'<span class="mgx-small mgx-muted">'..esc(st.message)..'</span>' end
|
||||
if (st.updated or "")~="" then h=h..'<span class="mgx-small mgx-muted">更新: '..esc(st.updated)..'</span>' end
|
||||
if (st.next or "")~="" then h=h..'<span class="mgx-small mgx-muted">下次: '..esc(st.next)..'</span>' end
|
||||
h=h..'</span><br><button type="button" class="cbi-button cbi-button-action mgx-sync" onclick="doSync(\''..section..'\',this)">手动同步</button>'
|
||||
h=h..'<span id="sr-'..section..'" class="mgx-sync-result" role="status"></span>'
|
||||
return h
|
||||
end
|
||||
|
||||
@@ -117,24 +134,53 @@ s=m:section(NamedSection,"global","global"); s.anonymous=true
|
||||
o=s:option(DummyValue,"_js"," "); o.rawhtml=true
|
||||
o.cfgvalue=function()
|
||||
local url=luci.dispatcher.build_url("admin/services/minigate/ddns_sync")
|
||||
local job=luci.dispatcher.build_url("admin/services/minigate/ddns_job")
|
||||
return[[<script type="text/javascript">
|
||||
function doSync(s,b){
|
||||
var e=document.getElementById("sr-"+s),done=false;
|
||||
function show(ok,text){var span=document.createElement("span");span.style.color=ok?"#4caf50":"#f44336";span.textContent=text;e.textContent="";e.appendChild(span);}
|
||||
function finish(x,d){
|
||||
if(done)return;done=true;
|
||||
b.disabled=false;b.textContent="手动同步";
|
||||
if(d&&d.success){
|
||||
show(true,"\u2713 "+[d.ip,d.ip6].filter(Boolean).join(" / "));
|
||||
setTimeout(function(){location.reload()},1500);
|
||||
}else{
|
||||
show(false,"\u2717 "+((d&&d.message)||(d?"同步失败,查看日志":"请求失败,请检查网络后重试")));
|
||||
}
|
||||
}
|
||||
b.disabled=true;b.textContent="同步中...";e.textContent="";
|
||||
var p=XHR.post("]]..url..[[",{section:s},finish);
|
||||
var mgFormDirty=false;
|
||||
document.addEventListener('change',function(e){if(e.target&&e.target.form&&e.target.className.indexOf('mgx-')<0)mgFormDirty=true;},true);
|
||||
function mgEsc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
|
||||
function mgShowState(s,d){
|
||||
var box=document.getElementById("ss-"+s);if(!box||!d)return;
|
||||
var labels={ok:['✓ 正常','mgx-ok'],partial:['⚠ 部分成功','mgx-warn'],error:['✗ 异常','mgx-err'],stopped:['已停止','mgx-muted']};
|
||||
var l=labels[d.status]||['未同步','mgx-muted'];
|
||||
var h='<span class="'+l[1]+'">'+l[0]+'</span>';
|
||||
if(d.status=='error'&&d.message)h+=' <span class="mgx-err">'+mgEsc(d.message)+'</span>';
|
||||
if(d.ip)h+='<span class="mgx-small mgx-muted">A: '+mgEsc(d.ip)+'</span>';
|
||||
if(d.ip6)h+='<span class="mgx-small mgx-info">AAAA: '+mgEsc(d.ip6)+'</span>';
|
||||
if(d.message&&d.status!='error')h+='<span class="mgx-small mgx-muted">'+mgEsc(d.message)+'</span>';
|
||||
if(d.updated)h+='<span class="mgx-small mgx-muted">更新: '+mgEsc(d.updated)+'</span>';
|
||||
if(d.next)h+='<span class="mgx-small mgx-muted">下次: '+mgEsc(d.next)+'</span>';
|
||||
box.innerHTML=h;
|
||||
}
|
||||
function mgRequest(method,url,args,cb){
|
||||
var done=false;
|
||||
function finish(x,d){if(done)return;done=true;cb(d);}
|
||||
var p=XHR[method](url,args,finish);
|
||||
if(p&&typeof p.catch==="function")p.catch(function(){finish(null,null)});
|
||||
}
|
||||
// 同步在后台进行,完成后原地更新状态,不刷新页面,表单里未保存的修改不会丢
|
||||
function doSync(s,b){
|
||||
var e=document.getElementById("sr-"+s),tries=0;
|
||||
function show(cls,text){e.innerHTML='<span class="'+cls+'">'+mgEsc(text)+'</span>';}
|
||||
function done(){b.disabled=false;b.textContent="手动同步";}
|
||||
if(mgFormDirty&&!confirm("表单里有尚未保存的修改。手动同步使用已保存的配置,确定继续吗?"))return;
|
||||
b.disabled=true;b.textContent="同步中...";e.textContent="";
|
||||
function poll(){
|
||||
mgRequest('get',"]]..job..[[",{section:s},function(d){
|
||||
if(d&&d.running&&tries++<90){setTimeout(poll,2000);return;}
|
||||
done();
|
||||
if(!d){show('mgx-err',"✗ 无法读取同步结果,请稍后刷新页面");return;}
|
||||
if(d.running){show('mgx-warn',"仍在同步,请稍后刷新页面查看");return;}
|
||||
mgShowState(s,d);
|
||||
if(d.success)show('mgx-ok',"✓ 同步完成 "+[d.ip,d.ip6].filter(Boolean).join(" / "));
|
||||
else show('mgx-err',"✗ "+(d.message||"同步失败,查看日志"));
|
||||
});
|
||||
}
|
||||
mgRequest('post',"]]..url..[[",{section:s},function(d){
|
||||
if(!d||!d.success){done();show('mgx-err',"✗ "+((d&&d.message)||"请求失败,请检查网络后重试"));return;}
|
||||
setTimeout(poll,1500);
|
||||
});
|
||||
}
|
||||
</script>]]
|
||||
end
|
||||
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
local m, s, o
|
||||
local sys = require "luci.sys"
|
||||
|
||||
m = Map("minigate", "minigate", "轻量级网关管理:动态域名解析(IPv4/IPv6双栈)、SSL 证书签发、反向代理。")
|
||||
-- 保存并应用后由 procd 的配置触发器重载服务(见 /etc/init.d/minigate)
|
||||
m = Map("minigate", "MiniGate", "轻量级网关管理:动态域名解析(IPv4/IPv6双栈)、SSL 证书签发、反向代理和登录防护。")
|
||||
|
||||
m.on_after_commit = function(self)
|
||||
local en = self.uci:get("minigate", "global", "enabled")
|
||||
sys.call("/bin/sh /usr/lib/minigate/after_commit.sh " .. (en == "1" and "restart" or "stop") .. " >/dev/null 2>&1")
|
||||
end
|
||||
-- 总开关放在最上方,新安装时不必翻到页面底部寻找
|
||||
s = m:section(NamedSection, "global", "global", "全局设置")
|
||||
s.anonymous = true
|
||||
o = s:option(Flag, "enabled", "启用 MiniGate")
|
||||
o.description = "总开关。关闭后停止 DDNS 定时任务、证书续期和反向代理;登录防护单独控制。点击「保存并应用」后生效。"
|
||||
o.rmempty = false
|
||||
|
||||
s = m:section(NamedSection, "global", "global", "服务状态")
|
||||
s.anonymous = true
|
||||
s:tab("status", "总览")
|
||||
|
||||
local su = luci.dispatcher.build_url("admin/services/minigate/status")
|
||||
|
||||
o = s:taboption("status", DummyValue, "_status")
|
||||
o = s:option(DummyValue, "_status")
|
||||
o.rawhtml = true
|
||||
o.cfgvalue = function()
|
||||
local su = luci.dispatcher.build_url("admin/services/minigate/status")
|
||||
@@ -29,11 +28,14 @@ o.cfgvalue = function()
|
||||
return [[
|
||||
<style>
|
||||
.mg-wrap{display:flex;flex-direction:column;gap:16px;--mg-success:#2f9e44;--mg-info:#1971c2;--mg-warning:#f08c00;--mg-danger:#e03131;--mg-danger-solid:#c92a2a;--mg-muted:#6b7280;--mg-link:#15803d;--mg-accent:#7c3aed;--mg-surface:#fff;--mg-surface-muted:#f6f8fb;--mg-surface-alt:#f8fafc;--mg-surface-hover:#eef4ff;--mg-control:#fff;--mg-text:#1f2937;--mg-text-soft:#2f3a47;--mg-text-muted:#5f6b7a;--mg-border:#d7dce3;--mg-border-soft:#e6eaf0;--mg-code-bg:#e8edf6;--mg-shadow:0 10px 24px rgba(15,23,42,.08)}
|
||||
.mg-map{--mg-danger:#c92a2a;--mg-danger-hover:#fff5f5;--mg-danger-border:#f1b8b8}
|
||||
.mg-page-head{display:flex;align-items:flex-start;justify-content:space-between;gap:28px}
|
||||
.mg-page-head-copy{min-width:0;flex:1}
|
||||
.mg-page-head-copy>h2{margin-top:0}
|
||||
.mg-title-action{display:flex;flex:0 0 auto;margin-top:15px}
|
||||
.mg-wrap{--mg-danger-hover:#fff5f5;--mg-danger-border:#f1b8b8}
|
||||
.mg-notice{padding:12px 16px;border-radius:8px;border:1px solid var(--mg-border);border-left:4px solid var(--mg-warning);background:var(--mg-surface);color:var(--mg-text);font-size:13px;line-height:1.6}
|
||||
.mg-notice[hidden]{display:none}
|
||||
.mg-row{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
|
||||
.mg-dot{display:inline-block;flex:0 0 auto;width:8px;height:8px;border-radius:50%;transform:translateY(-1px)}
|
||||
.mg-sub{color:var(--mg-text-muted)}
|
||||
.mg-warn-text{color:var(--mg-warning)}
|
||||
.mg-danger-text{color:var(--mg-danger)}
|
||||
.mg-uninstall-trigger{min-width:112px;min-height:38px;border:1px solid var(--mg-danger-border)!important;border-radius:6px!important;background:transparent!important;color:var(--mg-danger)!important;font-weight:600}
|
||||
.mg-uninstall-trigger:hover,.mg-uninstall-trigger:focus{background:var(--mg-danger-hover)!important}
|
||||
.mg-status-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
|
||||
@@ -90,13 +92,13 @@ o.cfgvalue = function()
|
||||
.mg-danger-button:hover{filter:brightness(.94)}
|
||||
.mg-danger-button:focus{outline-color:var(--mg-danger)!important}
|
||||
:root[data-darkmode="true"] .mg-wrap{--mg-success:#78c98d;--mg-info:#7db7df;--mg-warning:#d7a64a;--mg-danger:#e58a84;--mg-danger-solid:#a9504d;--mg-muted:#9aa7b3;--mg-link:#7bcf91;--mg-accent:#b69af4;--mg-surface:var(--background-color-medium,#171e26);--mg-surface-muted:var(--background-color-high,#131a22);--mg-surface-alt:var(--background-color-low,#19212a);--mg-surface-hover:#202a34;--mg-control:var(--background-color-high,#111820);--mg-text:var(--text-color-high,#d5dbe3);--mg-text-soft:var(--text-color-high,#c8d0d9);--mg-text-muted:var(--text-color-medium,#9aa7b3);--mg-border:var(--border-color-medium,rgba(148,163,184,.18));--mg-border-soft:var(--border-color-low,rgba(148,163,184,.12));--mg-code-bg:var(--background-color-low,#253142);--mg-shadow:none}
|
||||
:root[data-darkmode="true"] .mg-map{--mg-danger:#e58a84;--mg-danger-hover:rgba(229,138,132,.1);--mg-danger-border:rgba(229,138,132,.45)}
|
||||
:root[data-darkmode="true"] .mg-wrap{--mg-danger-hover:rgba(229,138,132,.1);--mg-danger-border:rgba(229,138,132,.45)}
|
||||
@media(prefers-color-scheme:dark){
|
||||
:root:not([data-darkmode]) .mg-wrap{--mg-success:#78c98d;--mg-info:#7db7df;--mg-warning:#d7a64a;--mg-danger:#e58a84;--mg-danger-solid:#a9504d;--mg-muted:#9aa7b3;--mg-link:#7bcf91;--mg-accent:#b69af4;--mg-surface:#171e26;--mg-surface-muted:#131a22;--mg-surface-alt:#19212a;--mg-surface-hover:#202a34;--mg-control:#111820;--mg-text:#d5dbe3;--mg-text-soft:#c8d0d9;--mg-text-muted:#9aa7b3;--mg-border:rgba(148,163,184,.18);--mg-border-soft:rgba(148,163,184,.12);--mg-code-bg:#253142;--mg-shadow:none}
|
||||
:root:not([data-darkmode]) .mg-map{--mg-danger:#e58a84;--mg-danger-hover:rgba(229,138,132,.1);--mg-danger-border:rgba(229,138,132,.45)}
|
||||
:root:not([data-darkmode]) .mg-wrap{--mg-danger-hover:rgba(229,138,132,.1);--mg-danger-border:rgba(229,138,132,.45)}
|
||||
}
|
||||
@media(max-width:900px){.mg-status-grid{grid-template-columns:1fr}.mg-card{min-height:auto}}
|
||||
@media(max-width:640px){.mg-page-head{flex-direction:column;gap:12px}.mg-title-action{width:100%;margin-top:0}.mg-uninstall-trigger{width:100%}.mg-modal{padding:12px}.mg-modal-dialog{max-height:88vh}.mg-modal-actions{display:grid;grid-template-columns:1fr 1fr}.mg-modal-actions button{width:100%;min-width:0}
|
||||
@media(max-width:640px){.mg-uninstall-trigger{width:100%}.mg-update-actions{width:100%}.mg-modal{padding:12px}.mg-modal-dialog{max-height:88vh}.mg-modal-actions{display:grid;grid-template-columns:1fr 1fr}.mg-modal-actions button{width:100%;min-width:0}
|
||||
.mg-table{min-width:0}.mg-table thead{display:none}
|
||||
.mg-table tbody tr{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;padding:12px 14px;border-bottom:1px solid var(--mg-border-soft)}
|
||||
.mg-table tbody tr:nth-child(even){background:var(--mg-surface-alt)}
|
||||
@@ -113,9 +115,7 @@ o.cfgvalue = function()
|
||||
</style>
|
||||
|
||||
<div class="mg-wrap">
|
||||
<div id="mg-title-action" class="mg-title-action" hidden>
|
||||
<button type="button" id="mg-uninstall-open" class="cbi-button mg-uninstall-trigger" onclick="mgOpenUninstallDialog()">卸载</button>
|
||||
</div>
|
||||
<div id="mg-disabled" class="mg-notice" role="status" hidden>MiniGate 尚未启用:勾选上方「启用 MiniGate」,再点击页面底部的「保存并应用」。</div>
|
||||
<div id="mg" class="mg-status-grid">
|
||||
<div id="mg-ddns-card" class="mg-card" style="--accent:var(--mg-success)">
|
||||
<div class="mg-card-title">动态 DNS</div>
|
||||
@@ -177,15 +177,28 @@ o.cfgvalue = function()
|
||||
<div id="mg-v-list" class="mg-table-wrap"><div class="mg-empty">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<div id="mg-danger" class="mg-panel">
|
||||
<div class="mg-panel-head"><div class="mg-panel-title">危险操作</div></div>
|
||||
<div class="mg-update-body">
|
||||
<div class="mg-update-copy">
|
||||
<div class="mg-update-version">卸载 MiniGate</div>
|
||||
<div class="mg-update-message">停止所有服务并移除程序;卸载时可选择是否保留配置、证书和封禁记录。</div>
|
||||
</div>
|
||||
<div class="mg-update-actions">
|
||||
<button type="button" id="mg-uninstall-open" class="cbi-button mg-uninstall-trigger" onclick="mgOpenUninstallDialog()">卸载</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mg-uninstall-modal" class="mg-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="mg-uninstall-modal-title">
|
||||
<div class="mg-modal-dialog">
|
||||
<div class="mg-modal-head">
|
||||
<h3 id="mg-uninstall-modal-title" class="mg-modal-title">卸载 minigate</h3>
|
||||
<h3 id="mg-uninstall-modal-title" class="mg-modal-title">卸载 MiniGate</h3>
|
||||
<button type="button" id="mg-uninstall-close" class="mg-modal-close" aria-label="关闭卸载确认" title="关闭卸载确认" onclick="mgCloseUninstallDialog()">×</button>
|
||||
</div>
|
||||
<div class="mg-uninstall-body">
|
||||
<p>卸载会停止动态 DNS、证书续期、反向代理和登录防护,并移除 minigate 的程序及 LuCI 页面。</p>
|
||||
<div class="mg-uninstall-warning">由 minigate 提供的公网访问会立即中断。</div>
|
||||
<p>卸载会停止动态 DNS、证书续期、反向代理和登录防护,并移除 MiniGate 的程序及 LuCI 页面。</p>
|
||||
<div class="mg-uninstall-warning">由 MiniGate 提供的公网访问会立即中断。</div>
|
||||
<label class="mg-uninstall-choice" for="mg-uninstall-purge">
|
||||
<input type="checkbox" id="mg-uninstall-purge" onchange="mgUpdateUninstallChoice()">
|
||||
<span>同时删除配置、证书、封禁记录和日志</span>
|
||||
@@ -338,33 +351,6 @@ var _updateReloading=false;
|
||||
var _uninstallDialogOpener=null;
|
||||
var _uninstallStarting=false;
|
||||
|
||||
function mgPreparePageHeader(){
|
||||
var action=document.getElementById('mg-title-action');
|
||||
var wrap=document.querySelector('.mg-wrap');
|
||||
if(!action||!wrap)return;
|
||||
var map=wrap;
|
||||
while(map&&(' '+map.className+' ').indexOf(' cbi-map ')<0)map=map.parentNode;
|
||||
if(!map)return;
|
||||
var title=null,description=null;
|
||||
for(var i=0;i<map.children.length;i++){
|
||||
var child=map.children[i];
|
||||
if(!title&&child.tagName&&child.tagName.toLowerCase()==='h2')title=child;
|
||||
else if(title&&(' '+child.className+' ').indexOf(' cbi-map-descr ')>=0){description=child;break;}
|
||||
}
|
||||
if(!title)return;
|
||||
var head=document.createElement('div');
|
||||
var copy=document.createElement('div');
|
||||
head.className='mg-page-head';
|
||||
copy.className='mg-page-head-copy';
|
||||
map.className+=' mg-map';
|
||||
map.insertBefore(head,title);
|
||||
head.appendChild(copy);
|
||||
copy.appendChild(title);
|
||||
if(description)copy.appendChild(description);
|
||||
action.hidden=false;
|
||||
head.appendChild(action);
|
||||
}
|
||||
|
||||
function mgOpenUninstallDialog(){
|
||||
if(_uninstallStarting)return;
|
||||
var modal=document.getElementById('mg-uninstall-modal');
|
||||
@@ -419,7 +405,7 @@ function mgStartUninstall(){
|
||||
return;
|
||||
}
|
||||
confirm.textContent='卸载中...';
|
||||
status.textContent='正在移除 minigate,完成后将返回服务页面。';
|
||||
status.textContent='正在移除 MiniGate,完成后将返回服务页面。';
|
||||
setTimeout(function(){location.href=']] .. services_url .. [[';},3000);
|
||||
});
|
||||
}
|
||||
@@ -564,8 +550,6 @@ document.addEventListener('keydown',function(e){
|
||||
}
|
||||
});
|
||||
|
||||
mgPreparePageHeader();
|
||||
|
||||
var limitSel=document.getElementById('mg-v-limit');
|
||||
if(limitSel){
|
||||
limitSel.value=_visitorLimit;
|
||||
@@ -595,48 +579,106 @@ function mgLoadStatus(){
|
||||
});
|
||||
}
|
||||
|
||||
function mgRenderStatus(d){
|
||||
var d1=document.getElementById('mg-d1'),d2=document.getElementById('mg-d2');
|
||||
var card=document.getElementById('mg-ddns-card');
|
||||
if(d.ddns_list&&d.ddns_list.length>0){
|
||||
var e=d.ddns_list[0];
|
||||
var c=e.status=='ok'?mgColor('success'):(e.status=='partial'?mgColor('warning'):(e.enabled=='1'?mgColor('danger'):mgColor('muted')));
|
||||
var l=e.status=='ok'?'\u2713 \u8fd0\u884c\u4e2d':(e.status=='partial'?'\u26a0 \u90e8\u5206\u6210\u529f':(e.enabled=='1'?'\u26a0 \u5f02\u5e38':'\u672a\u542f\u7528'));
|
||||
card.style.setProperty('--accent',c);
|
||||
d1.innerHTML='<span style="color:'+c+'">'+l+'</span>';
|
||||
var info=(e.domain||'')+'\n';
|
||||
if(e.last_ip)info+='A: '+e.last_ip+'\n';
|
||||
if(e.last_ip6)info+='AAAA: '+e.last_ip6+'\n';
|
||||
var sm=e.status_msg||'';
|
||||
if(sm){
|
||||
var dupA=e.last_ip&&sm.indexOf('A:'+e.last_ip)>=0;
|
||||
var dupAAAA=e.last_ip6&&sm.indexOf('AAAA:'+e.last_ip6)>=0;
|
||||
var onlyIpStatus=sm.replace(/A:[^;]+;?/g,'').replace(/AAAA:[^;]+;?/g,'').replace(/\s+/g,'')=='';
|
||||
if(!(onlyIpStatus&&(dupA||dupAAAA)))info+=sm+'\n';
|
||||
var MG_DDNS_STATE={ok:['\u2713 正常','success'],partial:['\u26a0 部分成功','warning'],error:['\u26a0 异常','danger'],stopped:['已停止','muted'],unknown:['未同步','muted'],disabled:['未启用','muted']};
|
||||
|
||||
function mgStateHtml(label,color){
|
||||
return '<span style="color:'+mgColor(color)+'">'+escHtml(label)+'</span>';
|
||||
}
|
||||
if(e.last_update)info+='\u66f4\u65b0: '+e.last_update+'\n';
|
||||
if(e.next_sync)info+='\u4e0b\u6b21: '+e.next_sync;
|
||||
if(d.ddns_list.length>1)info+='\n(+'+(d.ddns_list.length-1)+' \u6761\u8bb0\u5f55)';
|
||||
d2.innerHTML=escHtml(info).replace(/\n/g,'<br>');
|
||||
}else{d1.innerHTML='<span style="color:'+mgColor('muted')+'">\u672a\u914d\u7f6e</span>';d2.textContent='';card.style.setProperty('--accent',mgColor('muted'));}
|
||||
|
||||
var a1=document.getElementById('mg-a1'),a2=document.getElementById('mg-a2');
|
||||
if(d.acme&&d.acme.enabled=='1'){
|
||||
a1.innerHTML=d.acme.status=='ok'?'<span style="color:'+mgColor('info')+'">\u2713 \u6709\u6548</span>':'<span style="color:'+mgColor('danger')+'">'+escHtml(d.acme.status)+'</span>';
|
||||
a2.innerHTML=escHtml(d.acme.last_domain||'')+(d.acme.cert_expiry?'<br>\u8fc7\u671f: '+escHtml(d.acme.cert_expiry):'');
|
||||
}else{a1.innerHTML='<span style="color:'+mgColor('muted')+'">\u672a\u542f\u7528</span>';a2.textContent='';}
|
||||
function mgDot(color){
|
||||
return '<span class="mg-dot" style="background:'+mgColor(color)+'"></span>';
|
||||
}
|
||||
|
||||
var p1=document.getElementById('mg-p1'),p2=document.getElementById('mg-p2');
|
||||
p1.innerHTML=d.proxy_running?'<span style="color:'+mgColor('warning')+'">\u2713 \u8fd0\u884c\u4e2d</span>':'<span style="color:'+mgColor('muted')+'">\u5df2\u505c\u6b62</span>';
|
||||
var pinfo='';
|
||||
if(d.proxy_rules&&d.proxy_rules.length>0){
|
||||
for(var i=0;i<d.proxy_rules.length;i++){
|
||||
var r=d.proxy_rules[i];
|
||||
var url=r.scheme+'://'+r.domain+(r.listen_port!='443'&&r.listen_port!='80'?':'+r.listen_port:'');
|
||||
var v6tag=r.ipv6_listen=='1'?' <span style="color:'+mgColor('info')+';font-size:10px">[IPv6]</span>':'';
|
||||
pinfo+='<div><span class="mg-link">'+escHtml(url)+'</span>'+v6tag+' \u2192 '+escHtml(r.target)+'</div>';
|
||||
}}else if(d.proxy_running){pinfo='Nginx \u8fd0\u884c\u4e2d';}
|
||||
p2.innerHTML=pinfo;
|
||||
function mgRenderDdns(d){
|
||||
var d1=document.getElementById('mg-d1'),d2=document.getElementById('mg-d2');
|
||||
var card=document.getElementById('mg-ddns-card');
|
||||
var list=mgList(d.ddns_list)||[];
|
||||
if(!list.length){
|
||||
d1.innerHTML=mgStateHtml('未配置','muted');d2.textContent='';
|
||||
card.style.setProperty('--accent',mgColor('muted'));
|
||||
return;
|
||||
}
|
||||
// 卡片颜色取所有已启用记录中最差的状态,任一记录异常都能看到
|
||||
var overall=MG_DDNS_STATE[d.ddns_status||'disabled']||MG_DDNS_STATE.unknown;
|
||||
card.style.setProperty('--accent',mgColor(overall[1]));
|
||||
d1.innerHTML=mgStateHtml(overall[0],overall[1]);
|
||||
var h='';
|
||||
for(var i=0;i<list.length;i++){
|
||||
var e=list[i],st=MG_DDNS_STATE[e.status]||MG_DDNS_STATE.unknown;
|
||||
var ips=[e.last_ip?'A '+e.last_ip:'',e.last_ip6?'AAAA '+e.last_ip6:''].filter(Boolean).join(' · ');
|
||||
h+='<div class="mg-row">'+mgDot(st[1])+'<strong>'+escHtml(e.domain||'--')+'</strong>'+(ips?'<span class="mg-sub">'+escHtml(ips)+'</span>':'')+'</div>';
|
||||
if(e.status_msg&&(e.status=='error'||e.status=='partial'))h+='<div class="mg-'+(e.status=='error'?'danger':'warn')+'-text">'+escHtml(e.status_msg)+'</div>';
|
||||
if(e.last_update||e.next_sync)h+='<div class="mg-sub">'+escHtml((e.last_update?'更新 '+e.last_update:'')+(e.next_sync?' · 下次 '+e.next_sync:''))+'</div>';
|
||||
}
|
||||
d2.innerHTML=h;
|
||||
}
|
||||
|
||||
function mgCertLine(c){
|
||||
var left=typeof c.days_left==='number'?c.days_left:null;
|
||||
var color=left===null?'muted':(left<0?'danger':(left<14||c.staging?'warning':'success'));
|
||||
var when=c.expires_date?('到期 '+c.expires_date+(left===null?'':(left<0?'(已过期)':'(剩 '+left+' 天)'))):'到期时间未知';
|
||||
return '<div class="mg-row">'+mgDot(color)+'<strong>'+escHtml(c.domain)+'</strong><span class="mg-sub">'+escHtml(when)+'</span>'+(c.staging?'<span class="mg-warn-text">测试证书,浏览器不信任</span>':'')+'</div>';
|
||||
}
|
||||
|
||||
function mgRenderCerts(d){
|
||||
var a1=document.getElementById('mg-a1'),a2=document.getElementById('mg-a2');
|
||||
var card=document.getElementById('mg-cert-card');
|
||||
var certs=mgList(d.certs)||[];
|
||||
var acme=d.acme||{};
|
||||
var label,color;
|
||||
if(certs.length){
|
||||
var expired=false,soon=false,staging=false;
|
||||
for(var i=0;i<certs.length;i++){
|
||||
var left=certs[i].days_left;
|
||||
if(typeof left==='number'&&left<0)expired=true;
|
||||
else if(typeof left==='number'&&left<14)soon=true;
|
||||
if(certs[i].staging)staging=true;
|
||||
}
|
||||
if(expired){label='\u26a0 有证书已过期';color='danger';}
|
||||
else if(soon){label='\u26a0 即将到期';color='warning';}
|
||||
else if(staging){label='\u26a0 测试证书';color='warning';}
|
||||
else{label='\u2713 有效';color='success';}
|
||||
}else if(acme.enabled=='1'){label='暂无证书';color='muted';}
|
||||
else{label='未启用';color='muted';}
|
||||
card.style.setProperty('--accent',mgColor(color));
|
||||
a1.innerHTML=mgStateHtml(label,color);
|
||||
var h='';
|
||||
for(var j=0;j<certs.length;j++)h+=mgCertLine(certs[j]);
|
||||
if(acme.result=='error'||acme.result=='partial')h+='<div class="mg-'+(acme.result=='error'?'danger':'warn')+'-text">最近一次签发:'+escHtml(acme.message)+'</div>';
|
||||
a2.innerHTML=h;
|
||||
}
|
||||
|
||||
function mgRenderProxy(d){
|
||||
var p1=document.getElementById('mg-p1'),p2=document.getElementById('mg-p2');
|
||||
var card=document.getElementById('mg-proxy-card');
|
||||
var warnings=mgList(d.proxy_warnings)||[];
|
||||
var label,color;
|
||||
if(!d.proxy_running){label='已停止';color='muted';}
|
||||
else if(d.proxy_check=='error'){label='\u26a0 运行中,最近一次配置未生效';color='danger';}
|
||||
else if(warnings.length){label='\u26a0 运行中,有规则未生效';color='warning';}
|
||||
else{label='\u2713 运行中';color='success';}
|
||||
card.style.setProperty('--accent',mgColor(color));
|
||||
p1.innerHTML=mgStateHtml(label,color);
|
||||
var h='';
|
||||
var rules=mgList(d.proxy_rules)||[];
|
||||
for(var i=0;i<rules.length;i++){
|
||||
var r=rules[i];
|
||||
var url=r.scheme+'://'+r.domain+(r.listen_port!='443'&&r.listen_port!='80'?':'+r.listen_port:'');
|
||||
var v6tag=r.ipv6_listen=='1'?' <span class="mg-sub" style="font-size:10px">[IPv6]</span>':'';
|
||||
h+='<div><a class="mg-link" href="'+escHtml(url)+'" target="_blank" rel="noopener noreferrer">'+escHtml(url)+'</a>'+v6tag+' \u2192 '+escHtml(r.target)+'</div>';
|
||||
}
|
||||
if(!rules.length&&d.proxy_running)h='Nginx 运行中,暂无代理规则';
|
||||
if(d.proxy_check=='error'&&d.proxy_message)h+='<div class="mg-danger-text">'+escHtml(d.proxy_message)+'</div>';
|
||||
for(var k=0;k<warnings.length;k++)h+='<div class="mg-warn-text">'+escHtml(warnings[k])+'</div>';
|
||||
p2.innerHTML=h;
|
||||
}
|
||||
|
||||
function mgRenderStatus(d){
|
||||
var notice=document.getElementById('mg-disabled');
|
||||
if(notice)notice.hidden=(d.enabled=='1');
|
||||
mgRenderDdns(d);
|
||||
mgRenderCerts(d);
|
||||
mgRenderProxy(d);
|
||||
}
|
||||
|
||||
// 页面在后台时暂停轮询,切回前台立即刷新
|
||||
@@ -653,15 +695,4 @@ mgAutoCheckUpdate();
|
||||
</script>
|
||||
]] end
|
||||
|
||||
s = m:section(NamedSection, "global", "global", "全局设置")
|
||||
s.anonymous = true
|
||||
o = s:option(Flag, "enabled", "启用 MiniGate")
|
||||
o.description = "总开关。关闭后停止 DDNS 定时任务、ACME 续期和反向代理。保存后立即生效。"
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(Flag, "ipv6_listen", "反向代理监听 IPv6")
|
||||
o.description = "开启后,Nginx 反向代理将同时监听 IPv4 和 IPv6 地址(listen [::]:port)。"
|
||||
o.rmempty = false
|
||||
o.default = "0"
|
||||
|
||||
return m
|
||||
@@ -2,13 +2,10 @@ local m, s, o
|
||||
local sys = require "luci.sys"
|
||||
local fs = require "nixio.fs"
|
||||
|
||||
m = Map("minigate", "登录防护 (Login Guard)",
|
||||
"监控 SSH(dropbear)和 LuCI 网页登录失败,达到阈值自动封禁源 IP。" ..
|
||||
"适用于 SSH 端口被映射到公网、防爆破。LAN 私有 IP 自动豁免。")
|
||||
|
||||
m.on_after_commit = function(self)
|
||||
sys.call("/bin/sh /usr/lib/minigate/after_commit.sh reload >/dev/null 2>&1")
|
||||
end
|
||||
-- 保存并应用后由 procd 按新配置重启登录防护
|
||||
m = Map("minigate", "MiniGate - 登录防护",
|
||||
"监控 SSH(dropbear)和 LuCI 网页登录失败,达到阈值自动封禁源 IP,支持 IPv4 和 IPv6。" ..
|
||||
"适用于 SSH 端口被映射到公网、防爆破。LAN 私有地址自动豁免。")
|
||||
|
||||
-- ============== 实时状态 ==============
|
||||
s = m:section(NamedSection, "login_guard", "login_guard", "实时状态")
|
||||
@@ -106,6 +103,9 @@ o.cfgvalue = function()
|
||||
.lg-btn{height:30px;border:0;border-radius:6px;padding:0 12px;font-size:12px;color:#fff;cursor:pointer}
|
||||
.lg-btn-primary{background:var(--lg-primary)}
|
||||
.lg-btn-danger{background:var(--lg-danger)}
|
||||
.lg-panel-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:12px 16px;border-top:1px solid var(--lg-border-soft)}
|
||||
.lg-foot-note{color:var(--lg-text-muted);font-size:12px}
|
||||
.lg-btn-outline-danger{height:30px;border:1px solid var(--lg-danger);border-radius:6px;padding:0 12px;background:transparent;color:var(--lg-danger);font-size:12px;cursor:pointer}
|
||||
.lg-table-wrap{overflow-x:auto}
|
||||
.lg-table{width:100%;border-collapse:collapse;min-width:720px}
|
||||
.lg-table th{padding:10px 12px;color:var(--lg-text-muted);font-size:11px;font-weight:600;text-align:left;border-bottom:1px solid var(--lg-border);background:var(--lg-surface-muted)}
|
||||
@@ -166,12 +166,15 @@ o.cfgvalue = function()
|
||||
<option value="30">30 条</option>
|
||||
</select>
|
||||
</label>
|
||||
<input id="lg-ban-input" class="lg-input" type="text" placeholder="输入 IP 手动封禁" />
|
||||
<button class="lg-btn lg-btn-primary" onclick="lgManualBan()">封禁</button>
|
||||
<button class="lg-btn lg-btn-danger" onclick="lgFlushAll()">清空全部</button>
|
||||
<input id="lg-ban-input" class="lg-input" type="text" placeholder="输入 IPv4 或 IPv6 手动封禁" />
|
||||
<button type="button" class="lg-btn lg-btn-primary" onclick="lgManualBan()">封禁</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lg-banned-list" class="lg-table-wrap"><div class="lg-empty">加载中...</div></div>
|
||||
<div class="lg-panel-foot">
|
||||
<span class="lg-foot-note">清空后所有已封禁 IP 立即恢复访问,无法撤销。</span>
|
||||
<button type="button" class="lg-btn-outline-danger" onclick="lgFlushAll()">清空全部封禁</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg-panel">
|
||||
@@ -293,7 +296,7 @@ function lgUnban(ip,btn){
|
||||
function lgManualBan(){
|
||||
var ip=document.getElementById('lg-ban-input').value.trim();
|
||||
if(!ip){alert('请输入 IP');return;}
|
||||
if(!ip.match(/^\d+\.\d+\.\d+\.\d+$/)){alert('IP 格式不对');return;}
|
||||
if(!ip.match(/^\d+\.\d+\.\d+\.\d+$/)&&!(ip.match(/^[0-9a-fA-F:]+$/)&&ip.split(':').length>2)){alert('IP 格式不对');return;}
|
||||
lgRequest('post',']] .. bu .. [[',{ip:ip},function(x,d){
|
||||
if(d&&d.success){
|
||||
document.getElementById('lg-ban-input').value='';
|
||||
@@ -470,12 +473,12 @@ o.description = "开启后将自动监控 SSH/LuCI 失败登录。LAN IP 永远
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(Value, "threshold", "失败次数阈值")
|
||||
o.description = "在「失败窗口」时间内累积达到此次数则封禁。"
|
||||
o.datatype = "uinteger"
|
||||
o.description = "在「失败窗口」时间内累积达到此次数则封禁,范围 1-100。"
|
||||
o.datatype = "range(1,100)"
|
||||
o.default = "3"
|
||||
o.placeholder = "3"
|
||||
|
||||
o = s:option(ListValue, "window", "失败窗口(秒)")
|
||||
o = s:option(ListValue, "window", "失败窗口")
|
||||
o.description = "在多长时间内累计失败次数。超过此时间未再失败则计数清零。"
|
||||
o:value("300", "5 分钟")
|
||||
o:value("600", "10 分钟")
|
||||
@@ -494,8 +497,22 @@ o:value("2592000", "30 天")
|
||||
o.default = "43200"
|
||||
|
||||
o = s:option(DynamicList, "whitelist", "白名单(额外)")
|
||||
o.description = "除了 LAN 私有段(192.168.x、10.x、127.x、172.16-31.x)默认豁免外,再加这些 IP/CIDR。" ..
|
||||
"支持单 IP(如 1.2.3.4)或 /8 /16 /24 /32 子网。"
|
||||
o.placeholder = "如 8.8.8.8 或 203.0.113.0/24"
|
||||
o.description = "LAN 私有地址(192.168.x、10.x、172.16-31.x、IPv6 fd/fe80)和 LAN 分配的 IPv6 子网默认豁免," ..
|
||||
"这里再加其他 IP 或子网,支持 IPv4/IPv6 和任意前缀长度。"
|
||||
o.placeholder = "如 8.8.8.8、203.0.113.0/24 或 2001:db8::/48"
|
||||
-- 只接受地址或「地址/前缀长度」,与 login_guard.sh 的匹配方式一致
|
||||
o.validate = function(self, value)
|
||||
local dt = require "luci.cbi.datatypes"
|
||||
local values = type(value) == "table" and value or {value}
|
||||
for _, item in ipairs(values) do
|
||||
item = tostring(item or "")
|
||||
local addr, bits = item:match("^([^/]+)/(%d+)$")
|
||||
addr = addr or item
|
||||
bits = tonumber(bits)
|
||||
local ok = (dt.ip4addr(addr) and (not bits or bits <= 32)) or (dt.ip6addr(addr) and (not bits or bits <= 128))
|
||||
if not ok then return nil, "白名单格式不正确:" .. item end
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -1,23 +1,19 @@
|
||||
local m, s, o
|
||||
local sys = require "luci.sys"
|
||||
local uc = require "luci.model.uci".cursor()
|
||||
|
||||
m = Map("minigate", "MiniGate - 反向代理", "保存后自动生效。")
|
||||
|
||||
m.on_after_commit = function(self)
|
||||
sys.call("/bin/sh /usr/lib/minigate/after_commit.sh proxy >/dev/null 2>&1")
|
||||
end
|
||||
-- 保存并应用后由 procd 的配置触发器重新生成并重载反向代理
|
||||
m = Map("minigate", "MiniGate - 反向代理", "先配置代理规则,页面下方是 HTTP 跳转、IPv6、局域网解析和访问地区等高级设置。保存并应用后自动生效。")
|
||||
|
||||
local ddns_domains = {}
|
||||
local ddns_ok = {}
|
||||
local wildcard_domains = {}
|
||||
local normal_domains = {}
|
||||
local ctl = require "luci.controller.minigate"
|
||||
uc:foreach("minigate", "ddns", function(sec)
|
||||
local d = sec.domain or ""
|
||||
local st = sec.status or ""
|
||||
if d ~= "" then
|
||||
ddns_domains[#ddns_domains + 1] = d
|
||||
if st == "ok" then ddns_ok[d] = true end
|
||||
if (ctl.ddns_state(d).status or "") == "ok" then ddns_ok[d] = true end
|
||||
if d:match("^%*%.") then
|
||||
wildcard_domains[#wildcard_domains + 1] = d
|
||||
else
|
||||
@@ -26,68 +22,15 @@ uc:foreach("minigate", "ddns", function(sec)
|
||||
end
|
||||
end)
|
||||
|
||||
s = m:section(NamedSection, "global", "global", "HTTP 自动跳转",
|
||||
"让每个已启用 HTTPS 的监听端口同时接受 HTTP;明文请求会以 308 跳转到同域名、同端口的 HTTPS。")
|
||||
s.anonymous = true
|
||||
|
||||
o = s:option(Flag, "http_redirect", "启用 HTTP 跳转")
|
||||
o.default = "0"
|
||||
o.rmempty = false
|
||||
|
||||
local lan_ip_auto = uc:get("network", "lan", "ipaddr")
|
||||
if type(lan_ip_auto) == "table" then lan_ip_auto = lan_ip_auto[1] end
|
||||
lan_ip_auto = lan_ip_auto and lan_ip_auto:match("^(%d+%.%d+%.%d+%.%d+)")
|
||||
|
||||
s = m:section(NamedSection, "global", "global", "局域网直连解析",
|
||||
"让局域网设备把本页的代理域名直接解析到本机 LAN 地址:访问不经过公网 IP、主路由回流和上游 DNS 缓存,也不进入代理;公网解析不变。" ..
|
||||
"仅对使用本机 DNS(dnsmasq)的设备生效,且要求公网端口映射没有改端口号。")
|
||||
s.anonymous = true
|
||||
|
||||
o = s:option(Flag, "lan_dns", "启用局域网直连解析")
|
||||
o.default = "0"
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(Value, "lan_ip", "解析到的 LAN 地址", "留空自动使用 LAN 口 IPv4 地址。")
|
||||
o.datatype = "ip4addr"
|
||||
o.placeholder = lan_ip_auto and ("自动(" .. lan_ip_auto .. ")") or "自动"
|
||||
o:depends("lan_dns", "1")
|
||||
|
||||
s = m:section(NamedSection, "global", "global", "公网访问范围",
|
||||
"启用后,仅允许所选地区的公网 IP 访问本插件的所有反代站点(含 HTTP 跳转和 WebSocket);其他地区及归属地未知的 IP 直接断开。" ..
|
||||
"局域网私有地址和回环地址继续可用,不影响 SSH、RustDesk 等其他服务。IPv4/IPv6 均检查,按实际连接来源判断。" ..
|
||||
"使用随版本附带的离线地址库,归属地可能存在误差;外出或使用外省代理出口也会被拒绝。")
|
||||
s.anonymous = true
|
||||
|
||||
o = s:option(Flag, "access_enabled", "限制公网访问地区")
|
||||
o.default = "0"
|
||||
o.rmempty = false
|
||||
|
||||
local access_regions = {}
|
||||
local regions_file = io.open("/usr/lib/minigate/geo/regions.txt", "r")
|
||||
if regions_file then
|
||||
for line in regions_file:lines() do
|
||||
local code, name = line:match("^(%d+) (.+)$")
|
||||
if code then access_regions[#access_regions + 1] = {code, name} end
|
||||
end
|
||||
regions_file:close()
|
||||
s = m:section(SimpleSection); s.template = "minigate/style"
|
||||
if #ddns_domains == 0 then
|
||||
s = m:section(SimpleSection); s.template = "minigate/proxy_empty"
|
||||
end
|
||||
|
||||
o = s:option(DynamicList, "access_regions", "允许的省级地区", "可选择多个地区;只允许四川时,仅保留“四川省”。关闭地区限制即恢复所有地区访问。")
|
||||
o.default = "510000"
|
||||
o.rmempty = false
|
||||
o:depends("access_enabled", "1")
|
||||
local valid_regions = {}
|
||||
for _, region in ipairs(access_regions) do
|
||||
o:value(region[1], region[2])
|
||||
valid_regions[region[1]] = true
|
||||
end
|
||||
o.validate = function(self, value, section)
|
||||
local values = type(value) == "table" and value or {value}
|
||||
if #values == 0 then return nil, "请选择至少一个允许的地区" end
|
||||
for _, code in ipairs(values) do
|
||||
if not valid_regions[code] then return nil, "请从列表中选择有效的省级地区" end
|
||||
end
|
||||
return value
|
||||
-- 子域名前缀:一段字母、数字或连字符
|
||||
local function valid_prefix(value)
|
||||
value = tostring(value or "")
|
||||
return #value >= 1 and #value <= 63 and value:match("^[%w%-]+$") and not value:match("^%-") and not value:match("%-$")
|
||||
end
|
||||
|
||||
-- ================================
|
||||
@@ -96,15 +39,12 @@ end
|
||||
if #wildcard_domains > 0 then
|
||||
|
||||
s = m:section(TypedSection, "proxy_wildcard", "通配符域名",
|
||||
"配置监听端口和HTTPS,具体转发由「子域名」控制。")
|
||||
s.anonymous = false
|
||||
"配置监听端口和HTTPS,具体转发由「子域名」控制。禁用后,它下面的子域名一起停用。")
|
||||
s.anonymous = true
|
||||
s.addremove = true
|
||||
s.template = "cbi/tblsection"
|
||||
s.sectionhead = "名称"
|
||||
|
||||
o = s:option(ListValue, "enabled", "启用")
|
||||
o:value("1", "✓ 启用")
|
||||
o:value("0", "✗ 禁用")
|
||||
o = s:option(Flag, "enabled", "启用")
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
|
||||
@@ -131,12 +71,16 @@ o.rmempty = false
|
||||
-- ================================
|
||||
-- 子域名规则
|
||||
-- ================================
|
||||
s = m:section(TypedSection, "subproxy", "子域名规则")
|
||||
s = m:section(TypedSection, "subproxy", "子域名规则", "所有站点都自动支持 WebSocket。")
|
||||
s.anonymous = true
|
||||
s.addremove = true
|
||||
s.sortable = true
|
||||
s.template = "cbi/tblsection"
|
||||
|
||||
o = s:option(Flag, "enabled", "启用")
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(ListValue, "parent_domain", "主域名")
|
||||
o:value("", "--")
|
||||
for _, d in ipairs(wildcard_domains) do
|
||||
@@ -148,8 +92,13 @@ o.rmempty = false
|
||||
o = s:option(Value, "prefix", "前缀")
|
||||
o.rmempty = false
|
||||
o.placeholder = "app"
|
||||
o.validate = function(self, value)
|
||||
if valid_prefix(value) then return value end
|
||||
return nil, "前缀只能包含字母、数字和连字符,例如 app"
|
||||
end
|
||||
|
||||
o = s:option(Value, "target_addr", "目标地址")
|
||||
o.datatype = "host"
|
||||
o.rmempty = false
|
||||
o.placeholder = "192.168.1.100"
|
||||
|
||||
@@ -164,9 +113,9 @@ o.cfgvalue = function(self, section)
|
||||
local base = m.uci:get("minigate", section, "parent_domain") or ""
|
||||
local prefix = m.uci:get("minigate", section, "prefix") or ""
|
||||
if prefix ~= "" and base ~= "" then
|
||||
return '<strong style="color:#4caf50">' .. prefix .. '.' .. base .. '</strong>'
|
||||
return '<strong class="mgx-ok">' .. (prefix .. "." .. base):gsub("[<>&\"']", "") .. '</strong>'
|
||||
end
|
||||
return '<span style="color:#999">--</span>'
|
||||
return '<span class="mgx-muted">--</span>'
|
||||
end
|
||||
|
||||
end -- wildcard_domains
|
||||
@@ -177,16 +126,13 @@ end -- wildcard_domains
|
||||
if #normal_domains > 0 or #wildcard_domains == 0 then
|
||||
|
||||
s = m:section(TypedSection, "proxy", "代理规则",
|
||||
"非通配符域名的反向代理。")
|
||||
s.anonymous = false
|
||||
"非通配符域名的反向代理。同一端口只能用于一种协议(HTTP 或 HTTPS),冲突的规则不会生效并在总览页提示。所有站点都自动支持 WebSocket。")
|
||||
s.anonymous = true
|
||||
s.addremove = true
|
||||
s.template = "cbi/tblsection"
|
||||
s.sortable = true
|
||||
s.sectionhead = "名称"
|
||||
|
||||
o = s:option(ListValue, "enabled", "启用")
|
||||
o:value("1", "✓ 启用")
|
||||
o:value("0", "✗ 禁用")
|
||||
o = s:option(Flag, "enabled", "启用")
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
|
||||
@@ -205,6 +151,7 @@ o.default = "443"
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(Value, "target_addr", "目标地址")
|
||||
o.datatype = "host"
|
||||
o.rmempty = false
|
||||
o.placeholder = "192.168.1.100"
|
||||
|
||||
@@ -219,12 +166,88 @@ o:value("0", "✗ 关闭")
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(ListValue, "websocket", "WS")
|
||||
o:value("0", "✗ 关闭")
|
||||
o:value("1", "✓ 开启")
|
||||
end -- normal_domains
|
||||
|
||||
s = m:section(NamedSection, "global", "global", "高级设置:HTTP 跳转与 IPv6",
|
||||
"HTTP 跳转让每个已启用 HTTPS 的监听端口同时接受 HTTP,明文请求以 308 跳转到同域名、同端口的 HTTPS。")
|
||||
s.anonymous = true
|
||||
|
||||
o = s:option(Flag, "http_redirect", "启用 HTTP 跳转")
|
||||
o.default = "0"
|
||||
o.rmempty = false
|
||||
|
||||
end -- normal_domains
|
||||
o = s:option(Flag, "ipv6_listen", "监听 IPv6")
|
||||
o.description = "开启后,反向代理同时监听 IPv4 和 IPv6 地址(listen [::]:端口)。"
|
||||
o.default = "0"
|
||||
o.rmempty = false
|
||||
|
||||
local lan_ip_auto = uc:get("network", "lan", "ipaddr")
|
||||
if type(lan_ip_auto) == "table" then lan_ip_auto = lan_ip_auto[1] end
|
||||
lan_ip_auto = lan_ip_auto and lan_ip_auto:match("^(%d+%.%d+%.%d+%.%d+)")
|
||||
|
||||
s = m:section(NamedSection, "global", "global", "高级设置:局域网直连解析",
|
||||
"让局域网设备把本页的代理域名直接解析到本机 LAN 地址:访问不经过公网 IP、主路由回流和上游 DNS 缓存,也不进入代理;公网解析不变。" ..
|
||||
"仅对使用本机 DNS(dnsmasq)的设备生效,且要求公网端口映射没有改端口号。")
|
||||
s.anonymous = true
|
||||
|
||||
o = s:option(Flag, "lan_dns", "启用局域网直连解析")
|
||||
o.default = "0"
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(Value, "lan_ip", "解析到的 LAN 地址", "留空自动使用 LAN 口 IPv4 地址。")
|
||||
o.datatype = "ip4addr"
|
||||
o.placeholder = lan_ip_auto and ("自动(" .. lan_ip_auto .. ")") or "自动"
|
||||
o:depends("lan_dns", "1")
|
||||
|
||||
s = m:section(NamedSection, "global", "global", "高级设置:公网访问范围",
|
||||
"启用后,仅允许所选地区的公网 IP 访问本插件的所有反代站点(含 HTTP 跳转和 WebSocket);其他地区及归属地未知的 IP 直接断开。" ..
|
||||
"局域网私有地址和回环地址继续可用,不影响 SSH、RustDesk 等其他服务。IPv4/IPv6 均检查,按实际连接来源判断。" ..
|
||||
"使用随版本附带的离线地址库,归属地可能存在误差;外出或使用外省代理出口也会被拒绝。")
|
||||
s.anonymous = true
|
||||
|
||||
o = s:option(Flag, "access_enabled", "限制公网访问地区")
|
||||
o.default = "0"
|
||||
o.rmempty = false
|
||||
|
||||
local access_regions = {}
|
||||
local regions_file = io.open("/usr/lib/minigate/geo/regions.txt", "r")
|
||||
if regions_file then
|
||||
for line in regions_file:lines() do
|
||||
local code, name = line:match("^(%d+) (.+)$")
|
||||
if code then access_regions[#access_regions + 1] = {code, name} end
|
||||
end
|
||||
regions_file:close()
|
||||
end
|
||||
|
||||
o = s:option(MultiValue, "access_regions", "允许的省级地区", "勾选允许访问的地区,可多选。关闭地区限制即恢复所有地区访问。")
|
||||
o.widget = "checkbox"
|
||||
o.size = 6
|
||||
o.default = "510000"
|
||||
o.rmempty = false
|
||||
o:depends("access_enabled", "1")
|
||||
-- 旧版本以 list 保存;统一转成以空格分隔的字符串供复选框回显
|
||||
o.cfgvalue = function(self, section)
|
||||
local value = m.uci:get("minigate", section, "access_regions")
|
||||
if type(value) == "table" then return table.concat(value, " ") end
|
||||
return value
|
||||
end
|
||||
local valid_regions = {}
|
||||
for _, region in ipairs(access_regions) do
|
||||
o:value(region[1], region[2])
|
||||
valid_regions[region[1]] = true
|
||||
end
|
||||
o.validate = function(self, value, section)
|
||||
local values = {}
|
||||
if type(value) == "table" then
|
||||
values = value
|
||||
else
|
||||
for code in tostring(value or ""):gmatch("%S+") do values[#values + 1] = code end
|
||||
end
|
||||
if #values == 0 then return nil, "请选择至少一个允许的地区" end
|
||||
for _, code in ipairs(values) do
|
||||
if not valid_regions[code] then return nil, "请从列表中选择有效的省级地区" end
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -1,22 +1,46 @@
|
||||
<%+header%>
|
||||
<%+minigate/style%>
|
||||
<style>
|
||||
.mgx-log-tools{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:12px}
|
||||
.mgx-log{margin:0;max-height:600px;overflow-y:auto;padding:15px;border-radius:8px;border:1px solid var(--mgx-border);background:var(--mgx-log-bg);color:var(--mgx-log-text);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
||||
</style>
|
||||
<h2>MiniGate - 日志</h2>
|
||||
<div style="margin-bottom:15px">
|
||||
<select id="ls" style="padding:5px 10px;border-radius:4px;border:1px solid #ccc">
|
||||
<option value="all">全部</option><option value="ddns">DDNS</option><option value="acme">ACME</option><option value="proxy">代理</option>
|
||||
<div class="mgx-log-tools">
|
||||
<label for="ls">来源</label>
|
||||
<select id="ls" class="mgx-select" style="min-width:140px">
|
||||
<option value="all">全部(按时间合并)</option><option value="ddns">DDNS</option><option value="acme">证书</option><option value="proxy">反向代理</option><option value="login_guard">登录防护</option><option value="update">软件更新</option>
|
||||
</select>
|
||||
<button class="cbi-button cbi-button-action" onclick="rl()" style="margin-left:10px">刷新</button>
|
||||
<label style="margin-left:15px"><input type="checkbox" id="ar" checked> 自动刷新(5秒)</label>
|
||||
<button type="button" class="cbi-button cbi-button-action" onclick="rl(true)">刷新</button>
|
||||
<label><input type="checkbox" id="ar" checked> 自动刷新(5 秒)</label>
|
||||
<span id="lh" class="mgx-small mgx-muted" role="status"></span>
|
||||
</div>
|
||||
<pre id="lc" style="background:#1e1e2e;color:#cdd6f4;font-family:monospace;font-size:12px;line-height:1.5;padding:15px;border-radius:8px;max-height:600px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;margin:0">加载中...</pre>
|
||||
<pre id="lc" class="mgx-log">加载中...</pre>
|
||||
<script type="text/javascript">
|
||||
var lt;
|
||||
var lt,loading=false;
|
||||
var lu='<%=luci.dispatcher.build_url("admin/services/minigate/get_log")%>';
|
||||
function rl(){var s=document.getElementById('ls').value;var c=document.getElementById('lc');
|
||||
XHR.get(lu,{source:s},function(x,d){if(d&&typeof d.log==='string'){c.textContent=d.log||'(暂无)';c.scrollTop=c.scrollHeight}else c.textContent='(无法读取)'})}
|
||||
function sa(){if(lt)clearInterval(lt);if(document.getElementById('ar').checked)lt=setInterval(function(){if(!document.hidden)rl()},5000)}
|
||||
// 只有本来就停在底部时才跟随新日志;往上翻看旧内容时不被拉回
|
||||
function atBottom(c){return c.scrollHeight-c.scrollTop-c.clientHeight<24}
|
||||
function rl(jump){
|
||||
if(loading)return;
|
||||
loading=true;
|
||||
var s=document.getElementById('ls').value,c=document.getElementById('lc'),follow=jump||atBottom(c),done=false;
|
||||
function finish(x,d){
|
||||
if(done)return;done=true;loading=false;
|
||||
if(d&&typeof d.log==='string'){
|
||||
var top=c.scrollTop;
|
||||
c.textContent=d.log||'(暂无)';
|
||||
c.scrollTop=follow?c.scrollHeight:top;
|
||||
document.getElementById('lh').textContent=follow?'':'已暂停跟随最新日志,滚动到底部恢复';
|
||||
}else c.textContent='(无法读取)';
|
||||
}
|
||||
var p=XHR.get(lu,{source:s},finish);
|
||||
if(p&&typeof p.catch==='function')p.catch(function(){finish(null,null)});
|
||||
}
|
||||
function sa(){if(lt)clearInterval(lt);if(document.getElementById('ar').checked)lt=setInterval(function(){if(!document.hidden)rl(false)},5000)}
|
||||
document.getElementById('ar').addEventListener('change',sa);
|
||||
document.addEventListener('visibilitychange',function(){if(!document.hidden&&document.getElementById('ar').checked)rl()});
|
||||
document.getElementById('ls').addEventListener('change',rl);
|
||||
rl();sa();
|
||||
document.getElementById('lc').addEventListener('scroll',function(){if(atBottom(this))document.getElementById('lh').textContent=''});
|
||||
document.addEventListener('visibilitychange',function(){if(!document.hidden&&document.getElementById('ar').checked)rl(false)});
|
||||
document.getElementById('ls').addEventListener('change',function(){rl(true)});
|
||||
rl(true);sa();
|
||||
</script>
|
||||
<%+footer%>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="mgx-note is-info">还没有可用的域名。请先在<a href="<%=luci.dispatcher.build_url("admin", "services", "minigate", "ddns")%>">「动态DNS」</a>添加域名,再回到这里配置反向代理。</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<style>
|
||||
/* MiniGate 普通配置页共用的配色:跟随 LuCI 深浅色主题,不再写死浅色 */
|
||||
:root{--mgx-success:#2f9e44;--mgx-warning:#d9480f;--mgx-danger:#e03131;--mgx-info:#1971c2;--mgx-muted:#6b7280;--mgx-surface:#f6f8fb;--mgx-border:#d7dce3;--mgx-text:#1f2937;--mgx-control:#fff;--mgx-log-bg:#f6f8fb;--mgx-log-text:#1f2937}
|
||||
:root[data-darkmode="true"]{--mgx-success:#78c98d;--mgx-warning:#d7a64a;--mgx-danger:#e58a84;--mgx-info:#7db7df;--mgx-muted:#9aa7b3;--mgx-surface:var(--background-color-high,#131a22);--mgx-border:var(--border-color-medium,rgba(148,163,184,.18));--mgx-text:var(--text-color-high,#d5dbe3);--mgx-control:var(--background-color-high,#111820);--mgx-log-bg:#0f141a;--mgx-log-text:#d5dbe3}
|
||||
@media(prefers-color-scheme:dark){
|
||||
:root:not([data-darkmode]){--mgx-success:#78c98d;--mgx-warning:#d7a64a;--mgx-danger:#e58a84;--mgx-info:#7db7df;--mgx-muted:#9aa7b3;--mgx-surface:#131a22;--mgx-border:rgba(148,163,184,.18);--mgx-text:#d5dbe3;--mgx-control:#111820;--mgx-log-bg:#0f141a;--mgx-log-text:#d5dbe3}
|
||||
}
|
||||
.mgx-ok{color:var(--mgx-success)}
|
||||
.mgx-warn{color:var(--mgx-warning)}
|
||||
.mgx-err{color:var(--mgx-danger)}
|
||||
.mgx-info{color:var(--mgx-info)}
|
||||
.mgx-muted{color:var(--mgx-muted)}
|
||||
.mgx-small{display:block;font-size:12px;line-height:1.7}
|
||||
.mgx-domain{font-weight:700}
|
||||
.mgx-note{margin:8px 0 12px;padding:10px 14px;border-radius:6px;border:1px solid var(--mgx-border);border-left:4px solid var(--mgx-warning);background:var(--mgx-surface);color:var(--mgx-text);font-size:13px;line-height:1.6}
|
||||
.mgx-note.is-ok{border-left-color:var(--mgx-success)}
|
||||
.mgx-note.is-err{border-left-color:var(--mgx-danger)}
|
||||
.mgx-note.is-info{border-left-color:var(--mgx-info)}
|
||||
.mgx-note a{color:var(--mgx-info)}
|
||||
.mgx-select{min-width:220px;max-width:100%;padding:5px 10px;border-radius:4px;border:1px solid var(--mgx-border);background:var(--mgx-control);color:var(--mgx-text)}
|
||||
.mgx-actions{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
|
||||
.mgx-sync{margin-top:4px;font-size:12px!important;padding:2px 10px!important}
|
||||
.mgx-sync-result{margin-left:8px;font-size:12px}
|
||||
@keyframes mgx-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
|
||||
.mgx-spin{display:inline-block;margin-right:8px;animation:mgx-spin 1s linear infinite}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
## 审查问题与界面优化
|
||||
|
||||
这一版集中修复代码审查中发现的问题,并调整各页面界面。界面名称统一为 **MiniGate**;OpenWrt 包名仍为 `minigate`。
|
||||
|
||||
### 修复
|
||||
|
||||
- 通配符主域名停用或删除后,其子域名不再退回成 443 端口的明文 HTTP 站点。之前在同一端口还有 HTTPS 规则时,这还会让 Nginx 因两个默认站点而无法加载。现在子域名随主域名一起停用,也可以单独停用。
|
||||
- 同一端口同时配置了 HTTP 和 HTTPS 规则时,冲突的规则会被跳过,并在总览页说明原因,不再生成 Nginx 无法加载的配置。
|
||||
- 配置检查失败时,自动恢复上一份可用配置,正在运行的站点不受影响。生成过程加了锁,保存设置、证书续期和软件更新同时触发时不会互相覆盖。
|
||||
- 登录防护支持 IPv6 来源;白名单支持 IPv4/IPv6 和任意前缀长度。防火墙没有接受的封禁会如实记录为失败,不再误记为已封禁。
|
||||
- 封禁集合和拦截规则随防火墙每次重载自动加载;重载清空的封禁会在 30 秒内恢复,之前最多要等 5 分钟。
|
||||
- DDNS 只向 Cloudflare 推送公网地址。软路由在上级路由 NAT 之后时,网卡上的内网地址会被拒绝,并提示改用外部 URL。
|
||||
- 日志页「全部」改为按时间合并各来源。之前只是把各文件首尾相接后取最后 200 行,实际几乎看不到 DDNS 和证书日志。
|
||||
|
||||
### 可靠性与安全
|
||||
|
||||
- 服务改由 procd 管理:Nginx 和登录防护异常退出后自动拉起,停止时结束所有子进程。LuCI「保存并应用」后只重载受影响的部分,不再轮询等待配置写入。
|
||||
- DDNS 与证书的运行状态移到 `/var/run/minigate`,定时检查不再反复改写 `/etc/config/minigate`。升级时会清除旧版写在配置文件里的状态字段。
|
||||
- acme.sh 固定为 3.1.6,下载后先校验 SHA-256 再安装。证书剩余超过 30 天时,手动签发不会重复申请,避免触发 Let's Encrypt 频率限制。
|
||||
- 签发证书和手动同步 DDNS 改在后台执行,页面轮询结果,不受网页请求超时影响。
|
||||
- 外部 IP 查询默认改用 HTTPS 地址,失败时依次回退。
|
||||
- 所有站点自动支持 WebSocket。
|
||||
- 访客归属地优先查随包的离线地址库;境外地址才用 HTTPS 接口查询,缓存有数量上限。
|
||||
- 定时任务只管理本插件的脚本,不会再误删用户自己写的、名字里含 minigate 的任务。
|
||||
- 移除旧版地区日志扫描。已启用旧版限制的配置,升级时迁移为反代请求前准入,同时移除旧的全局防火墙集合。
|
||||
|
||||
### 界面
|
||||
|
||||
- 总开关移到总览页顶部,未启用时会显示提示。
|
||||
- DDNS 卡片列出全部记录,按最差的状态着色。
|
||||
- 证书卡片和证书页显示到期日期、剩余天数,并标出测试证书。
|
||||
- 反代地址可以直接点击,配置问题会显示在反代卡片里。
|
||||
- 卸载移到总览底部的「危险操作」区。
|
||||
- 日志页新增登录防护和软件更新来源;往上翻看旧日志时,不再被自动拉回底部。
|
||||
- DDNS 手动同步在原位置更新状态,不再刷新整页,表单里未保存的修改不会丢;有未保存修改时会先提示。
|
||||
- 反代页改为规则在前、高级设置在后,「监听 IPv6」也移到这里;地区改用复选框,启用开关统一为勾选框;没有域名时会引导到动态 DNS 页。
|
||||
- 登录防护页的「清空全部封禁」移到列表底部;手动封禁支持 IPv6。
|
||||
- DDNS、反代、证书和日志页跟随 LuCI 深色主题。
|
||||
- 表单增加校验:域名、Zone ID、API 令牌、检查间隔、失败阈值和白名单。
|
||||
|
||||
## 升级说明
|
||||
|
||||
- 升级后服务改由 procd 管理。LuCI 的「保存并应用」会自动重载服务,不需要再手动重启。
|
||||
- 防火墙下次重载时才会从随包文件加载新的封禁集合;在此之前,登录防护会自行创建同名集合和规则,封禁照常生效。
|
||||
- 旧版写在配置文件里的 DDNS 和证书状态字段会被清除。升级后首次同步前,状态显示为「未同步」。
|
||||
- 子域名所属的通配符主域名未启用时,该子域名不再生效。如果之前依赖这种回退的明文访问,请启用主域名,或改为普通代理规则。
|
||||
|
||||
## 验证
|
||||
|
||||
本地 127 项测试全部通过,无跳过。其中包括 LuaJIT 下的 LuCI 页面加载与表单校验、真实 Nginx 的转发与拦截、子域名跳过、端口冲突、配置回滚、登录防护 IPv6 解析与 procd 停止,以及 DDNS 公网地址校验。
|
||||
|
||||
另外两项检查:
|
||||
|
||||
- 用 `nft -c` 在 OpenWrt 25.12 上校验了封禁 include 文件,校验结果不写入防火墙。
|
||||
- 用 `sh -n` 检查了 12 个 Shell 脚本的语法。
|
||||
|
||||
本次不自动更新路由器。
|
||||
|
||||
## 安装包
|
||||
|
||||
- opkg 安装包:`minigate_2026.10.28_all.ipk`。
|
||||
- 源码安装包:`minigate-v2026.10.28-src.tar.gz`。
|
||||
- 兼容旧更新器:`luci-app-minigate-v2026.10.28-src.tar.gz`。
|
||||
- 使用随附的 `SHA256SUMS` 校验文件完整性。
|
||||
@@ -1,93 +1,124 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
START=99
|
||||
STOP=10
|
||||
LOGIN_GUARD=/usr/lib/minigate/login_guard.sh
|
||||
USE_PROCD=1
|
||||
|
||||
# === 启动登录防护 watcher(独立子进程) ===
|
||||
start_login_guard() {
|
||||
local lg_en=$(uci -q get minigate.login_guard.enabled)
|
||||
[ "$lg_en" = "1" ] || return 0
|
||||
# 已在跑则跳过
|
||||
pgrep -f "^/bin/sh ${LOGIN_GUARD} run" >/dev/null 2>&1 && return 0
|
||||
/bin/sh "$LOGIN_GUARD" run >/dev/null 2>&1 &
|
||||
logger -t minigate "登录防护已启动"
|
||||
PROG_DIR="${MINIGATE_LIB_DIR:-/usr/lib/minigate}"
|
||||
PROXY="$PROG_DIR/proxy.sh"
|
||||
LOGIN_GUARD="$PROG_DIR/login_guard.sh"
|
||||
NGINX_CONF="${MINIGATE_NGINX_CONF:-/etc/minigate/nginx/minigate.conf}"
|
||||
STATE_DIR="${MINIGATE_STATE_DIR:-/var/run/minigate}"
|
||||
CRON_FILE="${MINIGATE_CRON_FILE:-/etc/crontabs/root}"
|
||||
MG_RELOADING=0
|
||||
|
||||
# 只匹配本插件脚本路径,不会删除用户自己写的、名字里恰好含 minigate 的任务
|
||||
update_cron() {
|
||||
local enabled="$1" tmp
|
||||
mkdir -p "$(dirname "$CRON_FILE")"
|
||||
tmp="${CRON_FILE}.minigate.$$"
|
||||
if [ -f "$CRON_FILE" ]; then
|
||||
grep -v '/usr/lib/minigate/' "$CRON_FILE" > "$tmp"
|
||||
else
|
||||
: > "$tmp"
|
||||
fi
|
||||
if [ "$enabled" = "1" ]; then
|
||||
cat >> "$tmp" <<'EOF'
|
||||
*/5 * * * * /bin/sh /usr/lib/minigate/ddns.sh
|
||||
0 3 * * * /bin/sh /usr/lib/minigate/acme.sh renew
|
||||
*/10 * * * * /bin/sh /usr/lib/minigate/proxy.sh trim-logs
|
||||
EOF
|
||||
fi
|
||||
if [ -f "$CRON_FILE" ] && [ "$(cat "$tmp")" = "$(cat "$CRON_FILE")" ]; then
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
cat "$tmp" > "$CRON_FILE"
|
||||
rm -f "$tmp"
|
||||
# crond 是常驻进程,不能继承 procd 的服务锁
|
||||
( close_procd_lock; /etc/init.d/cron restart ) >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# 先结束子进程:logread -f / tail -f 忽略 SIGPIPE,父 shell 退出后不会自行结束
|
||||
stop_process_tree() {
|
||||
local child
|
||||
for child in $(pgrep -P "$1" 2>/dev/null); do
|
||||
stop_process_tree "$child"
|
||||
done
|
||||
kill "$1" >/dev/null 2>&1
|
||||
# 按配置类型计算摘要:保存某一页时只重载受影响的部分
|
||||
config_digest() {
|
||||
uci -q show minigate 2>/dev/null | awk -v types=" $1 " '
|
||||
{
|
||||
key = $0; sub(/=.*/, "", key)
|
||||
n = split(key, part, ".")
|
||||
}
|
||||
n == 2 { value = $0; sub(/^[^=]*=/, "", value); type[part[2]] = value }
|
||||
index(types, " " type[part[2]] " ") { print }
|
||||
' | md5sum | cut -d' ' -f1
|
||||
}
|
||||
|
||||
# 只匹配实际启动形式;调用方命令行里恰好含有这段文本时(BusyBox pgrep 不排除祖先),不能结束调用方
|
||||
stop_login_guard() {
|
||||
local pids pid
|
||||
pids=$(pgrep -f "^/bin/sh ${LOGIN_GUARD} run" 2>/dev/null)
|
||||
for pid in $pids; do
|
||||
stop_process_tree "$pid"
|
||||
done
|
||||
sleep 1
|
||||
pids=$(pgrep -f "^/bin/sh ${LOGIN_GUARD} run" 2>/dev/null)
|
||||
[ -n "$pids" ] && kill -9 $pids >/dev/null 2>&1
|
||||
# 移除 nft 中的 drop 规则(保留 set,让现有封禁继续生效到自然过期)
|
||||
local handle=$(nft -a list chain inet fw4 input 2>/dev/null | grep "minigate-login-guard" | grep -oE 'handle [0-9]+' | awk '{print $2}')
|
||||
[ -n "$handle" ] && nft delete rule inet fw4 input handle "$handle" 2>/dev/null
|
||||
logger -t minigate "登录防护已停止"
|
||||
# procd 在 fd 1000 上持有服务锁;常驻或长时间运行的子进程继承它会挡住下一次服务操作。
|
||||
# 用 eval 关闭,避免只支持个位数描述符的 shell 在解析本文件时出错。
|
||||
close_procd_lock() {
|
||||
[ -e /proc/self/fd/1000 ] && eval 'exec 1000>&-'
|
||||
return 0
|
||||
}
|
||||
|
||||
start() {
|
||||
# === 登录防护可独立启用,与 minigate 主开关无关 ===
|
||||
start_login_guard
|
||||
|
||||
local en=$(uci -q get minigate.global.enabled)
|
||||
[ "$en" = "1" ] || { logger -t minigate "主功能未启用(仅登录防护可能已运行)"; return 0; }
|
||||
logger -t minigate "启动..."
|
||||
/bin/sh /usr/lib/minigate/proxy.sh start
|
||||
/bin/sh /usr/lib/minigate/ddns.sh force &
|
||||
# 设置 cron
|
||||
sed -i '/minigate/d' /etc/crontabs/root 2>/dev/null
|
||||
echo "*/5 * * * * /bin/sh /usr/lib/minigate/ddns.sh" >> /etc/crontabs/root
|
||||
echo "0 3 * * * /bin/sh /usr/lib/minigate/acme.sh renew" >> /etc/crontabs/root
|
||||
echo "*/2 * * * * /bin/sh /usr/lib/minigate/geofence.sh scan" >> /etc/crontabs/root
|
||||
echo "*/10 * * * * /bin/sh /usr/lib/minigate/proxy.sh trim-logs" >> /etc/crontabs/root
|
||||
/etc/init.d/cron restart 2>/dev/null
|
||||
logger -t minigate "已启动"
|
||||
run_background() {
|
||||
( close_procd_lock; "$@" ) </dev/null >/dev/null 2>&1 &
|
||||
}
|
||||
|
||||
stop() {
|
||||
logger -t minigate "停止所有服务..."
|
||||
# 停止反向代理
|
||||
/bin/sh /usr/lib/minigate/proxy.sh stop
|
||||
sleep 1
|
||||
# 移除所有 cron 定时任务(DDNS + ACME)
|
||||
sed -i '/minigate/d' /etc/crontabs/root 2>/dev/null
|
||||
/etc/init.d/cron restart 2>/dev/null
|
||||
# 清除 DDNS 运行状态
|
||||
local sections=$(uci -q show minigate | grep '=ddns$' | cut -d. -f2 | cut -d= -f1)
|
||||
for sec in $sections; do
|
||||
uci -q set minigate.${sec}.status="stopped"
|
||||
uci -q set minigate.${sec}.status_msg="已停止"
|
||||
uci -q set minigate.${sec}.next_sync=""
|
||||
done
|
||||
uci commit minigate
|
||||
# 停止登录防护
|
||||
stop_login_guard
|
||||
logger -t minigate "已停止(DDNS定时/ACME续期/反代/登录防护 全部关闭)"
|
||||
start_service() {
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# 登录防护可独立启用,与 MiniGate 主开关无关;配置摘要变化时 procd 自动重启它
|
||||
if [ "$(uci -q get minigate.login_guard.enabled)" = "1" ]; then
|
||||
procd_open_instance login_guard
|
||||
procd_set_param command /bin/sh "$LOGIN_GUARD" run
|
||||
procd_set_param env MINIGATE_LG_DIGEST="$(config_digest login_guard)"
|
||||
procd_set_param respawn
|
||||
procd_close_instance
|
||||
fi
|
||||
|
||||
if [ "$(uci -q get minigate.global.enabled)" != "1" ]; then
|
||||
update_cron 0
|
||||
/bin/sh "$PROXY" stop
|
||||
/bin/sh "$PROG_DIR/ddns.sh" mark-stopped
|
||||
return 0
|
||||
fi
|
||||
|
||||
[ "${MINIGATE_SKIP_PREPARE:-0}" = "1" ] || /bin/sh "$PROXY" prepare
|
||||
# 生成失败时 proxy.sh 已恢复上一份配置,继续用它运行,不因一次保存错误中断全部站点
|
||||
if [ -s "$NGINX_CONF" ]; then
|
||||
procd_open_instance nginx
|
||||
procd_set_param command /bin/sh "$PROXY" run
|
||||
procd_set_param env MINIGATE_STREAM="$(grep -c '^stream {' "$NGINX_CONF")"
|
||||
procd_set_param respawn
|
||||
procd_close_instance
|
||||
/bin/sh "$PROXY" lan-dns
|
||||
fi
|
||||
|
||||
update_cron 1
|
||||
config_digest "global proxy proxy_wildcard subproxy" > "$STATE_DIR/digest.proxy"
|
||||
config_digest "ddns" > "$STATE_DIR/digest.ddns"
|
||||
[ "$MG_RELOADING" = "1" ] || run_background /bin/sh "$PROG_DIR/ddns.sh" force
|
||||
}
|
||||
|
||||
restart() {
|
||||
stop
|
||||
sleep 1
|
||||
# LuCI「保存并应用」后由 procd 调用:重新声明进程,只重载配置有变化的部分
|
||||
reload_service() {
|
||||
local old_proxy old_ddns
|
||||
old_proxy=$(cat "$STATE_DIR/digest.proxy" 2>/dev/null)
|
||||
old_ddns=$(cat "$STATE_DIR/digest.ddns" 2>/dev/null)
|
||||
|
||||
MG_RELOADING=1
|
||||
start
|
||||
MG_RELOADING=0
|
||||
|
||||
[ "$(uci -q get minigate.global.enabled)" = "1" ] || return 0
|
||||
[ "$old_proxy" = "$(cat "$STATE_DIR/digest.proxy" 2>/dev/null)" ] || /bin/sh "$PROXY" hup
|
||||
[ "$old_ddns" = "$(cat "$STATE_DIR/digest.ddns" 2>/dev/null)" ] || run_background /bin/sh "$PROG_DIR/ddns.sh" force
|
||||
}
|
||||
|
||||
reload() {
|
||||
/bin/sh /usr/lib/minigate/proxy.sh reload
|
||||
/bin/sh /usr/lib/minigate/ddns.sh force
|
||||
# 登录防护配置改了也需要重启 watcher
|
||||
stop_login_guard
|
||||
start_login_guard
|
||||
stop_service() {
|
||||
update_cron 0
|
||||
/bin/sh "$PROXY" stop
|
||||
/bin/sh "$PROG_DIR/ddns.sh" mark-stopped
|
||||
rm -f "$STATE_DIR/digest.proxy" "$STATE_DIR/digest.ddns"
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "minigate"
|
||||
}
|
||||
@@ -4,9 +4,30 @@ ACME_BIN="${ACME_HOME}/acme.sh"
|
||||
CERT_DIR="${MINIGATE_CERT_DIR:-/etc/minigate/certs}"
|
||||
LOGFILE="${MINIGATE_ACME_LOG:-/var/log/minigate-acme.log}"
|
||||
PROXY_SCRIPT="${MINIGATE_PROXY_SCRIPT:-/usr/lib/minigate/proxy.sh}"
|
||||
# 最近一次签发结果只放内存;证书本身的有效期由页面直接读取证书文件
|
||||
STATE_FILE="${MINIGATE_ACME_STATE:-/var/run/minigate/acme.state}"
|
||||
# 固定 acme.sh 版本并校验 GitHub 标签归档的 SHA-256,不再以 root 执行未校验的 master 分支
|
||||
ACME_VERSION="3.1.6"
|
||||
ACME_SHA256="0d3f9000ac44a6331314742a88c475f79134e24fc991997883652adc59efc486"
|
||||
ACME_URL="https://github.com/acmesh-official/acme.sh/archive/refs/tags/${ACME_VERSION}.tar.gz"
|
||||
# 证书剩余天数少于此值才强制重签,避免反复点击触发 Let's Encrypt 频率限制
|
||||
RENEW_DAYS=30
|
||||
|
||||
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [ACME] $*" >> "$LOGFILE"; }
|
||||
|
||||
# write_state 结果 域名 说明
|
||||
write_state() {
|
||||
local tmp
|
||||
mkdir -p "$(dirname "$STATE_FILE")" 2>/dev/null || return 0
|
||||
tmp="${STATE_FILE}.$$"
|
||||
{
|
||||
printf 'result=%s\n' "$1"
|
||||
printf 'domain=%s\n' "$2"
|
||||
printf 'message=%s\n' "$3"
|
||||
printf 'time=%s\n' "$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
} > "$tmp" && mv -f "$tmp" "$STATE_FILE"
|
||||
}
|
||||
|
||||
safe_dir() { echo "$1" | sed 's/\*/_wildcard_/g'; }
|
||||
|
||||
cert_path_for_domain() {
|
||||
@@ -22,23 +43,24 @@ cert_is_valid() {
|
||||
[ -f "$cert" ] && openssl x509 -checkend 0 -noout -in "$cert" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
cert_valid_for_days() {
|
||||
local cert="$1" days="$2"
|
||||
[ -f "$cert" ] && openssl x509 -checkend $((days * 86400)) -noout -in "$cert" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
keep_existing_cert_ok() {
|
||||
local domain="$1" reason="$2"
|
||||
local cert="$(cert_path_for_domain "$domain")"
|
||||
if cert_is_valid "$cert"; then
|
||||
local expiry=$(cert_expiry "$cert")
|
||||
log "失败: $reason;现有证书仍有效: $domain (过期: $expiry)"
|
||||
uci -q set minigate.acme.status="ok"
|
||||
uci -q set minigate.acme.last_domain="$domain"
|
||||
uci -q set minigate.acme.cert_expiry="$expiry"
|
||||
uci -q set minigate.acme.last_error="$reason"
|
||||
uci commit minigate
|
||||
log "失败: $reason;现有证书仍有效: $domain (过期: $(cert_expiry "$cert"))"
|
||||
write_state partial "$domain" "$reason;现有证书仍有效,已继续使用"
|
||||
[ -f "$PROXY_SCRIPT" ] && /bin/sh "$PROXY_SCRIPT" reload 2>/dev/null
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 只使用同一域名或其上级域名所在 DDNS 记录的令牌,不拿其他区域的令牌碰运气
|
||||
find_token_for_domain() {
|
||||
local target="$1"
|
||||
local sections=$(uci -q show minigate | grep '=ddns$' | cut -d. -f2 | cut -d= -f1)
|
||||
@@ -49,43 +71,50 @@ find_token_for_domain() {
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
local parent=$(echo "$target" | sed 's/^[^.]*\.//')
|
||||
local parent=$(echo "$target" | sed 's/^\*\.//; s/^[^.]*\.//')
|
||||
local base=$(echo "$target" | sed 's/^\*\.//')
|
||||
for sec in $sections; do
|
||||
local d=$(uci -q get minigate.${sec}.domain)
|
||||
local db=$(echo "$d" | sed 's/^\*\.//')
|
||||
if [ "$db" = "$parent" ] || [ "$d" = "*.$parent" ]; then
|
||||
if [ "$db" = "$parent" ] || [ "$db" = "$base" ] || [ "$d" = "*.$parent" ]; then
|
||||
echo "$(uci -q get minigate.${sec}.cf_api_token)|$(uci -q get minigate.${sec}.cf_zone_id)"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
for sec in $sections; do
|
||||
local t=$(uci -q get minigate.${sec}.cf_api_token)
|
||||
local z=$(uci -q get minigate.${sec}.cf_zone_id)
|
||||
[ -n "$t" ] && echo "${t}|${z}" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
download_acme() {
|
||||
local output="$1"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 10 --max-time 120 -o "$output" "$ACME_URL" 2>>"$LOGFILE" && return 0
|
||||
fi
|
||||
command -v wget >/dev/null 2>&1 && wget -qO "$output" "$ACME_URL" 2>>"$LOGFILE"
|
||||
}
|
||||
|
||||
do_install() {
|
||||
if [ -x "$ACME_BIN" ] && [ -d "${ACME_HOME}/dnsapi" ]; then
|
||||
log "acme.sh 已安装"; return 0
|
||||
fi
|
||||
log "下载 acme.sh ..."
|
||||
log "下载 acme.sh ${ACME_VERSION} ..."
|
||||
mkdir -p "$ACME_HOME" "$CERT_DIR"
|
||||
cd /tmp; rm -rf acme.sh-master acme-master.tar.gz
|
||||
local ok=0
|
||||
for url in "https://github.com/acmesh-official/acme.sh/archive/master.tar.gz"; do
|
||||
wget -qO /tmp/acme-master.tar.gz "$url" 2>/dev/null && ok=1 && break
|
||||
curl -sLo /tmp/acme-master.tar.gz "$url" 2>/dev/null && ok=1 && break
|
||||
done
|
||||
[ "$ok" != "1" ] && { log "下载失败"; return 1; }
|
||||
tar xzf /tmp/acme-master.tar.gz -C /tmp/ 2>/dev/null
|
||||
cd /tmp/acme.sh-master
|
||||
./acme.sh --install --home "$ACME_HOME" --no-cron --no-profile 2>>"$LOGFILE"
|
||||
cd /tmp; rm -rf /tmp/acme-master.tar.gz /tmp/acme.sh-master
|
||||
local work archive actual
|
||||
work=$(mktemp -d /tmp/minigate-acme.XXXXXX) || { log "无法创建临时目录"; return 1; }
|
||||
archive="${work}/acme.tar.gz"
|
||||
if ! download_acme "$archive"; then
|
||||
log "下载失败"; rm -rf "$work"; return 1
|
||||
fi
|
||||
actual=$(sha256sum "$archive" | awk '{print $1}')
|
||||
if [ "$actual" != "$ACME_SHA256" ]; then
|
||||
log "acme.sh 校验失败(SHA-256 不一致),已拒绝安装"
|
||||
rm -rf "$work"; return 1
|
||||
fi
|
||||
tar xzf "$archive" -C "$work" 2>>"$LOGFILE" || { log "解压失败"; rm -rf "$work"; return 1; }
|
||||
( cd "${work}/acme.sh-${ACME_VERSION}" && ./acme.sh --install --home "$ACME_HOME" --no-cron --no-profile ) >> "$LOGFILE" 2>&1
|
||||
rm -rf "$work"
|
||||
if [ -x "$ACME_BIN" ] && [ -d "${ACME_HOME}/dnsapi" ]; then
|
||||
"$ACME_BIN" --home "$ACME_HOME" --set-default-ca --server letsencrypt 2>>"$LOGFILE"
|
||||
log "安装成功"; uci -q set minigate.acme.status="installed"; uci commit minigate; return 0
|
||||
"$ACME_BIN" --home "$ACME_HOME" --set-default-ca --server letsencrypt >> "$LOGFILE" 2>&1
|
||||
log "安装成功"; return 0
|
||||
else
|
||||
log "安装失败"; return 1
|
||||
fi
|
||||
@@ -101,20 +130,30 @@ do_issue() {
|
||||
local fs=$(uci -q show minigate | grep '=ddns$' | head -1 | cut -d. -f2 | cut -d= -f1)
|
||||
[ -n "$fs" ] && domain=$(uci -q get minigate.${fs}.domain)
|
||||
fi
|
||||
[ -z "$domain" ] && { log "未指定域名"; uci -q set minigate.acme.status="error"; uci commit minigate; return 1; }
|
||||
[ -z "$domain" ] && { log "未指定域名"; write_state error "" "未指定域名"; return 1; }
|
||||
|
||||
local ti=$(find_token_for_domain "$domain")
|
||||
local token=$(echo "$ti" | cut -d'|' -f1)
|
||||
local zone_id=$(echo "$ti" | cut -d'|' -f2)
|
||||
[ -z "$token" ] && { log "无API令牌"; uci -q set minigate.acme.status="error"; uci commit minigate; return 1; }
|
||||
[ -z "$token" ] && { log "无API令牌"; write_state error "$domain" "没有找到该域名对应的 Cloudflare 令牌"; return 1; }
|
||||
|
||||
local safe=$(safe_dir "$domain")
|
||||
local cert="${CERT_DIR}/${safe}/fullchain.pem"
|
||||
mkdir -p "${CERT_DIR}/${safe}"
|
||||
if ! do_install; then
|
||||
keep_existing_cert_ok "$domain" "acme.sh 安装失败" && return 0
|
||||
uci -q set minigate.acme.status="error"; uci commit minigate
|
||||
write_state error "$domain" "acme.sh 安装失败"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local force=""
|
||||
if cert_valid_for_days "$cert" "$RENEW_DAYS" && [ "$staging" != "1" ] && ! cert_is_staging "$cert"; then
|
||||
log "跳过: $domain 证书剩余超过 ${RENEW_DAYS} 天 (过期: $(cert_expiry "$cert"))"
|
||||
write_state ok "$domain" "证书剩余超过 ${RENEW_DAYS} 天,未重复签发"
|
||||
return 0
|
||||
fi
|
||||
[ -f "$cert" ] && force="--force"
|
||||
|
||||
export CF_Token="$token"
|
||||
[ -n "$zone_id" ] && export CF_Zone_ID="$zone_id"
|
||||
|
||||
@@ -124,23 +163,21 @@ do_issue() {
|
||||
[ "$staging" = "1" ] && sf="--staging --server letsencrypt"
|
||||
|
||||
log "签发: $domain (staging=$staging)"
|
||||
uci -q set minigate.acme.status="issuing"; uci commit minigate
|
||||
write_state issuing "$domain" "正在签发"
|
||||
|
||||
"$ACME_BIN" --home "$ACME_HOME" --issue --dns dns_cf $sf -d "$domain" --keylength "$kl" \
|
||||
${email:+--accountemail "$email"} \
|
||||
--cert-file "${CERT_DIR}/${safe}/cert.pem" --key-file "${CERT_DIR}/${safe}/key.pem" \
|
||||
--fullchain-file "${CERT_DIR}/${safe}/fullchain.pem" --force >> "$LOGFILE" 2>&1
|
||||
--fullchain-file "$cert" $force >> "$LOGFILE" 2>&1
|
||||
local ret=$?
|
||||
|
||||
if [ $ret -eq 0 ] && [ -f "${CERT_DIR}/${safe}/fullchain.pem" ]; then
|
||||
local expiry=$(cert_expiry "${CERT_DIR}/${safe}/fullchain.pem")
|
||||
log "成功: $domain (过期: $expiry)"
|
||||
uci -q set minigate.acme.status="ok"
|
||||
uci -q set minigate.acme.last_domain="$domain"
|
||||
uci -q set minigate.acme.last_issue="$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
uci -q set minigate.acme.cert_expiry="$expiry"
|
||||
uci -q delete minigate.acme.last_error
|
||||
uci commit minigate
|
||||
if [ $ret -eq 0 ] && [ -f "$cert" ]; then
|
||||
log "成功: $domain (过期: $(cert_expiry "$cert"))"
|
||||
if [ "$staging" = "1" ]; then
|
||||
write_state ok "$domain" "已签发测试证书(浏览器不信任),正式使用请关闭测试模式后重新签发"
|
||||
else
|
||||
write_state ok "$domain" "签发成功"
|
||||
fi
|
||||
if echo "$domain" | grep -q '^\*\.'; then
|
||||
local base=$(echo "$domain" | sed 's/^\*\.//')
|
||||
[ ! -e "${CERT_DIR}/${base}" ] && ln -sf "${safe}" "${CERT_DIR}/${base}"
|
||||
@@ -149,16 +186,22 @@ do_issue() {
|
||||
return 0
|
||||
else
|
||||
keep_existing_cert_ok "$domain" "签发失败 (exit=$ret)" && return 0
|
||||
log "失败 (exit=$ret)"; uci -q set minigate.acme.status="error"; uci -q set minigate.acme.last_error="签发失败 (exit=$ret)"; uci commit minigate; return 1
|
||||
log "失败 (exit=$ret)"
|
||||
write_state error "$domain" "签发失败 (exit=$ret),请查看 ACME 日志"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
cert_is_staging() {
|
||||
openssl x509 -in "$1" -noout -issuer 2>/dev/null | grep -qi 'staging\|fake'
|
||||
}
|
||||
|
||||
certs_fingerprint() {
|
||||
cat "$CERT_DIR"/*/fullchain.pem 2>/dev/null | md5sum | cut -d' ' -f1
|
||||
}
|
||||
|
||||
do_renew() {
|
||||
local before domain cert
|
||||
local before
|
||||
do_install || return 1
|
||||
before=$(certs_fingerprint)
|
||||
log "续期..."
|
||||
@@ -167,14 +210,12 @@ do_renew() {
|
||||
# acme.sh 只替换证书文件,运行中的 Nginx 必须重载才会使用新证书。
|
||||
[ "$(certs_fingerprint)" = "$before" ] && return 0
|
||||
log "证书已更新,重载反向代理"
|
||||
domain=$(uci -q get minigate.acme.last_domain)
|
||||
cert="$(cert_path_for_domain "$domain")"
|
||||
if [ -n "$domain" ] && [ -f "$cert" ]; then
|
||||
uci -q set minigate.acme.cert_expiry="$(cert_expiry "$cert")"
|
||||
uci commit minigate
|
||||
fi
|
||||
[ -f "$PROXY_SCRIPT" ] && /bin/sh "$PROXY_SCRIPT" reload 2>/dev/null
|
||||
return 0
|
||||
}
|
||||
|
||||
if [ "${MINIGATE_ACME_LIBRARY_ONLY:-0}" = "1" ]; then
|
||||
return 0 2>/dev/null || exit 0
|
||||
fi
|
||||
|
||||
case "${1:-issue}" in install) do_install;; issue) do_issue "$2";; renew) do_renew;; *) echo "用法: $0 {install|issue [域名]|renew}";; esac
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/bin/sh
|
||||
# LuCI 的 on_after_commit 在浏览器提交配置之前执行。这里先记录当前配置摘要,
|
||||
# 后台等配置文件变化(最多 WAIT 秒)后再应用,避免后台脚本读到旧配置。
|
||||
CONFIG="${MINIGATE_CONFIG_FILE:-/etc/config/minigate}"
|
||||
WAIT="${MINIGATE_COMMIT_WAIT:-15}"
|
||||
INIT="${MINIGATE_INIT:-/etc/init.d/minigate}"
|
||||
LIB_DIR="${MINIGATE_LIB_DIR:-/usr/lib/minigate}"
|
||||
|
||||
config_digest() {
|
||||
md5sum "$CONFIG" 2>/dev/null | cut -d' ' -f1
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
restart|stop|reload|ddns|proxy) ;;
|
||||
*)
|
||||
echo "用法: $0 {restart|stop|reload|ddns|proxy}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
before=$(config_digest)
|
||||
(
|
||||
i=0
|
||||
while [ "$i" -lt "$WAIT" ] && [ "$(config_digest)" = "$before" ]; do
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
case "$1" in
|
||||
restart|stop|reload) exec "$INIT" "$1" ;;
|
||||
ddns) exec /bin/sh "$LIB_DIR/ddns.sh" force ;;
|
||||
proxy) exec /bin/sh "$LIB_DIR/proxy.sh" reload ;;
|
||||
esac
|
||||
) </dev/null >/dev/null 2>&1 &
|
||||
@@ -6,6 +6,8 @@ CACHE_DIR="${MINIGATE_CACHE_DIR:-/tmp}"
|
||||
SINGBOX_CONFIG="${MINIGATE_SINGBOX_CONFIG:-/opt/open-box/etc/config.json}"
|
||||
CLASH_API_URL="${MINIGATE_CLASH_API_URL:-http://127.0.0.1:9095}"
|
||||
LOCK_FILE="${MINIGATE_DDNS_LOCK:-/var/lock/minigate-ddns.lock}"
|
||||
# 运行状态只放内存,不写 /etc/config:避免每次检查都提交配置、备份里混入运行数据
|
||||
STATE_DIR="${MINIGATE_DDNS_STATE_DIR:-/var/run/minigate/ddns}"
|
||||
|
||||
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [DDNS] $*" >> "$LOGFILE"; }
|
||||
trim_log() { [ -f "$LOGFILE" ] && [ "$(wc -l < "$LOGFILE")" -gt 300 ] && { tail -n 150 "$LOGFILE" > "${LOGFILE}.tmp"; mv "${LOGFILE}.tmp" "$LOGFILE"; }; }
|
||||
@@ -24,6 +26,71 @@ lock_ddns() {
|
||||
fi
|
||||
}
|
||||
|
||||
# 状态文件与 IP 历史一样按域名命名;LuCI 侧用相同规则读取
|
||||
state_key() {
|
||||
printf '%s' "$1" | tr -c 'A-Za-z0-9_-' '_'
|
||||
}
|
||||
|
||||
state_value() {
|
||||
sed -n "s/^$2=//p" "$1" 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
# write_state 域名 状态 说明 IPv4 IPv6 更新时间 下次检查;IP 为空时保留上次结果
|
||||
write_state() {
|
||||
local key file tmp ip ip6
|
||||
key=$(state_key "$1")
|
||||
[ -n "$key" ] || return 0
|
||||
mkdir -p "$STATE_DIR" 2>/dev/null || return 0
|
||||
file="${STATE_DIR}/${key}.state"
|
||||
tmp="${file}.$$"
|
||||
ip="$4"; ip6="$5"
|
||||
[ -n "$ip" ] || ip=$(state_value "$file" ip)
|
||||
[ -n "$ip6" ] || ip6=$(state_value "$file" ip6)
|
||||
{
|
||||
printf 'status=%s\n' "$2"
|
||||
printf 'message=%s\n' "$3"
|
||||
printf 'ip=%s\n' "$ip"
|
||||
printf 'ip6=%s\n' "$ip6"
|
||||
printf 'updated=%s\n' "$6"
|
||||
printf 'next=%s\n' "$7"
|
||||
} > "$tmp" && mv -f "$tmp" "$file"
|
||||
}
|
||||
|
||||
mark_stopped() {
|
||||
local sec domain file
|
||||
for sec in $(uci -q -X show minigate | grep '=ddns$' | cut -d. -f2 | cut -d= -f1); do
|
||||
domain=$(uci -q get minigate.${sec}.domain)
|
||||
[ -n "$domain" ] || continue
|
||||
file="${STATE_DIR}/$(state_key "$domain").state"
|
||||
write_state "$domain" stopped "已停止" "" "" "$(state_value "$file" updated)" ""
|
||||
done
|
||||
}
|
||||
|
||||
# 只接受公网地址:软路由在上级路由 NAT 之后时,网卡上是内网地址,不能推到 Cloudflare
|
||||
is_public_ipv4() {
|
||||
printf '%s\n' "$1" | awk -F. '
|
||||
NF != 4 { exit 1 }
|
||||
{
|
||||
for (i = 1; i <= 4; i++) if ($i !~ /^[0-9]+$/ || $i + 0 > 255) exit 1
|
||||
a = $1 + 0; b = $2 + 0
|
||||
if (a == 0 || a == 10 || a == 127 || a >= 224) exit 1
|
||||
if (a == 100 && b >= 64 && b <= 127) exit 1
|
||||
if (a == 169 && b == 254) exit 1
|
||||
if (a == 172 && b >= 16 && b <= 31) exit 1
|
||||
if (a == 192 && b == 168) exit 1
|
||||
if (a == 198 && (b == 18 || b == 19)) exit 1
|
||||
}'
|
||||
}
|
||||
|
||||
# 全球单播 2000::/3;排除 ULA、链路本地等
|
||||
is_public_ipv6() {
|
||||
case "$1" in
|
||||
*[!0-9a-fA-F:]*) return 1 ;;
|
||||
[23][0-9a-fA-F]*:*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
history_now() {
|
||||
if [ -n "${MINIGATE_HISTORY_NOW:-}" ]; then
|
||||
printf '%s\n' "$MINIGATE_HISTORY_NOW"
|
||||
@@ -158,49 +225,60 @@ get_ip4_iface() {
|
||||
echo "$ip" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
}
|
||||
|
||||
# 依次请求查询地址,取第一个公网 IPv4;HTTPS 优先,HTTP 仅作兜底
|
||||
IP4_FALLBACK_URLS="https://ddns.oray.com/checkip http://ip.3322.net http://members.3322.org/dyndns/getip http://ns1.dnspod.net:6666"
|
||||
|
||||
get_ip4_url() {
|
||||
local ip=""
|
||||
# 备用 URL 列表(主选 + 自动回退)
|
||||
local fallback_urls="http://ip.3322.net http://members.3322.org/dyndns/getip http://ns1.dnspod.net:6666 http://ip.tool.chinaz.com/getip"
|
||||
local urls="$1 $fallback_urls"
|
||||
|
||||
for url in $urls; do
|
||||
local host=$(echo "$url" | sed -E 's|https?://||;s|/.*||;s|:.*||')
|
||||
local port=$(echo "$url" | grep -oE ':[0-9]+' | head -1 | tr -d ':')
|
||||
local path=$(echo "$url" | sed -E 's|https?://[^/]*||')
|
||||
[ -z "$path" ] && path="/"
|
||||
[ -z "$port" ] && port="80"
|
||||
|
||||
# 方法1: 直连
|
||||
ip=$(curl -4 -s --connect-timeout 3 --max-time 8 "$url" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
[ -n "$ip" ] && { echo "$ip"; return; }
|
||||
|
||||
# 方法2: DNS 解析后用 Host 头直连(绕过代理)
|
||||
local resolved=$(nslookup "$host" 223.5.5.5 2>/dev/null | grep -A1 'Name:' | grep 'Address:' | head -1 | awk '{print $2}')
|
||||
if [ -n "$resolved" ]; then
|
||||
ip=$(curl -4 -s --connect-timeout 3 --max-time 8 -H "Host: ${host}" "http://${resolved}:${port}${path}" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
[ -n "$ip" ] && { echo "$ip"; return; }
|
||||
local ip="" url
|
||||
for url in $1 $IP4_FALLBACK_URLS; do
|
||||
ip=$(fetch_ip4 "$url")
|
||||
if [ -n "$ip" ]; then
|
||||
echo "$ip"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
|
||||
fetch_ip4() {
|
||||
local url="$1" ip candidate host port scheme resolved
|
||||
host=$(echo "$url" | sed -E 's|https?://||;s|/.*||;s|:.*||')
|
||||
port=$(echo "$url" | sed -E 's|https?://[^/:]*||' | grep -oE '^:[0-9]+' | tr -d ':')
|
||||
scheme="http"
|
||||
echo "$url" | grep -q '^https' && scheme="https"
|
||||
[ -z "$port" ] && { [ "$scheme" = "https" ] && port="443" || port="80"; }
|
||||
|
||||
# 方法1: 直连
|
||||
for candidate in $(curl -4 -s --connect-timeout 3 --max-time 8 "$url" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -3); do
|
||||
is_public_ipv4 "$candidate" && { echo "$candidate"; return 0; }
|
||||
done
|
||||
|
||||
# 方法2: 用公共 DNS 解析后直连(绕过透明代理的 fake-ip 解析)
|
||||
resolved=$(nslookup "$host" 223.5.5.5 2>/dev/null | grep -A1 'Name:' | grep 'Address:' | head -1 | awk '{print $2}')
|
||||
is_public_ipv4 "$resolved" || return 0
|
||||
for candidate in $(curl -4 -s --connect-timeout 3 --max-time 8 --resolve "${host}:${port}:${resolved}" "$url" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -3); do
|
||||
is_public_ipv4 "$candidate" && { echo "$candidate"; return 0; }
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# ====== IPv6 获取 ======
|
||||
get_ip6_iface() {
|
||||
local ip="" iface="${1:-wan6}"
|
||||
# 优先取全局 IPv6 地址(排除 link-local fe80::)
|
||||
ip=$(ubus call network.interface.${iface} status 2>/dev/null | jsonfilter -e '@["ipv6-address"][0].address' 2>/dev/null)
|
||||
[ -z "$ip" ] && ip=$(ifstatus "${iface}" 2>/dev/null | jsonfilter -e '@["ipv6-address"][0].address' 2>/dev/null)
|
||||
# 也尝试从 ipv6-prefix-assignment 获取
|
||||
[ -z "$ip" ] && ip=$(ubus call network.interface.${iface} status 2>/dev/null | jsonfilter -e '@["ipv6-prefix-assignment"][0]["local-address"].address' 2>/dev/null)
|
||||
# 验证是有效的 IPv6(排除 link-local)
|
||||
echo "$ip" | grep -v '^fe80' | grep -oE '^[0-9a-fA-F:]+$' | head -1
|
||||
local status ip iface="${1:-wan6}"
|
||||
status=$(ubus call network.interface.${iface} status 2>/dev/null)
|
||||
[ -n "$status" ] || status=$(ifstatus "${iface}" 2>/dev/null)
|
||||
# 取第一个全球单播地址(跳过 ULA、链路本地),没有时再看前缀分配给本机的地址
|
||||
for ip in $(printf '%s' "$status" | jsonfilter -e '@["ipv6-address"][*].address' 2>/dev/null) \
|
||||
$(printf '%s' "$status" | jsonfilter -e '@["ipv6-prefix-assignment"][*]["local-address"].address' 2>/dev/null); do
|
||||
is_public_ipv6 "$ip" && { echo "$ip"; return 0; }
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
|
||||
get_ip6_url() {
|
||||
local url="$1" ip=""
|
||||
local host=$(echo "$url" | sed -E 's|https?://||;s|/.*||;s|:.*||')
|
||||
local port=$(echo "$url" | grep -oE ':[0-9]+' | head -1 | tr -d ':')
|
||||
local port=$(echo "$url" | sed -E 's|https?://[^/:]*||' | grep -oE '^:[0-9]+' | tr -d ':')
|
||||
|
||||
# 先尝试直连
|
||||
ip=$(curl -6 -s --connect-timeout 5 --max-time 10 "$url" 2>/dev/null | grep -oE '([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}' | head -1)
|
||||
@@ -215,7 +293,7 @@ get_ip6_url() {
|
||||
ip=$(curl -6 -s --connect-timeout 5 --max-time 10 --resolve "${host}:${port}:${resolved}" "$url" 2>/dev/null | grep -oE '([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}' | head -1)
|
||||
fi
|
||||
fi
|
||||
echo "$ip"
|
||||
is_public_ipv6 "$ip" && echo "$ip"
|
||||
}
|
||||
|
||||
# Token 经标准输入交给 curl,不出现在进程参数中。
|
||||
@@ -315,7 +393,7 @@ update_one() {
|
||||
|
||||
[ -z "$domain" ] || [ -z "$zone_id" ] || [ -z "$token" ] && {
|
||||
log "$domain: 配置不完整"
|
||||
uci -q set minigate.${sec}.status="error"; uci -q set minigate.${sec}.status_msg="配置不完整"
|
||||
write_state "$domain" error "配置不完整" "" "" "$(date '+%Y-%m-%d %H:%M:%S')" ""
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -333,7 +411,12 @@ update_one() {
|
||||
local ip4=""
|
||||
case "$ip_source" in url) ip4=$(get_ip4_url "$ip_url");; *) ip4=$(get_ip4_iface "$iface");; esac
|
||||
|
||||
if [ -z "$ip4" ]; then
|
||||
if [ -n "$ip4" ] && ! is_public_ipv4 "$ip4"; then
|
||||
log "$domain: 网卡地址 $ip4 不是公网地址(可能在上级路由 NAT 之后),请改用「从外部URL获取」"
|
||||
msgs="${msgs}IPv4 ${ip4} 不是公网地址; "
|
||||
any_fail=1
|
||||
ip4=""
|
||||
elif [ -z "$ip4" ]; then
|
||||
log "$domain: 获取IPv4失败"
|
||||
msgs="${msgs}IPv4获取失败; "
|
||||
any_fail=1
|
||||
@@ -406,18 +489,13 @@ update_one() {
|
||||
fi
|
||||
|
||||
# ====== 更新状态 ======
|
||||
local status="error"
|
||||
if [ "$any_ok" = "1" ] && [ "$any_fail" = "0" ]; then
|
||||
uci -q set minigate.${sec}.status="ok"
|
||||
status="ok"
|
||||
elif [ "$any_ok" = "1" ] && [ "$any_fail" = "1" ]; then
|
||||
uci -q set minigate.${sec}.status="partial"
|
||||
else
|
||||
uci -q set minigate.${sec}.status="error"
|
||||
status="partial"
|
||||
fi
|
||||
uci -q set minigate.${sec}.status_msg="$msgs"
|
||||
[ -n "$result_ip" ] && uci -q set minigate.${sec}.last_ip="$result_ip"
|
||||
[ -n "$result_ip6" ] && uci -q set minigate.${sec}.last_ip6="$result_ip6"
|
||||
uci -q set minigate.${sec}.last_update="$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
uci -q set minigate.${sec}.next_sync="$next_time"
|
||||
write_state "$domain" "$status" "$msgs" "$result_ip" "$result_ip6" "$(date '+%Y-%m-%d %H:%M:%S')" "$next_time"
|
||||
[ "$refresh_dns" = "1" ] && refresh_local_dns_caches "$domain" || true
|
||||
}
|
||||
|
||||
@@ -448,7 +526,6 @@ main() {
|
||||
local c=0
|
||||
for sec in $due; do update_one "$sec" "$force"; c=$((c + 1)); done
|
||||
[ "$c" = "0" ] && log "无 DDNS 记录"
|
||||
uci commit minigate
|
||||
log "--- 同步完成 ($c 条) ---"
|
||||
trim_log
|
||||
}
|
||||
@@ -462,9 +539,13 @@ if [ "$1" = "single" ] && [ -n "$2" ]; then
|
||||
lock_ddns wait
|
||||
log "手动同步: $2"
|
||||
update_one "$2" "1"
|
||||
uci commit minigate
|
||||
trim_log
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = "mark-stopped" ]; then
|
||||
mark_stopped
|
||||
exit 0
|
||||
fi
|
||||
|
||||
main "$@"
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/bin/sh
|
||||
# 用随包的离线地址库查询省级归属地,命中时输出地区名;未命中输出空。
|
||||
GEO_DIR="${MINIGATE_GEO_DIR:-/usr/lib/minigate/geo}"
|
||||
ip="$1"
|
||||
|
||||
case "$ip" in
|
||||
''|*[!0-9a-fA-F:.]*) exit 1 ;;
|
||||
esac
|
||||
|
||||
code=$(awk -v ip="$ip" '
|
||||
function v4(a, p, n, i, v) {
|
||||
n = split(a, p, ".")
|
||||
if (n != 4) return -1
|
||||
v = 0
|
||||
for (i = 1; i <= 4; i++) {
|
||||
if (p[i] !~ /^[0-9]+$/ || p[i] + 0 > 255) return -1
|
||||
v = v * 256 + p[i]
|
||||
}
|
||||
return v
|
||||
}
|
||||
function pad(g) { while (length(g) < 4) g = "0" g; return g }
|
||||
function v6(a, halves, left, right, l, r, i, out, missing) {
|
||||
a = tolower(a)
|
||||
if (a !~ /^[0-9a-f:]+$/) return ""
|
||||
if (split(a, halves, "::") > 2) return ""
|
||||
l = (halves[1] == "") ? 0 : split(halves[1], left, ":")
|
||||
r = (index(a, "::") && halves[2] != "") ? split(halves[2], right, ":") : 0
|
||||
if (!index(a, "::") && l != 8) return ""
|
||||
missing = 8 - l - r
|
||||
if (missing < 0) return ""
|
||||
out = ""
|
||||
for (i = 1; i <= l; i++) { if (length(left[i]) > 4 || left[i] == "") return ""; out = out pad(left[i]) }
|
||||
for (i = 0; i < missing; i++) out = out "0000"
|
||||
for (i = 1; i <= r; i++) { if (length(right[i]) > 4 || right[i] == "") return ""; out = out pad(right[i]) }
|
||||
return out
|
||||
}
|
||||
function hexval(c) { return index("0123456789abcdef", c) - 1 }
|
||||
BEGIN {
|
||||
family = index(ip, ":") ? 6 : 4
|
||||
if (family == 4) { want4 = v4(ip); if (want4 < 0) exit }
|
||||
else { want6 = v6(ip); if (want6 == "") exit }
|
||||
}
|
||||
/^[0-9a-fA-F.:]+\/[0-9]+ 1;$/ {
|
||||
net = $1; sub(/ .*/, "", net)
|
||||
addr = net; sub(/\/.*/, "", addr)
|
||||
bits = net; sub(/^[^\/]*\//, "", bits); bits = bits + 0
|
||||
if (family == 4) {
|
||||
if (index(addr, ":")) next
|
||||
d = 2 ^ (32 - bits)
|
||||
if (int(want4 / d) == int(v4(addr) / d)) { found = FILENAME; exit }
|
||||
} else {
|
||||
if (!index(addr, ":")) next
|
||||
b = v6(addr)
|
||||
full = int(bits / 4)
|
||||
if (substr(want6, 1, full) != substr(b, 1, full)) next
|
||||
rest = bits - full * 4
|
||||
if (rest == 0) { found = FILENAME; exit }
|
||||
d = 2 ^ (4 - rest)
|
||||
if (int(hexval(substr(want6, full + 1, 1)) / d) == int(hexval(substr(b, full + 1, 1)) / d)) { found = FILENAME; exit }
|
||||
}
|
||||
}
|
||||
END {
|
||||
if (found != "") { sub(/^.*\//, "", found); sub(/\.conf$/, "", found); print found }
|
||||
}
|
||||
' "$GEO_DIR"/[0-9]*.conf 2>/dev/null)
|
||||
|
||||
[ -n "$code" ] || exit 0
|
||||
awk -v code="$code" '$1 == code { $1 = ""; sub(/^ /, ""); print; exit }' "$GEO_DIR/regions.txt"
|
||||
@@ -1,198 +0,0 @@
|
||||
#!/bin/sh
|
||||
# MiniGate GeoFence - 地区访问控制
|
||||
# 检查反代访问日志,非允许地区的 IP 用防火墙封禁
|
||||
LOGFILE="${MINIGATE_GEOFENCE_LOG:-/var/log/minigate-geofence.log}"
|
||||
CACHE_DIR="${MINIGATE_GEO_CACHE_DIR:-/tmp/minigate_geo}"
|
||||
BLOCK_SET="minigate_blocked"
|
||||
ACCESS_LOG="${MINIGATE_ACCESS_LOG:-/var/log/minigate-access.log}"
|
||||
|
||||
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [GEOFENCE] $*" >> "$LOGFILE"; }
|
||||
|
||||
# 查询 IP 归属地,返回省份名
|
||||
lookup_geo() {
|
||||
local ip="$1"
|
||||
local cache="$CACHE_DIR/${ip}"
|
||||
|
||||
# 缓存命中
|
||||
[ -f "$cache" ] && cat "$cache" && return 0
|
||||
|
||||
local geo=""
|
||||
|
||||
# API 1: pconline
|
||||
if [ -z "$geo" ]; then
|
||||
local raw=$(curl -s --connect-timeout 3 --max-time 5 "https://whois.pconline.com.cn/ipJson.jsp?ip=${ip}&json=true" 2>/dev/null)
|
||||
geo=$(echo "$raw" | jsonfilter -e '$.pro' 2>/dev/null)
|
||||
fi
|
||||
|
||||
# API 2: ip9.com.cn
|
||||
if [ -z "$geo" ]; then
|
||||
local raw=$(curl -s --connect-timeout 3 --max-time 5 "https://ip9.com.cn/get?ip=${ip}" 2>/dev/null)
|
||||
geo=$(echo "$raw" | jsonfilter -e '$.data.prov' 2>/dev/null)
|
||||
fi
|
||||
|
||||
# API 3: ip-api.com
|
||||
if [ -z "$geo" ]; then
|
||||
local raw=$(curl -s --connect-timeout 3 --max-time 5 "http://ip-api.com/json/${ip}?fields=regionName" 2>/dev/null)
|
||||
geo=$(echo "$raw" | jsonfilter -e '$.regionName' 2>/dev/null)
|
||||
fi
|
||||
|
||||
[ -z "$geo" ] && geo="unknown"
|
||||
|
||||
# 写入缓存
|
||||
mkdir -p "$CACHE_DIR"
|
||||
echo "$geo" > "$cache"
|
||||
echo "$geo"
|
||||
}
|
||||
|
||||
# 检查 IP 是否在允许的地区列表中
|
||||
is_allowed() {
|
||||
local geo="$1"
|
||||
local allowed=$(uci -q get minigate.global.geo_allowed)
|
||||
|
||||
[ -z "$allowed" ] && return 0 # 未配置地区限制,全部放行
|
||||
|
||||
# allowed 是逗号分隔的省份列表,如 "四川,重庆,贵州"
|
||||
echo "$allowed" | tr ',' '\n' | while read prov; do
|
||||
[ -z "$prov" ] && continue
|
||||
echo "$geo" | grep -q "$prov" && echo "yes" && return 0
|
||||
done | grep -q "yes"
|
||||
}
|
||||
|
||||
# 初始化 ipset(nftables 或 iptables)
|
||||
init_block_set() {
|
||||
if command -v nft >/dev/null 2>&1; then
|
||||
# nftables (OpenWrt 22+)
|
||||
nft list set inet fw4 "$BLOCK_SET" >/dev/null 2>&1 || {
|
||||
nft add set inet fw4 "$BLOCK_SET" '{ type ipv4_addr; flags timeout; }'
|
||||
nft add rule inet fw4 input ip saddr @"$BLOCK_SET" drop 2>/dev/null
|
||||
log "nftables 封禁集合已创建"
|
||||
}
|
||||
else
|
||||
# iptables fallback
|
||||
ipset list "$BLOCK_SET" >/dev/null 2>&1 || {
|
||||
ipset create "$BLOCK_SET" hash:ip timeout 43200 2>/dev/null # 12小时超时
|
||||
iptables -I INPUT -m set --match-set "$BLOCK_SET" src -j DROP 2>/dev/null
|
||||
log "ipset 封禁集合已创建"
|
||||
}
|
||||
fi
|
||||
}
|
||||
|
||||
# 封禁 IP
|
||||
block_ip() {
|
||||
local ip="$1" reason="$2"
|
||||
if command -v nft >/dev/null 2>&1; then
|
||||
nft add element inet fw4 "$BLOCK_SET" "{ $ip timeout 12h }" 2>/dev/null
|
||||
else
|
||||
ipset add "$BLOCK_SET" "$ip" timeout 43200 2>/dev/null
|
||||
fi
|
||||
log "封禁: $ip ($reason) 12小时"
|
||||
|
||||
# 记录到 UCI
|
||||
local ts=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
uci -q add_list minigate.global.blocked_ips="${ip}|${reason}|${ts}"
|
||||
uci commit minigate 2>/dev/null
|
||||
}
|
||||
|
||||
# 清理过期缓存(超过24小时的)
|
||||
clean_cache() {
|
||||
[ -d "$CACHE_DIR" ] && find "$CACHE_DIR" -type f -mmin +1440 -delete 2>/dev/null
|
||||
}
|
||||
|
||||
# 局域网、回环、链路本地和运营商 NAT 地址没有归属地,局域网直连时访客就是内网 IP,不参与地区判断
|
||||
is_private_ip() {
|
||||
case "$1" in
|
||||
0.*|10.*|127.*|169.254.*|192.168.*) return 0 ;;
|
||||
172.1[6-9].*|172.2[0-9].*|172.3[01].*) return 0 ;;
|
||||
100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# BusyBox grep 不支持 -P,用 sed 取 IPv4 访客地址
|
||||
extract_public_ips() {
|
||||
local ip
|
||||
sed -n 's/.*"client":"\([0-9]\{1,3\}\(\.[0-9]\{1,3\}\)\{3\}\)".*/\1/p' "$ACCESS_LOG" 2>/dev/null | \
|
||||
sort -u | while read -r ip; do
|
||||
is_private_ip "$ip" || echo "$ip"
|
||||
done
|
||||
}
|
||||
|
||||
# 主逻辑:扫描 access log 中的新 IP
|
||||
scan_and_block() {
|
||||
# 请求前准入已覆盖公网反代,避免旧扫描器再把来源全局封禁到其他服务。
|
||||
[ "$(uci -q get minigate.global.access_enabled)" = "1" ] && return 0
|
||||
local enabled=$(uci -q get minigate.global.geo_enabled)
|
||||
[ "$enabled" != "1" ] && return 0
|
||||
|
||||
local allowed=$(uci -q get minigate.global.geo_allowed)
|
||||
[ -z "$allowed" ] && { log "未配置允许地区,跳过"; return 0; }
|
||||
|
||||
init_block_set
|
||||
|
||||
local ips=$(extract_public_ips)
|
||||
|
||||
local checked=0 blocked=0
|
||||
for ip in $ips; do
|
||||
# 已经封禁的跳过
|
||||
if command -v nft >/dev/null 2>&1; then
|
||||
nft list set inet fw4 "$BLOCK_SET" 2>/dev/null | grep -q "$ip" && continue
|
||||
else
|
||||
ipset test "$BLOCK_SET" "$ip" 2>/dev/null && continue
|
||||
fi
|
||||
|
||||
local geo=$(lookup_geo "$ip")
|
||||
checked=$((checked + 1))
|
||||
|
||||
if [ "$geo" = "unknown" ]; then
|
||||
log "警告: $ip 归属地未知,暂时放行"
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! is_allowed "$geo"; then
|
||||
block_ip "$ip" "$geo"
|
||||
blocked=$((blocked + 1))
|
||||
else
|
||||
log "放行: $ip ($geo)"
|
||||
fi
|
||||
|
||||
# 限速,避免 API 过载
|
||||
sleep 1
|
||||
done
|
||||
|
||||
[ "$checked" -gt 0 ] && log "扫描完成: 检查 $checked 个IP,封禁 $blocked 个"
|
||||
clean_cache
|
||||
}
|
||||
|
||||
# 手动解封
|
||||
unblock_ip() {
|
||||
local ip="$1"
|
||||
if command -v nft >/dev/null 2>&1; then
|
||||
nft delete element inet fw4 "$BLOCK_SET" "{ $ip }" 2>/dev/null
|
||||
else
|
||||
ipset del "$BLOCK_SET" "$ip" 2>/dev/null
|
||||
fi
|
||||
log "解封: $ip"
|
||||
}
|
||||
|
||||
# 清除所有封禁
|
||||
flush_blocks() {
|
||||
if command -v nft >/dev/null 2>&1; then
|
||||
nft flush set inet fw4 "$BLOCK_SET" 2>/dev/null
|
||||
else
|
||||
ipset flush "$BLOCK_SET" 2>/dev/null
|
||||
fi
|
||||
uci -q delete minigate.global.blocked_ips
|
||||
uci commit minigate 2>/dev/null
|
||||
log "已清除所有封禁"
|
||||
}
|
||||
|
||||
if [ "${MINIGATE_GEOFENCE_LIBRARY_ONLY:-0}" = "1" ]; then
|
||||
return 0 2>/dev/null || exit 0
|
||||
fi
|
||||
|
||||
case "${1:-scan}" in
|
||||
scan) scan_and_block ;;
|
||||
unblock) unblock_ip "$2" ;;
|
||||
flush) flush_blocks ;;
|
||||
*) echo "用法: $0 {scan|unblock <ip>|flush}" ;;
|
||||
esac
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# 后台执行耗时操作(签发证书、手动同步 DDNS),LuCI 轮询状态文件,不再卡住网页请求。
|
||||
# 用法: job.sh <任务名> <命令...>
|
||||
JOB_DIR="${MINIGATE_JOB_DIR:-/var/run/minigate/jobs}"
|
||||
|
||||
name="$1"
|
||||
shift
|
||||
case "$name" in ''|*[!A-Za-z0-9_-]*) echo "无效任务名" >&2; exit 2 ;; esac
|
||||
[ "$#" -gt 0 ] || { echo "用法: $0 <任务名> <命令...>" >&2; exit 2; }
|
||||
|
||||
mkdir -p "$JOB_DIR" || exit 1
|
||||
file="${JOB_DIR}/${name}.state"
|
||||
|
||||
write_job() {
|
||||
printf 'status=%s\nstarted=%s\nfinished=%s\ncode=%s\n' "$1" "$2" "$3" "$4" > "${file}.$$" &&
|
||||
mv -f "${file}.$$" "$file"
|
||||
}
|
||||
|
||||
started=$(date +%s)
|
||||
write_job running "$started" "" ""
|
||||
"$@" </dev/null >/dev/null 2>&1
|
||||
code=$?
|
||||
write_job done "$started" "$(date +%s)" "$code"
|
||||
exit "$code"
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/bin/sh
|
||||
# MiniGate · 登录防护(Login Guard)
|
||||
# 监控 SSH (dropbear) 和 LuCI 网页登录失败,达到阈值自动封禁源 IP
|
||||
# 监控 SSH (dropbear) 和 LuCI 网页登录失败,达到阈值自动封禁源 IP(IPv4 / IPv6)
|
||||
#
|
||||
# 用法:
|
||||
# login_guard.sh run 后台监控(init.d 调用)
|
||||
# login_guard.sh run 后台监控(由 procd 启动)
|
||||
# login_guard.sh list 列出当前已封禁的 IP
|
||||
# login_guard.sh status 状态总览
|
||||
# login_guard.sh ban <IP> 手动封禁
|
||||
@@ -16,15 +16,21 @@ ENABLED=$(uci -q get minigate.login_guard.enabled || echo "0")
|
||||
THRESHOLD=$(uci -q get minigate.login_guard.threshold || echo "3")
|
||||
BANTIME=$(uci -q get minigate.login_guard.bantime || echo "43200")
|
||||
WINDOW=$(uci -q get minigate.login_guard.window || echo "600")
|
||||
case "$THRESHOLD" in ''|*[!0-9]*|0) THRESHOLD=3 ;; esac
|
||||
case "$BANTIME" in ''|*[!0-9]*|0) BANTIME=43200 ;; esac
|
||||
case "$WINDOW" in ''|*[!0-9]*|0) WINDOW=600 ;; esac
|
||||
|
||||
DATA_DIR="${MINIGATE_LG_DATA_DIR:-/etc/minigate/login-guard}"
|
||||
RUN_DIR="${MINIGATE_LG_RUN_DIR:-/var/run/minigate/login-guard}"
|
||||
BANS_FILE="$DATA_DIR/bans.txt"
|
||||
COUNTER_DIR="$RUN_DIR/counters"
|
||||
LOG_FILE="${MINIGATE_LG_LOG:-/var/log/minigate-login-guard.log}"
|
||||
ACCESS_LOG=/var/log/minigate-access.log
|
||||
SET="login_banned_v4"
|
||||
ACCESS_LOG="${MINIGATE_ACCESS_LOG:-/var/log/minigate-access.log}"
|
||||
SET4="login_banned_v4"
|
||||
SET6="login_banned_v6"
|
||||
TABLE="inet fw4"
|
||||
WATCHDOG_INTERVAL="${MINIGATE_LG_WATCHDOG:-30}"
|
||||
LAN6_NETS=""
|
||||
|
||||
mkdir -p "$DATA_DIR" "$COUNTER_DIR"
|
||||
touch "$BANS_FILE"
|
||||
@@ -34,62 +40,212 @@ log() {
|
||||
logger -t minigate-lg "$*"
|
||||
}
|
||||
|
||||
# === 白名单:从 uci 读 list whitelist + 默认私有段 ===
|
||||
fmt_duration() {
|
||||
local s=$1
|
||||
if [ "$s" -ge 86400 ] && [ $((s % 86400)) -eq 0 ]; then
|
||||
printf "%d天" $((s / 86400))
|
||||
elif [ "$s" -ge 3600 ]; then
|
||||
if [ $((s % 3600)) -eq 0 ]; then
|
||||
printf "%d小时" $((s / 3600))
|
||||
else
|
||||
printf "%d小时%d分" $((s / 3600)) $(((s % 3600) / 60))
|
||||
fi
|
||||
elif [ "$s" -ge 60 ]; then
|
||||
printf "%d分%d秒" $((s / 60)) $((s % 60))
|
||||
else
|
||||
printf "%d秒" "$s"
|
||||
fi
|
||||
}
|
||||
|
||||
# 输出 4 / 6,无效地址输出空。IPv6 只接受纯十六进制写法,地址会原样进入 nft 命令。
|
||||
ip_family() {
|
||||
case "$1" in
|
||||
*[!0-9.]*) ;;
|
||||
*.*.*.*)
|
||||
printf '%s\n' "$1" | awk -F. 'NF == 4 { for (i = 1; i <= 4; i++) if ($i !~ /^[0-9]+$/ || $i > 255) exit 1; ok = 1 } END { exit !ok }' && echo 4
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
case "$1" in
|
||||
*:*:*)
|
||||
case "$1" in *[!0-9a-fA-F:]*) return 0 ;; esac
|
||||
[ "${#1}" -le 39 ] && echo 6
|
||||
;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
set_for_ip() {
|
||||
case "$(ip_family "$1")" in
|
||||
4) echo "$SET4" ;;
|
||||
6) echo "$SET6" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 判断 IP 是否属于 CIDR(IPv4 任意前缀;IPv6 展开后逐位比较)
|
||||
cidr_match() {
|
||||
awk -v ip="$1" -v net="$2" '
|
||||
function v4(a, p, n, i, v) {
|
||||
n = split(a, p, ".")
|
||||
if (n != 4) return -1
|
||||
v = 0
|
||||
for (i = 1; i <= 4; i++) {
|
||||
if (p[i] !~ /^[0-9]+$/ || p[i] + 0 > 255) return -1
|
||||
v = v * 256 + p[i]
|
||||
}
|
||||
return v
|
||||
}
|
||||
function pad(g) { while (length(g) < 4) g = "0" g; return g }
|
||||
function v6(a, halves, left, right, l, r, i, out, missing, g) {
|
||||
a = tolower(a)
|
||||
if (a !~ /^[0-9a-f:]+$/) return ""
|
||||
if (split(a, halves, "::") > 2) return ""
|
||||
l = (halves[1] == "") ? 0 : split(halves[1], left, ":")
|
||||
r = (index(a, "::") && halves[2] != "") ? split(halves[2], right, ":") : 0
|
||||
if (!index(a, "::") && l != 8) return ""
|
||||
missing = 8 - l - r
|
||||
if (missing < 0) return ""
|
||||
out = ""
|
||||
for (i = 1; i <= l; i++) { if (length(left[i]) > 4 || left[i] == "") return ""; out = out pad(left[i]) }
|
||||
for (i = 0; i < missing; i++) out = out "0000"
|
||||
for (i = 1; i <= r; i++) { if (length(right[i]) > 4 || right[i] == "") return ""; out = out pad(right[i]) }
|
||||
return out
|
||||
}
|
||||
function hexval(c) { return index("0123456789abcdef", c) - 1 }
|
||||
BEGIN {
|
||||
addr = net; bits = -1
|
||||
if (index(net, "/")) { addr = substr(net, 1, index(net, "/") - 1); bits = substr(net, index(net, "/") + 1) }
|
||||
if (bits != -1 && bits !~ /^[0-9]+$/) exit 1
|
||||
bits = bits + 0
|
||||
if (index(ip, ":")) {
|
||||
a = v6(ip); b = v6(addr)
|
||||
if (a == "" || b == "") exit 1
|
||||
if (bits == -1) bits = 128
|
||||
if (bits > 128) exit 1
|
||||
full = int(bits / 4)
|
||||
if (substr(a, 1, full) != substr(b, 1, full)) exit 1
|
||||
rest = bits - full * 4
|
||||
if (rest == 0) exit 0
|
||||
d = 2 ^ (4 - rest)
|
||||
exit !(int(hexval(substr(a, full + 1, 1)) / d) == int(hexval(substr(b, full + 1, 1)) / d))
|
||||
}
|
||||
a = v4(ip); b = v4(addr)
|
||||
if (a < 0 || b < 0) exit 1
|
||||
if (bits == -1) bits = 32
|
||||
if (bits > 32) exit 1
|
||||
d = 2 ^ (32 - bits)
|
||||
exit !(int(a / d) == int(b / d))
|
||||
}'
|
||||
}
|
||||
|
||||
# LAN 接口实际分配到的全球 IPv6 子网也属于内网
|
||||
load_lan6_nets() {
|
||||
local status address mask i=0 nets=""
|
||||
status=$(ubus call network.interface.lan status 2>/dev/null)
|
||||
if [ -n "$status" ]; then
|
||||
while [ "$i" -lt 32 ]; do
|
||||
address=$(printf '%s' "$status" | jsonfilter -e "@[\"ipv6-prefix-assignment\"][${i}].address" 2>/dev/null)
|
||||
mask=$(printf '%s' "$status" | jsonfilter -e "@[\"ipv6-prefix-assignment\"][${i}].mask" 2>/dev/null)
|
||||
[ -n "$address" ] || break
|
||||
i=$((i + 1))
|
||||
case "$mask" in ''|*[!0-9]*) continue ;; esac
|
||||
[ "$mask" -ge 48 ] && [ "$mask" -le 128 ] || continue
|
||||
nets="$nets ${address}/${mask}"
|
||||
done
|
||||
fi
|
||||
LAN6_NETS="$nets"
|
||||
}
|
||||
|
||||
# === 白名单:默认私有段 + LAN 的 IPv6 子网 + uci list whitelist ===
|
||||
is_whitelisted() {
|
||||
local ip="$1"
|
||||
local ip="$1" net
|
||||
case "$ip" in
|
||||
192.168.*|10.*|127.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*) return 0 ;;
|
||||
::1|[fF][cCdD]*:*|[fF][eE][89aAbB]*:*) return 0 ;;
|
||||
esac
|
||||
local wl
|
||||
wl=$(uci -q get minigate.login_guard.whitelist 2>/dev/null)
|
||||
[ -z "$wl" ] && return 1
|
||||
for net in $wl; do
|
||||
case "$net" in
|
||||
"$ip") return 0 ;;
|
||||
*/[0-9]*)
|
||||
# 简单 CIDR 匹配(前缀 byte 比较)
|
||||
local prefix="${net%/*}"
|
||||
local mask="${net#*/}"
|
||||
# 只比 /8 /16 /24 /32 这种整字节边界(够用)
|
||||
case "$mask" in
|
||||
8) [ "${ip%%.*}" = "${prefix%%.*}" ] && return 0 ;;
|
||||
16) [ "${ip%.*.*}" = "${prefix%.*.*}" ] && return 0 ;;
|
||||
24) [ "${ip%.*}" = "${prefix%.*}" ] && return 0 ;;
|
||||
32) [ "$ip" = "$prefix" ] && return 0 ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
for net in $LAN6_NETS $(uci -q get minigate.login_guard.whitelist 2>/dev/null); do
|
||||
cidr_match "$ip" "$net" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# === 确保 nft set + drop rule 存在(fw4 reload 后自动重建) ===
|
||||
# 返回 0 表示刚重建过,调用方需要从持久化文件恢复封禁。
|
||||
nft_set_spec() {
|
||||
case "$1" in
|
||||
"$SET6") echo '{ type ipv6_addr; flags timeout; }' ;;
|
||||
*) echo '{ type ipv4_addr; flags timeout; }' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
nft_rule_match() {
|
||||
case "$1" in
|
||||
"$SET6") echo "ip6 saddr" ;;
|
||||
*) echo "ip saddr" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# === 确保 nft set + drop rule 存在 ===
|
||||
# 规则和集合随包放在 /usr/share/nftables.d,防火墙每次重载都会带上;这里兜底处理
|
||||
# 安装后防火墙尚未重载的情况。返回 0 表示刚重建过,调用方需要从持久化文件恢复封禁。
|
||||
ensure_nft() {
|
||||
local recreated=0
|
||||
if ! nft list set $TABLE $SET >/dev/null 2>&1; then
|
||||
nft "add set $TABLE $SET { type ipv4_addr; flags timeout; timeout ${BANTIME}s; }" 2>/dev/null
|
||||
recreated=1
|
||||
fi
|
||||
if ! nft list chain $TABLE input 2>/dev/null | grep -q "@$SET"; then
|
||||
nft "insert rule $TABLE input ip saddr @$SET counter drop comment \"minigate-login-guard\"" 2>/dev/null
|
||||
recreated=1
|
||||
fi
|
||||
local recreated=0 set chain
|
||||
for set in "$SET4" "$SET6"; do
|
||||
if ! nft list set $TABLE $set >/dev/null 2>&1; then
|
||||
nft "add set $TABLE $set $(nft_set_spec "$set")" 2>/dev/null
|
||||
recreated=1
|
||||
fi
|
||||
done
|
||||
chain=$(nft list chain $TABLE input 2>/dev/null)
|
||||
for set in "$SET4" "$SET6"; do
|
||||
if ! printf '%s\n' "$chain" | grep -q "@$set"; then
|
||||
nft "insert rule $TABLE input $(nft_rule_match "$set") @$set counter drop comment \"minigate-login-guard\"" 2>/dev/null
|
||||
recreated=1
|
||||
fi
|
||||
done
|
||||
[ $recreated -eq 1 ] || return 1
|
||||
log "已创建 nft set + drop 规则"
|
||||
return 0
|
||||
}
|
||||
|
||||
# 防火墙重载后,随包的集合会重新出现但内容为空;持久化文件里仍有未过期封禁时恢复
|
||||
sets_lost_bans() {
|
||||
local now set
|
||||
now=$(date +%s)
|
||||
for set in "$SET4" "$SET6"; do
|
||||
nft list set $TABLE $set 2>/dev/null | grep -q 'elements' && continue
|
||||
while read -r ip exp; do
|
||||
[ -n "$ip" ] || continue
|
||||
case "$exp" in ''|*[!0-9]*) continue ;; esac
|
||||
[ "$exp" -gt "$now" ] || continue
|
||||
[ "$(set_for_ip "$ip")" = "$set" ] && return 0
|
||||
done < "$BANS_FILE"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
add_ban_element() {
|
||||
local ip="$1" seconds="$2" set
|
||||
set=$(set_for_ip "$ip")
|
||||
[ -n "$set" ] || return 1
|
||||
nft add element $TABLE $set "{ $ip timeout ${seconds}s }" 2>/dev/null
|
||||
}
|
||||
|
||||
persist_ban() {
|
||||
local ip="$1" exp="$2"
|
||||
grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true
|
||||
echo "$ip $exp" >> "${BANS_FILE}.tmp"
|
||||
mv "${BANS_FILE}.tmp" "$BANS_FILE"
|
||||
}
|
||||
|
||||
# === 把 IP 加入封禁集合 + 持久化 ===
|
||||
ban_ip() {
|
||||
local ip="$1"
|
||||
local exp=$(($(date +%s) + BANTIME))
|
||||
nft add element $TABLE $SET "{ $ip timeout ${BANTIME}s }" 2>/dev/null
|
||||
# 去重写入持久化文件
|
||||
grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true
|
||||
echo "$ip $exp" >> "${BANS_FILE}.tmp"
|
||||
mv "${BANS_FILE}.tmp" "$BANS_FILE"
|
||||
log "BAN $ip 12小时(达到 $THRESHOLD 次失败)"
|
||||
if ! add_ban_element "$ip" "$BANTIME"; then
|
||||
log "封禁 $ip 失败:防火墙未接受该地址"
|
||||
return 1
|
||||
fi
|
||||
persist_ban "$ip" "$exp"
|
||||
log "BAN $ip $(fmt_duration "$BANTIME")(达到 $THRESHOLD 次失败)"
|
||||
rm -f "$COUNTER_DIR/$ip"
|
||||
}
|
||||
|
||||
@@ -102,9 +258,9 @@ restore_bans() {
|
||||
local count=0
|
||||
while read -r ip exp; do
|
||||
[ -z "$ip" ] && continue
|
||||
if [ "$exp" -gt "$now" ]; then
|
||||
local remaining=$((exp - now))
|
||||
nft add element $TABLE $SET "{ $ip timeout ${remaining}s }" 2>/dev/null
|
||||
case "$exp" in ''|*[!0-9]*) continue ;; esac
|
||||
if [ "$exp" -gt "$now" ] && [ -n "$(set_for_ip "$ip")" ]; then
|
||||
add_ban_element "$ip" $((exp - now))
|
||||
echo "$ip $exp" >> "$tmp"
|
||||
count=$((count + 1))
|
||||
fi
|
||||
@@ -117,6 +273,7 @@ restore_bans() {
|
||||
handle_failure() {
|
||||
local ip="$1"
|
||||
[ -z "$ip" ] && return
|
||||
[ -n "$(ip_family "$ip")" ] || return
|
||||
is_whitelisted "$ip" && return
|
||||
|
||||
# 滑动窗口:超过 window 秒的旧失败丢弃
|
||||
@@ -139,14 +296,24 @@ handle_failure() {
|
||||
|
||||
if [ "$count" -ge "$THRESHOLD" ]; then
|
||||
# 加 ban 前再确认 nft 还在(fw4 可能重载)
|
||||
local ban_target="$ip"
|
||||
repair_nft
|
||||
ban_ip "$ban_target"
|
||||
ban_ip "$ip"
|
||||
fi
|
||||
}
|
||||
|
||||
# 取日志行里最后一个 " from " 后的地址:用户名由访客提供,可能伪造前面的 from。
|
||||
# dropbear 以 "地址:端口" 记录(IPv6 不加方括号),LuCI 只记录地址。
|
||||
extract_ip() {
|
||||
echo "$1" | grep -oE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" | head -1
|
||||
local line="$1" with_port="$2" addr
|
||||
addr=${line##* from }
|
||||
addr=${addr%% *}
|
||||
case "$addr" in
|
||||
\[*\]:*) addr=${addr#\[}; addr=${addr%%\]*} ;;
|
||||
*)
|
||||
[ "$with_port" = "1" ] && case "$addr" in *:[0-9]*) addr=${addr%:*} ;; esac
|
||||
;;
|
||||
esac
|
||||
[ -n "$(ip_family "$addr")" ] && echo "$addr"
|
||||
}
|
||||
|
||||
extract_json_field() {
|
||||
@@ -171,8 +338,24 @@ access_log_loop() {
|
||||
done
|
||||
}
|
||||
|
||||
syslog_loop() {
|
||||
logread -f 2>/dev/null | while read -r line; do
|
||||
case "$line" in
|
||||
*"Bad password attempt"*"from"*|\
|
||||
*"Login attempt for nonexistent user"*"from"*)
|
||||
handle_failure "$(extract_ip "$line" 1)"
|
||||
;;
|
||||
*"luci: failed login"*"from"*)
|
||||
handle_failure "$(extract_ip "$line" 0)"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
repair_nft() {
|
||||
ensure_nft && restore_bans
|
||||
if ensure_nft || sets_lost_bans; then
|
||||
restore_bans
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -183,15 +366,42 @@ trim_log() {
|
||||
tail -n 500 "$LOG_FILE" > "${LOG_FILE}.tmp" && mv -f "${LOG_FILE}.tmp" "$LOG_FILE"
|
||||
}
|
||||
|
||||
# === 后台 watchdog:每 5 分钟检查 nft 资源 ===
|
||||
# === 后台 watchdog:防火墙重载后尽快恢复封禁 ===
|
||||
watchdog_loop() {
|
||||
local tick=0
|
||||
while true; do
|
||||
sleep 300
|
||||
sleep "$WATCHDOG_INTERVAL"
|
||||
repair_nft
|
||||
trim_log
|
||||
tick=$((tick + 1))
|
||||
if [ $((tick % 10)) -eq 0 ]; then
|
||||
load_lan6_nets
|
||||
trim_log
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
stop_process_tree() {
|
||||
local child
|
||||
for child in $(pgrep -P "$1" 2>/dev/null); do
|
||||
stop_process_tree "$child"
|
||||
done
|
||||
kill "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# procd 停止时只向本进程发 TERM;logread -f / tail -f 忽略 SIGPIPE 不会自行退出,需逐个结束。
|
||||
# 停止后不再拦截(与旧版移除规则一致),持久化文件保留,下次启动恢复。
|
||||
stop_watcher() {
|
||||
local child
|
||||
trap - TERM INT
|
||||
for child in $(pgrep -P $$ 2>/dev/null); do
|
||||
stop_process_tree "$child"
|
||||
done
|
||||
nft flush set $TABLE $SET4 2>/dev/null
|
||||
nft flush set $TABLE $SET6 2>/dev/null
|
||||
log "登录防护已停止"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# === 主监控循环 ===
|
||||
run_watcher() {
|
||||
[ "$ENABLED" = "1" ] || {
|
||||
@@ -199,28 +409,19 @@ run_watcher() {
|
||||
exit 0
|
||||
}
|
||||
|
||||
load_lan6_nets
|
||||
ensure_nft
|
||||
restore_bans
|
||||
log "登录防护启动 阈值=$THRESHOLD 窗口=${WINDOW}s 封禁=${BANTIME}s"
|
||||
log "登录防护启动 阈值=$THRESHOLD 窗口=${WINDOW}s 封禁=$(fmt_duration "$BANTIME")"
|
||||
|
||||
# 后台 watchdog
|
||||
trap stop_watcher TERM INT
|
||||
watchdog_loop &
|
||||
|
||||
# MiniGate 反代访问 LuCI 时,LuCI 日志只会看到反代内网 IP。
|
||||
# 这里补充监听 nginx access log,用 JSON client 字段记录真实公网 IP。
|
||||
access_log_loop &
|
||||
|
||||
# 主循环:监控系统日志
|
||||
logread -f 2>/dev/null | while read -r line; do
|
||||
case "$line" in
|
||||
*"Bad password attempt"*"from"*|\
|
||||
*"Login attempt for nonexistent user"*"from"*|\
|
||||
*"luci: failed login"*"from"*)
|
||||
ip=$(extract_ip "$line")
|
||||
handle_failure "$ip"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
syslog_loop &
|
||||
# 在 wait 中才能及时响应 TERM;任一循环全部退出后进程结束,由 procd 重新拉起
|
||||
wait
|
||||
}
|
||||
|
||||
# === CLI 命令 ===
|
||||
@@ -228,37 +429,26 @@ run_watcher() {
|
||||
# 用 nft -j 拿封禁列表,输出每行: <IP> <剩余秒数>
|
||||
# 注意 nft -j 输出可能有空格、字段顺序也不固定(有的 elem 多个 timeout 字段)
|
||||
parse_set_json() {
|
||||
nft -j list set $TABLE $SET 2>/dev/null | awk '
|
||||
{
|
||||
# 在每条 elem 块({...})内匹配 val + expires
|
||||
# [^{}]* 限制不跨越 brace 边界
|
||||
while (match($0, /"val":[ ]*"[0-9.]+"[^{}]*"expires":[ ]*[0-9]+/)) {
|
||||
block = substr($0, RSTART, RLENGTH)
|
||||
$0 = substr($0, RSTART + RLENGTH)
|
||||
# 提取 IP
|
||||
if (match(block, /[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/)) {
|
||||
ip = substr(block, RSTART, RLENGTH)
|
||||
local set
|
||||
for set in "$SET4" "$SET6"; do
|
||||
nft -j list set $TABLE $set 2>/dev/null | awk '
|
||||
{
|
||||
# 在每条 elem 块({...})内匹配 val + expires;[^{}]* 限制不跨越 brace 边界
|
||||
while (match($0, /"val":[ ]*"[0-9a-fA-F:.]+"[^{}]*"expires":[ ]*[0-9]+/)) {
|
||||
block = substr($0, RSTART, RLENGTH)
|
||||
$0 = substr($0, RSTART + RLENGTH)
|
||||
if (match(block, /"val":[ ]*"[0-9a-fA-F:.]+"/)) {
|
||||
ip = substr(block, RSTART, RLENGTH)
|
||||
sub(/^"val":[ ]*"/, "", ip); sub(/"$/, "", ip)
|
||||
}
|
||||
if (match(block, /"expires":[ ]*[0-9]+/)) {
|
||||
exp_str = substr(block, RSTART, RLENGTH)
|
||||
gsub(/[^0-9]/, "", exp_str)
|
||||
print ip, exp_str
|
||||
}
|
||||
}
|
||||
# 提取 expires
|
||||
if (match(block, /"expires":[ ]*[0-9]+/)) {
|
||||
exp_str = substr(block, RSTART, RLENGTH)
|
||||
gsub(/[^0-9]/, "", exp_str)
|
||||
print ip, exp_str
|
||||
}
|
||||
}
|
||||
}'
|
||||
}
|
||||
|
||||
# 把秒数转人类可读
|
||||
fmt_duration() {
|
||||
local s=$1
|
||||
if [ "$s" -ge 3600 ]; then
|
||||
printf "%dh%dm" $((s/3600)) $(((s%3600)/60))
|
||||
elif [ "$s" -ge 60 ]; then
|
||||
printf "%dm%ds" $((s/60)) $((s%60))
|
||||
else
|
||||
printf "%ds" "$s"
|
||||
fi
|
||||
}'
|
||||
done
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
@@ -270,7 +460,7 @@ cmd_list() {
|
||||
return
|
||||
fi
|
||||
echo "$out" | while read -r ip secs; do
|
||||
printf " %-18s 剩余 %s\n" "$ip" "$(fmt_duration "$secs")"
|
||||
printf " %-40s 剩余 %s\n" "$ip" "$(fmt_duration "$secs")"
|
||||
done
|
||||
}
|
||||
|
||||
@@ -293,15 +483,16 @@ cmd_status() {
|
||||
read -r first count < "$f"
|
||||
now=$(date +%s)
|
||||
age=$((now - first))
|
||||
printf " %-18s %d/%d (距首次失败 %ds)\n" "$ip" "$count" "$THRESHOLD" "$age"
|
||||
printf " %-40s %d/%d (距首次失败 %ds)\n" "$ip" "$count" "$THRESHOLD" "$age"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_unban() {
|
||||
local ip="$1"
|
||||
[ -z "$ip" ] && { echo "用法: $0 unban <IP>"; exit 1; }
|
||||
nft delete element $TABLE $SET "{ $ip }" 2>&1
|
||||
local ip="$1" set
|
||||
set=$(set_for_ip "$ip")
|
||||
[ -n "$set" ] || { echo "用法: $0 unban <IP>"; exit 1; }
|
||||
nft delete element $TABLE $set "{ $ip }" 2>&1
|
||||
grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true
|
||||
mv "${BANS_FILE}.tmp" "$BANS_FILE"
|
||||
rm -f "$COUNTER_DIR/$ip"
|
||||
@@ -311,19 +502,20 @@ cmd_unban() {
|
||||
|
||||
cmd_ban() {
|
||||
local ip="$1"
|
||||
[ -z "$ip" ] && { echo "用法: $0 ban <IP>"; exit 1; }
|
||||
[ -n "$(ip_family "$ip")" ] || { echo "用法: $0 ban <IP>"; exit 1; }
|
||||
ensure_nft
|
||||
local exp=$(($(date +%s) + BANTIME))
|
||||
nft add element $TABLE $SET "{ $ip timeout ${BANTIME}s }" 2>&1
|
||||
grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true
|
||||
echo "$ip $exp" >> "${BANS_FILE}.tmp"
|
||||
mv "${BANS_FILE}.tmp" "$BANS_FILE"
|
||||
if ! add_ban_element "$ip" "$BANTIME"; then
|
||||
echo "✗ 封禁 $ip 失败"
|
||||
exit 1
|
||||
fi
|
||||
persist_ban "$ip" $(($(date +%s) + BANTIME))
|
||||
echo "✓ 已封禁 $ip"
|
||||
log "MANUAL ban $ip"
|
||||
}
|
||||
|
||||
cmd_flush() {
|
||||
nft flush set $TABLE $SET 2>&1 || true
|
||||
nft flush set $TABLE $SET4 2>&1 || true
|
||||
nft flush set $TABLE $SET6 2>&1 || true
|
||||
> "$BANS_FILE"
|
||||
rm -rf "$COUNTER_DIR"/*
|
||||
echo "✓ 已清空所有封禁"
|
||||
@@ -356,7 +548,7 @@ case "${1:-help}" in
|
||||
cat <<EOH
|
||||
MiniGate 登录防护
|
||||
|
||||
$0 run 后台监控(init.d 用)
|
||||
$0 run 后台监控(procd 启动)
|
||||
$0 list 列出已封禁 IP
|
||||
$0 status 状态总览
|
||||
$0 ban <IP> 手动封禁
|
||||
|
||||
@@ -15,9 +15,40 @@ ACCESS_LOG_MAX="${MINIGATE_ACCESS_LOG_MAX:-1048576}"
|
||||
LAN_HOSTS_FILE="${MINIGATE_LAN_HOSTS_FILE:-/tmp/hosts/minigate}"
|
||||
GEO_DIR="${MINIGATE_GEO_DIR:-/usr/lib/minigate/geo}"
|
||||
MIME_TYPES="${MINIGATE_MIME_TYPES:-/etc/nginx/mime.types}"
|
||||
LOCK_FILE="${MINIGATE_PROXY_LOCK:-/var/lock/minigate-proxy.lock}"
|
||||
STATUS_FILE="${MINIGATE_PROXY_STATUS:-/var/run/minigate/proxy.status}"
|
||||
INIT_SCRIPT="${MINIGATE_INIT:-/etc/init.d/minigate}"
|
||||
# 每次生成使用独立临时目录记录端口、默认站点和通配符设置,结束后删除
|
||||
GEN_TMP=""
|
||||
|
||||
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [PROXY] $*" >> "$LOGFILE"; }
|
||||
|
||||
# 生成配置时的提示同时写日志,并在总览页显示
|
||||
warn() {
|
||||
log "$*"
|
||||
[ -n "$GEN_TMP" ] && printf '%s\n' "$*" >> "$GEN_TMP/warnings"
|
||||
}
|
||||
|
||||
# LuCI 保存、证书续期和更新可能同时触发生成;串行化,避免互相删除刚生成的文件。
|
||||
lock_proxy() {
|
||||
command -v flock >/dev/null 2>&1 || return 0
|
||||
mkdir -p "$(dirname "$LOCK_FILE")" 2>/dev/null
|
||||
exec 6>"$LOCK_FILE" || return 0
|
||||
flock -x 6
|
||||
}
|
||||
|
||||
write_status() {
|
||||
local result="$1" message="$2" tmp
|
||||
mkdir -p "$(dirname "$STATUS_FILE")" 2>/dev/null || return 0
|
||||
tmp="${STATUS_FILE}.$$"
|
||||
{
|
||||
printf 'result=%s\n' "$result"
|
||||
printf 'time=%s\n' "$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
printf 'message=%s\n' "$message"
|
||||
[ -n "$GEN_TMP" ] && [ -f "$GEN_TMP/warnings" ] && sed 's/^/warning=/' "$GEN_TMP/warnings"
|
||||
} > "$tmp" && mv -f "$tmp" "$STATUS_FILE"
|
||||
}
|
||||
|
||||
generate_main_conf() {
|
||||
local http_redirect=$(get_http_redirect)
|
||||
local use_stream=0
|
||||
@@ -27,7 +58,7 @@ generate_main_conf() {
|
||||
use_stream=1
|
||||
fi
|
||||
if [ "$use_stream" = "1" ] && [ ! -f "$STREAM_MODULE" ]; then
|
||||
log "同端口HTTP跳转需要 nginx-mod-stream"
|
||||
warn "同端口HTTP跳转需要 nginx-mod-stream,请先安装"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -62,6 +93,11 @@ EOF
|
||||
default $proxy_protocol_addr;
|
||||
"" $remote_addr;
|
||||
}
|
||||
# 所有站点都支持 WebSocket;普通请求仍按 nginx 默认关闭上游连接
|
||||
map $http_upgrade $minigate_connection_upgrade {
|
||||
default upgrade;
|
||||
"" close;
|
||||
}
|
||||
log_format minigate_json escape=json
|
||||
'{"time":"$time_iso8601",'
|
||||
'"domain":"$server_name",'
|
||||
@@ -97,9 +133,19 @@ find_cert() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# nginx 1.25 起用独立的 http2 指令;同时兼容以后的主版本号
|
||||
check_h2() {
|
||||
local m=$(nginx -v 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1 | cut -d. -f2)
|
||||
[ "${m:-0}" -ge 25 ] && echo "new" || echo "old"
|
||||
local v major minor
|
||||
v=$(nginx -v 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1)
|
||||
major=${v%%.*}
|
||||
minor=${v#*.}
|
||||
case "$major" in ''|*[!0-9]*) major=0 ;; esac
|
||||
case "$minor" in ''|*[!0-9]*) minor=0 ;; esac
|
||||
if [ "$major" -gt 1 ] || { [ "$major" = "1" ] && [ "$minor" -ge 25 ]; }; then
|
||||
echo "new"
|
||||
else
|
||||
echo "old"
|
||||
fi
|
||||
}
|
||||
|
||||
# 读取全局 IPv6 监听设置
|
||||
@@ -229,7 +275,7 @@ SEOF
|
||||
ensure_default_server() {
|
||||
local lport="$1" ssl="$2" h2s="$3" ipv6_listen="$4" mux="$5"
|
||||
local key=$(echo "${lport}_${ssl}_${mux}" | tr -c 'A-Za-z0-9_' '_')
|
||||
local mark="/tmp/minigate_default_${key}.tmp"
|
||||
local mark="${GEN_TMP}/default_${key}"
|
||||
[ -f "$mark" ] && return 0
|
||||
: > "$mark"
|
||||
write_default_server "${SITES_DIR}/000_default_${key}.conf" "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux"
|
||||
@@ -254,7 +300,7 @@ SEOF
|
||||
|
||||
ensure_stream_server() {
|
||||
local lport="$1" ipv6_listen="$2"
|
||||
local mark="/tmp/minigate_stream_${lport}.tmp"
|
||||
local mark="${GEN_TMP}/stream_${lport}"
|
||||
local conf="${STREAMS_DIR}/stream_${lport}.conf"
|
||||
[ -f "$mark" ] && return 0
|
||||
: > "$mark"
|
||||
@@ -320,11 +366,15 @@ SEOF
|
||||
write_geo_guard "$conf"
|
||||
if [ "$ssl" = "1" ]; then
|
||||
local cp=$(find_cert "$domain")
|
||||
[ -n "$cp" ] && cat >> "$conf" <<SEOF
|
||||
if [ -n "$cp" ]; then
|
||||
cat >> "$conf" <<SEOF
|
||||
ssl_certificate ${cp}/fullchain.pem;
|
||||
ssl_certificate_key ${cp}/key.pem;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
SEOF
|
||||
else
|
||||
warn "${domain} 没有可用证书,HTTPS 访问会失败;请先在「SSL 证书」页签发"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 判断目标地址是否为 IPv6
|
||||
@@ -345,22 +395,77 @@ SEOF
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_buffering off;
|
||||
SEOF
|
||||
[ "$ws" = "1" ] && cat >> "$conf" <<SEOF
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Connection \$minigate_connection_upgrade;
|
||||
}
|
||||
}
|
||||
SEOF
|
||||
echo " }" >> "$conf"; echo "}" >> "$conf"
|
||||
}
|
||||
|
||||
# 同一端口只能用于一种协议:明文 HTTP、HTTPS,或 HTTPS + HTTP 跳转(stream 分流)。
|
||||
# 先占用的规则生效,冲突的规则跳过并提示,避免生成 nginx 无法加载的配置。
|
||||
claim_port() {
|
||||
local lport="$1" mode="$2" domain="$3" file used
|
||||
file="${GEN_TMP}/port_${lport}"
|
||||
if [ -f "$file" ]; then
|
||||
used=$(cat "$file")
|
||||
[ "$used" = "$mode" ] && return 0
|
||||
warn "跳过 ${domain}:端口 ${lport} 已用于$(port_mode_label "$used"),不能同时用于$(port_mode_label "$mode")"
|
||||
return 1
|
||||
fi
|
||||
echo "$mode" > "$file"
|
||||
}
|
||||
|
||||
port_mode_label() {
|
||||
case "$1" in
|
||||
http) echo "HTTP" ;;
|
||||
https) echo "HTTPS" ;;
|
||||
*) echo "HTTPS(含 HTTP 跳转)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 生成一条站点规则;返回非 0 表示因端口冲突被跳过
|
||||
add_site() {
|
||||
local domain="$1" lport="$2" taddr="$3" tport="$4" ssl="$5" h2s="$6" ipv6_listen="$7" http_redirect="$8"
|
||||
local mux=0 mode=http conf
|
||||
if [ "$ssl" = "1" ]; then
|
||||
mode=https
|
||||
if [ "$http_redirect" = "1" ]; then
|
||||
mux=1
|
||||
mode=mux
|
||||
fi
|
||||
fi
|
||||
claim_port "$lport" "$mode" "$domain" || return 1
|
||||
if [ "$mux" = "1" ]; then
|
||||
ensure_stream_server "$lport" "$ipv6_listen"
|
||||
ensure_default_server "$lport" "0" "$h2s" "$ipv6_listen" "1"
|
||||
fi
|
||||
ensure_default_server "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux"
|
||||
SITE_INDEX=$((SITE_INDEX + 1))
|
||||
conf="${SITES_DIR}/site_${SITE_INDEX}.conf"
|
||||
: > "$conf"
|
||||
write_server "$conf" "$domain" "$lport" "$taddr" "$tport" "$ssl" "1" "1" "$h2s" "$ipv6_listen" "$mux"
|
||||
[ "$mux" = "1" ] && write_redirect_server "$conf" "$domain" "$lport"
|
||||
return 0
|
||||
}
|
||||
|
||||
wildcard_key() {
|
||||
printf '%s' "$1" | tr -c 'A-Za-z0-9.-' '_'
|
||||
}
|
||||
|
||||
generate_sites() {
|
||||
local own_tmp=0
|
||||
if [ -z "$GEN_TMP" ]; then
|
||||
GEN_TMP=$(mktemp -d "${TMPDIR:-/tmp}/minigate-gen.XXXXXX") || return 1
|
||||
own_tmp=1
|
||||
fi
|
||||
mkdir -p "$SITES_DIR" "$STREAMS_DIR" "$SOCKET_DIR"
|
||||
rm -f "$SITES_DIR"/*.conf
|
||||
rm -f "$STREAMS_DIR"/*.conf
|
||||
local h2s=$(check_h2)
|
||||
local idx=0
|
||||
local ipv6_listen=$(get_ipv6_listen)
|
||||
local http_redirect=$(get_http_redirect)
|
||||
SITE_INDEX=0
|
||||
|
||||
# === 通配符域名设置 (proxy_wildcard sections) ===
|
||||
local wc_sections=$(uci -q show minigate | grep '=proxy_wildcard$' | cut -d. -f2 | cut -d= -f1)
|
||||
@@ -372,7 +477,7 @@ generate_sites() {
|
||||
local lport=$(uci -q get minigate.${sec}.listen_port); lport=${lport:-443}
|
||||
local ssl=$(uci -q get minigate.${sec}.ssl); ssl=${ssl:-0}
|
||||
local base=$(echo "$domain" | sed 's/^\*\.//')
|
||||
echo "${lport}|${ssl}" > "/tmp/minigate_proxy_${base}.tmp"
|
||||
echo "${lport}|${ssl}" > "${GEN_TMP}/wildcard_$(wildcard_key "$base")"
|
||||
log "通配符: $domain (端口:$lport https:$ssl ipv6:$ipv6_listen)"
|
||||
done
|
||||
|
||||
@@ -386,58 +491,38 @@ generate_sites() {
|
||||
local taddr=$(uci -q get minigate.${sec}.target_addr)
|
||||
local tport=$(uci -q get minigate.${sec}.target_port); tport=${tport:-80}
|
||||
local ssl=$(uci -q get minigate.${sec}.ssl); ssl=${ssl:-1}
|
||||
local h2=$(uci -q get minigate.${sec}.http2); h2=${h2:-1}
|
||||
local ws=$(uci -q get minigate.${sec}.websocket); ws=${ws:-0}
|
||||
[ -z "$domain" ] || [ -z "$taddr" ] && continue
|
||||
local mux=0
|
||||
if [ "$http_redirect" = "1" ] && [ "$ssl" = "1" ]; then
|
||||
mux=1
|
||||
ensure_stream_server "$lport" "$ipv6_listen"
|
||||
ensure_default_server "$lport" "0" "$h2s" "$ipv6_listen" "1"
|
||||
fi
|
||||
ensure_default_server "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux"
|
||||
idx=$((idx + 1))
|
||||
local conf="${SITES_DIR}/site_${idx}.conf"; > "$conf"
|
||||
write_server "$conf" "$domain" "$lport" "$taddr" "$tport" "$ssl" "$h2" "$ws" "$h2s" "$ipv6_listen" "$mux"
|
||||
[ "$mux" = "1" ] && write_redirect_server "$conf" "$domain" "$lport"
|
||||
add_site "$domain" "$lport" "$taddr" "$tport" "$ssl" "$h2s" "$ipv6_listen" "$http_redirect"
|
||||
done
|
||||
|
||||
# === 子域名规则:继承通配符主域名设置 ===
|
||||
# === 子域名规则:继承通配符主域名设置;主域名未启用时整组不生效 ===
|
||||
local subs=$(uci -q show minigate | grep '=subproxy$' | cut -d. -f2 | cut -d= -f1)
|
||||
for sec in $subs; do
|
||||
local enabled=$(uci -q get minigate.${sec}.enabled)
|
||||
[ "${enabled:-1}" = "1" ] || continue
|
||||
local parent=$(uci -q get minigate.${sec}.parent_domain)
|
||||
local prefix=$(uci -q get minigate.${sec}.prefix)
|
||||
local taddr=$(uci -q get minigate.${sec}.target_addr)
|
||||
local tport=$(uci -q get minigate.${sec}.target_port); tport=${tport:-80}
|
||||
local ws=$(uci -q get minigate.${sec}.websocket); ws=${ws:-0}
|
||||
[ -z "$parent" ] || [ -z "$prefix" ] || [ -z "$taddr" ] && continue
|
||||
|
||||
local domain="${prefix}.${parent}"
|
||||
|
||||
local parent_conf="/tmp/minigate_proxy_${parent}.tmp"
|
||||
local lport="443" ssl="0"
|
||||
if [ -f "$parent_conf" ]; then
|
||||
local settings=$(cat "$parent_conf")
|
||||
lport=$(echo "$settings" | cut -d'|' -f1)
|
||||
ssl=$(echo "$settings" | cut -d'|' -f2)
|
||||
local parent_conf="${GEN_TMP}/wildcard_$(wildcard_key "$parent")"
|
||||
if [ ! -f "$parent_conf" ]; then
|
||||
warn "跳过 ${domain}:通配符主域名 *.${parent} 未启用或不存在"
|
||||
continue
|
||||
fi
|
||||
|
||||
idx=$((idx + 1))
|
||||
local mux=0
|
||||
if [ "$http_redirect" = "1" ] && [ "$ssl" = "1" ]; then
|
||||
mux=1
|
||||
ensure_stream_server "$lport" "$ipv6_listen"
|
||||
ensure_default_server "$lport" "0" "$h2s" "$ipv6_listen" "1"
|
||||
fi
|
||||
ensure_default_server "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux"
|
||||
local conf="${SITES_DIR}/site_${idx}.conf"; > "$conf"
|
||||
write_server "$conf" "$domain" "$lport" "$taddr" "$tport" "$ssl" "1" "$ws" "$h2s" "$ipv6_listen" "$mux"
|
||||
[ "$mux" = "1" ] && write_redirect_server "$conf" "$domain" "$lport"
|
||||
local settings=$(cat "$parent_conf")
|
||||
local lport=${settings%%|*}
|
||||
local ssl=${settings#*|}
|
||||
add_site "$domain" "$lport" "$taddr" "$tport" "$ssl" "$h2s" "$ipv6_listen" "$http_redirect"
|
||||
done
|
||||
|
||||
rm -f /tmp/minigate_proxy_*.tmp
|
||||
rm -f /tmp/minigate_default_*.tmp
|
||||
rm -f /tmp/minigate_stream_*.tmp
|
||||
if [ "$own_tmp" = "1" ]; then
|
||||
rm -rf "$GEN_TMP"
|
||||
GEN_TMP=""
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# === 局域网直连解析 ===
|
||||
@@ -523,62 +608,109 @@ ${ip} ${domain}"
|
||||
log "局域网直连解析 -> ${ip}: $(echo $domains)"
|
||||
}
|
||||
|
||||
do_stop() {
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
local pid=$(cat "$PID_FILE" 2>/dev/null)
|
||||
if [ -n "$pid" ]; then
|
||||
kill "$pid" 2>/dev/null
|
||||
local i=0
|
||||
while [ $i -lt 10 ] && kill -0 "$pid" 2>/dev/null; do
|
||||
sleep 1; i=$((i + 1))
|
||||
done
|
||||
kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
rm -f "$SOCKET_DIR"/*.sock
|
||||
log "已停止"
|
||||
# 保存当前配置,生成或检查失败时恢复,磁盘上始终保留最后一份可用配置
|
||||
snapshot_config() {
|
||||
local dir="$1"
|
||||
mkdir -p "$dir/sites" "$dir/streams" || return 1
|
||||
cp -p "$SITES_DIR"/*.conf "$dir/sites/" 2>/dev/null
|
||||
cp -p "$STREAMS_DIR"/*.conf "$dir/streams/" 2>/dev/null
|
||||
[ -f "$NGINX_CONF" ] && cp -p "$NGINX_CONF" "$dir/main.conf"
|
||||
return 0
|
||||
}
|
||||
|
||||
restore_config() {
|
||||
local dir="$1"
|
||||
rm -f "$SITES_DIR"/*.conf "$STREAMS_DIR"/*.conf
|
||||
cp -p "$dir/sites/"*.conf "$SITES_DIR/" 2>/dev/null
|
||||
cp -p "$dir/streams/"*.conf "$STREAMS_DIR/" 2>/dev/null
|
||||
if [ -f "$dir/main.conf" ]; then
|
||||
cp -p "$dir/main.conf" "$NGINX_CONF"
|
||||
else
|
||||
rm -f "$NGINX_CONF"
|
||||
fi
|
||||
}
|
||||
|
||||
# 生成并检查配置;失败时恢复上一份配置,不影响正在运行的 nginx
|
||||
prepare_config() {
|
||||
generate_sites || return 1
|
||||
generate_main_conf || return 1
|
||||
nginx -t -c "$NGINX_CONF" >> "$LOGFILE" 2>&1 || { log "配置错误"; return 1; }
|
||||
local backup output rc=0 message=""
|
||||
GEN_TMP=$(mktemp -d "${TMPDIR:-/tmp}/minigate-gen.XXXXXX") || { log "无法创建临时目录"; return 1; }
|
||||
backup="${GEN_TMP}/backup"
|
||||
output="${GEN_TMP}/nginx-test.log"
|
||||
snapshot_config "$backup"
|
||||
|
||||
if ! generate_sites || ! generate_main_conf; then
|
||||
rc=1
|
||||
message="生成配置失败,已保留上一份配置"
|
||||
elif ! nginx -t -c "$NGINX_CONF" > "$output" 2>&1; then
|
||||
rc=1
|
||||
cat "$output" >> "$LOGFILE"
|
||||
message="配置检查失败,已保留上一份配置:$(grep -m1 -E 'emerg|error' "$output" | sed 's/^nginx: //')"
|
||||
log "配置错误,已恢复上一份配置"
|
||||
fi
|
||||
|
||||
if [ "$rc" = "0" ]; then
|
||||
write_status ok "配置检查通过"
|
||||
else
|
||||
restore_config "$backup"
|
||||
write_status error "$message"
|
||||
fi
|
||||
rm -rf "$GEN_TMP"
|
||||
GEN_TMP=""
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
start_prepared() {
|
||||
do_stop 2>/dev/null
|
||||
sleep 1
|
||||
nginx_pid() {
|
||||
local pid
|
||||
pid=$(cat "$PID_FILE" 2>/dev/null)
|
||||
case "$pid" in ''|*[!0-9]*) return 1 ;; esac
|
||||
kill -0 "$pid" 2>/dev/null || return 1
|
||||
echo "$pid"
|
||||
}
|
||||
|
||||
# 由 procd 启动(前台运行,崩溃后自动拉起)。启动前清掉上次异常退出留下的 socket。
|
||||
do_run() {
|
||||
mkdir -p "$SOCKET_DIR"
|
||||
rm -f "$SOCKET_DIR"/*.sock
|
||||
nginx -c "$NGINX_CONF" >> "$LOGFILE" 2>&1 && log "已启动" || { log "启动失败"; return 1; }
|
||||
exec nginx -c "$NGINX_CONF" -g 'daemon off;'
|
||||
}
|
||||
|
||||
do_start() {
|
||||
[ -z "$(which nginx 2>/dev/null)" ] && { log "nginx未安装"; return 1; }
|
||||
prepare_config || return 1
|
||||
start_prepared || return 1
|
||||
sync_lan_hosts
|
||||
# 让运行中的 nginx 加载新配置。stream 模块增减需要重启进程,交给 procd 处理。
|
||||
apply_config() {
|
||||
local pid old_stream="$1" new_stream=0
|
||||
grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && new_stream=1
|
||||
pid=$(nginx_pid)
|
||||
if [ -n "$pid" ] && [ "$old_stream" = "$new_stream" ]; then
|
||||
kill -HUP "$pid" || return 1
|
||||
log "已重载"
|
||||
else
|
||||
MINIGATE_SKIP_PREPARE=1 "$INIT_SCRIPT" start 6>&- >/dev/null 2>&1 || return 1
|
||||
log "已交由 procd 启动"
|
||||
fi
|
||||
}
|
||||
|
||||
do_reload() {
|
||||
local old_mux=0 new_mux=0
|
||||
grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && old_mux=1
|
||||
local old_stream=0
|
||||
[ "$(uci -q get minigate.global.enabled)" = "1" ] || return 0
|
||||
lock_proxy
|
||||
grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && old_stream=1
|
||||
prepare_config || return 1
|
||||
grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && new_mux=1
|
||||
if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE" 2>/dev/null) 2>/dev/null; then
|
||||
if [ "$old_mux" != "$new_mux" ]; then
|
||||
start_prepared || return 1
|
||||
else
|
||||
kill -HUP $(cat "$PID_FILE") || return 1
|
||||
log "已重载"
|
||||
fi
|
||||
else
|
||||
start_prepared || return 1
|
||||
fi
|
||||
apply_config "$old_stream" || return 1
|
||||
sync_lan_hosts
|
||||
}
|
||||
|
||||
# 仅向已加载新配置的 nginx 发送重载信号(init 已完成生成和检查)
|
||||
do_hup() {
|
||||
local pid
|
||||
pid=$(nginx_pid) || return 0
|
||||
kill -HUP "$pid" && log "已重载"
|
||||
}
|
||||
|
||||
do_stop() {
|
||||
remove_lan_hosts
|
||||
rm -f "$SOCKET_DIR"/*.sock 2>/dev/null
|
||||
write_status stopped "反向代理已停止"
|
||||
}
|
||||
|
||||
# /var/log 在内存中且 nginx 不会自行轮转。访问日志保留最近记录到 .1 后原地清空:
|
||||
# nginx 以追加方式继续写入,登录防护的 tail -f 从头读取新内容,不会重放旧记录。
|
||||
trim_logs() {
|
||||
@@ -599,11 +731,12 @@ if [ "${MINIGATE_PROXY_LIBRARY_ONLY:-0}" = "1" ]; then
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
start) do_start ;;
|
||||
stop) do_stop; remove_lan_hosts ;;
|
||||
prepare|test) lock_proxy; prepare_config ;;
|
||||
run) do_run ;;
|
||||
reload) do_reload ;;
|
||||
test) prepare_config ;;
|
||||
hup) do_hup ;;
|
||||
stop) do_stop ;;
|
||||
trim-logs) trim_logs ;;
|
||||
lan-dns) sync_lan_hosts ;;
|
||||
*) echo "用法: $0 {start|stop|reload|test|trim-logs|lan-dns}" ;;
|
||||
*) echo "用法: $0 {prepare|run|reload|hup|stop|trim-logs|lan-dns}" ;;
|
||||
esac
|
||||
@@ -158,6 +158,7 @@ remove_firewall_state() {
|
||||
[ -z "$ROOT" ] || return 0
|
||||
|
||||
remove_nft_rule_for_set login_banned_v4
|
||||
remove_nft_rule_for_set login_banned_v6
|
||||
remove_nft_rule_for_set minigate_blocked
|
||||
if command -v iptables >/dev/null 2>&1; then
|
||||
while iptables -D INPUT -m set --match-set minigate_blocked src -j DROP >/dev/null 2>&1; do :; done
|
||||
@@ -170,7 +171,8 @@ remove_cron() {
|
||||
cron_file="$(root_path /etc/crontabs/root)"
|
||||
if [ -f "$cron_file" ]; then
|
||||
cron_tmp="${cron_file}.minigate.$$"
|
||||
if sed '/minigate/d' "$cron_file" > "$cron_tmp"; then
|
||||
# 只删除本插件脚本的任务,保留用户自己写的其他任务
|
||||
if sed '\#/usr/lib/minigate/#d' "$cron_file" > "$cron_tmp"; then
|
||||
cat "$cron_tmp" > "$cron_file"
|
||||
fi
|
||||
rm -f "$cron_tmp"
|
||||
@@ -205,7 +207,9 @@ remove_program_files() {
|
||||
rm -f \
|
||||
"$(root_path /etc/init.d/minigate)" \
|
||||
"$(root_path /usr/lib/lua/luci/controller/minigate.lua)" \
|
||||
"$(root_path /usr/lib/lua/luci/i18n/minigate.zh-cn.lmo)"
|
||||
"$(root_path /usr/lib/lua/luci/i18n/minigate.zh-cn.lmo)" \
|
||||
"$(root_path /usr/share/nftables.d/table-pre/20-minigate-login-guard.nft)" \
|
||||
"$(root_path /usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft)"
|
||||
rm -rf \
|
||||
"$(root_path /usr/lib/minigate)" \
|
||||
"$(root_path /usr/lib/lua/luci/model/cbi/minigate)" \
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
export PATH
|
||||
|
||||
CURRENT_VERSION="2026.10.26"
|
||||
CURRENT_VERSION="2026.10.28"
|
||||
REPOSITORY="tpxcer/minigate"
|
||||
API_URL="https://api.github.com/repos/${REPOSITORY}/releases/latest"
|
||||
DOWNLOAD_ROOT="https://github.com/${REPOSITORY}/releases/download"
|
||||
@@ -159,7 +159,9 @@ backup_program() {
|
||||
usr/lib/minigate \
|
||||
usr/lib/lua/luci/controller/minigate.lua \
|
||||
usr/lib/lua/luci/model/cbi/minigate \
|
||||
usr/lib/lua/luci/view/minigate; do
|
||||
usr/lib/lua/luci/view/minigate \
|
||||
usr/share/nftables.d/table-pre/20-minigate-login-guard.nft \
|
||||
usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft; do
|
||||
[ -e "/$path" ] && paths="$paths $path"
|
||||
done
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# MiniGate 登录防护:放在 input 链最前,已建立的连接也会被断开
|
||||
ip saddr @login_banned_v4 counter drop comment "minigate-login-guard"
|
||||
ip6 saddr @login_banned_v6 counter drop comment "minigate-login-guard"
|
||||
@@ -0,0 +1,10 @@
|
||||
# MiniGate 登录防护:封禁集合随防火墙每次重载重建,封禁记录由 login_guard.sh 恢复
|
||||
set login_banned_v4 {
|
||||
type ipv4_addr
|
||||
flags timeout
|
||||
}
|
||||
|
||||
set login_banned_v6 {
|
||||
type ipv6_addr
|
||||
flags timeout
|
||||
}
|
||||
@@ -5,7 +5,7 @@ ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
OUT_DIR="${1:-$ROOT_DIR/dist}"
|
||||
|
||||
PKG_NAME="minigate"
|
||||
PKG_VERSION="2026.10.26"
|
||||
PKG_VERSION="2026.10.28"
|
||||
PKG_ARCH="all"
|
||||
PKG_FILE="${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.ipk"
|
||||
DEPS="libc, luci-base, luci-compat, nginx-ssl, nginx-mod-stream, openssl-util, wget, curl, jsonfilter, coreutils-stat, nftables"
|
||||
@@ -29,7 +29,9 @@ mkdir -p \
|
||||
"$DATA_DIR/etc/minigate/certs" \
|
||||
"$DATA_DIR/etc/minigate/nginx/sites" \
|
||||
"$DATA_DIR/etc/minigate/nginx/streams" \
|
||||
"$DATA_DIR/etc/minigate/login-guard"
|
||||
"$DATA_DIR/etc/minigate/login-guard" \
|
||||
"$DATA_DIR/usr/share/nftables.d/table-pre" \
|
||||
"$DATA_DIR/usr/share/nftables.d/chain-pre/input"
|
||||
|
||||
install -m 0644 "$ROOT_DIR/luasrc/controller/minigate.lua" "$DATA_DIR/usr/lib/lua/luci/controller/minigate.lua"
|
||||
install -m 0644 "$ROOT_DIR"/luasrc/model/cbi/minigate/*.lua "$DATA_DIR/usr/lib/lua/luci/model/cbi/minigate/"
|
||||
@@ -38,6 +40,8 @@ install -m 0755 "$ROOT_DIR"/root/usr/lib/minigate/*.sh "$DATA_DIR/usr/lib/miniga
|
||||
cp -R "$ROOT_DIR/root/usr/lib/minigate/geo" "$DATA_DIR/usr/lib/minigate/"
|
||||
install -m 0644 "$ROOT_DIR/root/etc/config/minigate" "$DATA_DIR/etc/config/minigate"
|
||||
install -m 0755 "$ROOT_DIR/root/etc/init.d/minigate" "$DATA_DIR/etc/init.d/minigate"
|
||||
install -m 0644 "$ROOT_DIR/root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft" "$DATA_DIR/usr/share/nftables.d/table-pre/"
|
||||
install -m 0644 "$ROOT_DIR/root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft" "$DATA_DIR/usr/share/nftables.d/chain-pre/input/"
|
||||
|
||||
if [ -f "$ROOT_DIR/po/zh-cn/minigate.po" ] && command -v po2lmo >/dev/null 2>&1; then
|
||||
mkdir -p "$DATA_DIR/usr/lib/lua/luci/i18n"
|
||||
|
||||
@@ -10,11 +10,12 @@ test('LuCI access settings offer all provinces, default off, validate multiple c
|
||||
const result = spawnSync(lua, ['-'], { encoding: 'utf8', input: `
|
||||
local options = {}
|
||||
package.preload['luci.sys'] = function() return {} end
|
||||
package.preload['luci.controller.minigate'] = function() return {ddns_state=function() return {} end} end
|
||||
package.preload['luci.model.uci'] = function()
|
||||
return {cursor=function() return {foreach=function() end, get=function() end} end}
|
||||
end
|
||||
Flag, Value, DynamicList, ListValue, DummyValue = 'Flag', 'Value', 'DynamicList', 'ListValue', 'DummyValue'
|
||||
NamedSection, TypedSection = 'NamedSection', 'TypedSection'
|
||||
Flag, Value, DynamicList, ListValue, DummyValue, MultiValue = 'Flag', 'Value', 'DynamicList', 'ListValue', 'DummyValue', 'MultiValue'
|
||||
NamedSection, TypedSection, SimpleSection = 'NamedSection', 'TypedSection', 'SimpleSection'
|
||||
function Map()
|
||||
return {section=function()
|
||||
return {option=function(self, kind, name)
|
||||
@@ -34,7 +35,7 @@ test('LuCI access settings offer all provinces, default off, validate multiple c
|
||||
assert(dofile(${JSON.stringify(path.join(root, 'luasrc/model/cbi/minigate/proxy.lua'))}))
|
||||
assert(options.access_enabled.default == '0')
|
||||
local regions = options.access_regions
|
||||
assert(regions.kind == 'DynamicList')
|
||||
assert(regions.kind == 'MultiValue' and regions.widget == 'checkbox')
|
||||
assert(regions.default == '510000')
|
||||
assert(regions.dependency[1] == 'access_enabled' and regions.dependency[2] == '1')
|
||||
assert(regions.choices['510000'] == '四川省')
|
||||
@@ -42,6 +43,8 @@ test('LuCI access settings offer all provinces, default off, validate multiple c
|
||||
assert(count == 34)
|
||||
assert(regions:validate({'510000', '500000'}, 'global'))
|
||||
assert(regions:validate('510000', 'global'))
|
||||
assert(regions:validate('510000 500000', 'global'))
|
||||
assert(not regions:validate('510000 ../x', 'global'))
|
||||
assert(not regions:validate({}, 'global'))
|
||||
assert(not regions:validate({''}, 'global'))
|
||||
assert(not regions:validate({'990000'}, 'global'))
|
||||
|
||||
@@ -64,17 +64,36 @@ test('HTTP redirect rejects unapproved source before sending a redirect', () =>
|
||||
assert.ok(result.conf.indexOf('return 444;') < result.conf.indexOf('return 308'));
|
||||
});
|
||||
|
||||
test('the legacy scanner cannot add global firewall bans in request access mode', () => {
|
||||
const scanner = path.join(root, 'root/usr/lib/minigate/geofence.sh');
|
||||
test('the installer removes the legacy scanner and its global firewall set', () => {
|
||||
const installer = fs.readFileSync(path.join(root, 'install.sh'), 'utf8');
|
||||
const init = fs.readFileSync(path.join(root, 'root/etc/init.d/minigate'), 'utf8');
|
||||
assert.equal(fs.existsSync(path.join(root, 'root/usr/lib/minigate/geofence.sh')), false);
|
||||
assert.doesNotMatch(init, /geofence/);
|
||||
assert.match(installer, /rm -f \/usr\/lib\/minigate\/after_commit\.sh \/usr\/lib\/minigate\/geofence\.sh/);
|
||||
assert.match(installer, /nft delete set inet fw4 minigate_blocked/);
|
||||
assert.match(installer, /uci -q delete minigate\.global\.blocked_ips/);
|
||||
});
|
||||
|
||||
test('legacy province names migrate to request access region codes', () => {
|
||||
const installer = fs.readFileSync(path.join(root, 'install.sh'), 'utf8');
|
||||
const body = installer.slice(installer.indexOf('migrate_legacy_geofence() {'), installer.indexOf('\nmigrate_legacy_geofence\n'));
|
||||
const result = spawnSync('/bin/sh', ['-c', `
|
||||
export MINIGATE_GEOFENCE_LIBRARY_ONLY=1
|
||||
. '${scanner}'
|
||||
uci() { [ "$3" = minigate.global.access_enabled ] && echo 1; }
|
||||
init_block_set() { echo unexpected >&2; return 1; }
|
||||
scan_and_block
|
||||
D=${JSON.stringify(root)}
|
||||
uci() {
|
||||
case "$*" in
|
||||
'-q get minigate.global.geo_enabled') echo 1 ;;
|
||||
'-q get minigate.global.access_enabled') echo 0 ;;
|
||||
'-q get minigate.global.geo_allowed') echo '四川,重庆' ;;
|
||||
*) echo "uci $*" ;;
|
||||
esac
|
||||
}
|
||||
${body}
|
||||
migrate_legacy_geofence
|
||||
`], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stderr, '');
|
||||
assert.match(result.stdout, /add_list minigate\.global\.access_regions=510000/);
|
||||
assert.match(result.stdout, /add_list minigate\.global\.access_regions=500000/);
|
||||
assert.match(result.stdout, /set minigate\.global\.access_enabled=1/);
|
||||
});
|
||||
|
||||
test('bundled regions all cover IPv4 and IPv6 and data follows every install path', () => {
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const script = path.join(root, 'root/usr/lib/minigate/after_commit.sh');
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
function fixture() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-after-commit-test-'));
|
||||
const config = path.join(dir, 'minigate');
|
||||
const marker = path.join(dir, 'applied.log');
|
||||
fs.writeFileSync(config, "option enabled '0'\n");
|
||||
fs.mkdirSync(path.join(dir, 'lib'));
|
||||
fs.writeFileSync(path.join(dir, 'lib/ddns.sh'), `printf 'ddns %s %s\\n' "$1" "$(cat ${JSON.stringify(config)})" >> ${JSON.stringify(marker)}\n`);
|
||||
fs.writeFileSync(path.join(dir, 'lib/proxy.sh'), `printf 'proxy %s\\n' "$1" >> ${JSON.stringify(marker)}\n`);
|
||||
fs.writeFileSync(path.join(dir, 'init'), `#!/bin/sh\nprintf 'init %s\\n' "$1" >> ${JSON.stringify(marker)}\n`);
|
||||
fs.chmodSync(path.join(dir, 'init'), 0o755);
|
||||
return { dir, config, marker };
|
||||
}
|
||||
|
||||
function run(fx, action, wait) {
|
||||
return spawnSync('/bin/sh', [script, action], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
env: {
|
||||
...process.env,
|
||||
MINIGATE_CONFIG_FILE: fx.config,
|
||||
MINIGATE_COMMIT_WAIT: String(wait),
|
||||
MINIGATE_INIT: path.join(fx.dir, 'init'),
|
||||
MINIGATE_LIB_DIR: path.join(fx.dir, 'lib')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const applied = fx => (fs.existsSync(fx.marker) ? fs.readFileSync(fx.marker, 'utf8').trim() : '');
|
||||
|
||||
async function waitFor(check, timeout = 4000) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeout) {
|
||||
if (check()) return true;
|
||||
await sleep(100);
|
||||
}
|
||||
return check();
|
||||
}
|
||||
|
||||
test('saved settings are applied only after LuCI commits the new configuration', async () => {
|
||||
const fx = fixture();
|
||||
const started = Date.now();
|
||||
const result = run(fx, 'ddns', 10);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(Date.now() - started < 1000, 'the LuCI hook must return immediately');
|
||||
|
||||
await sleep(1300);
|
||||
assert.equal(applied(fx), '', 'must not run against the old configuration');
|
||||
fs.writeFileSync(fx.config, "option enabled '1'\n");
|
||||
assert.ok(await waitFor(() => applied(fx) !== ''));
|
||||
assert.equal(applied(fx), "ddns force option enabled '1'");
|
||||
});
|
||||
|
||||
test('an unchanged save still applies after the wait limit', async () => {
|
||||
const fx = fixture();
|
||||
assert.equal(run(fx, 'proxy', 1).status, 0);
|
||||
assert.ok(await waitFor(() => applied(fx) !== ''));
|
||||
assert.equal(applied(fx), 'proxy reload');
|
||||
});
|
||||
|
||||
test('service actions go through the init script and unknown actions are rejected', async () => {
|
||||
const fx = fixture();
|
||||
assert.equal(run(fx, 'restart', 1).status, 0);
|
||||
assert.ok(await waitFor(() => applied(fx) !== ''));
|
||||
assert.equal(applied(fx), 'init restart');
|
||||
|
||||
const bad = fixture();
|
||||
assert.equal(run(bad, 'reboot', 1).status, 2);
|
||||
await sleep(1500);
|
||||
assert.equal(applied(bad), '');
|
||||
});
|
||||
|
||||
test('LuCI pages defer their actions and saving DDNS no longer restarts the proxy', () => {
|
||||
const page = name => fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate', name), 'utf8');
|
||||
assert.match(page('general.lua'), /after_commit\.sh " \.\. \(en == "1" and "restart" or "stop"\)/);
|
||||
assert.match(page('ddns.lua'), /after_commit\.sh ddns/);
|
||||
assert.match(page('proxy.lua'), /after_commit\.sh proxy/);
|
||||
assert.match(page('login_guard.lua'), /after_commit\.sh reload/);
|
||||
assert.doesNotMatch(page('ddns.lua'), /minigate restart/);
|
||||
const makefile = fs.readFileSync(path.join(root, 'Makefile'), 'utf8');
|
||||
assert.match(makefile, /root\/usr\/lib\/minigate\/after_commit\.sh/);
|
||||
});
|
||||
@@ -31,11 +31,12 @@ function readRows(file) {
|
||||
return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) {
|
||||
function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0, currentIp = '198.51.100.20' }) {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-ddns-update-'));
|
||||
const cacheFile = path.join(tempDir, 'minigate_ddns_cfg01.cache');
|
||||
const refreshLog = path.join(tempDir, 'refresh.log');
|
||||
const uciLog = path.join(tempDir, 'uci.log');
|
||||
const stateDir = path.join(tempDir, 'state');
|
||||
if (cachedIp) fs.writeFileSync(cacheFile, `${cachedIp}\n`);
|
||||
|
||||
const command = `
|
||||
@@ -44,6 +45,7 @@ function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) {
|
||||
export MINIGATE_CACHE_DIR=${JSON.stringify(tempDir)}
|
||||
export MINIGATE_HISTORY_DIR=${JSON.stringify(path.join(tempDir, 'history'))}
|
||||
export MINIGATE_DDNS_LOG=${JSON.stringify(path.join(tempDir, 'ddns.log'))}
|
||||
export MINIGATE_DDNS_STATE_DIR=${JSON.stringify(stateDir)}
|
||||
. ${JSON.stringify(ddnsScript)}
|
||||
uci() {
|
||||
[ "\${1:-}" = "-q" ] && shift
|
||||
@@ -57,11 +59,11 @@ function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) {
|
||||
get:minigate.cfg01.ip_url) printf 'http://ip.example/\\n' ;;
|
||||
get:minigate.cfg01.check_interval) printf '300\\n' ;;
|
||||
get:minigate.cfg01.ip_version) printf 'ipv4\\n' ;;
|
||||
set:*) printf '%s\\n' "\$2" >> ${JSON.stringify(uciLog)} ;;
|
||||
set:*|commit:*) printf '%s\\n' "\$*" >> ${JSON.stringify(uciLog)} ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
get_ip4_url() { printf '198.51.100.20\\n'; }
|
||||
get_ip4_url() { printf '%s\\n' ${JSON.stringify(currentIp)}; }
|
||||
record_ip_history() { return 0; }
|
||||
calc_next() { printf '12:00:00\\n'; }
|
||||
cf_update() {
|
||||
@@ -78,7 +80,15 @@ function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) {
|
||||
const refreshes = fs.existsSync(refreshLog) ? readRows(refreshLog) : [];
|
||||
const uciWrites = fs.existsSync(uciLog) ? readRows(uciLog) : [];
|
||||
const cache = fs.existsSync(cacheFile) ? fs.readFileSync(cacheFile, 'utf8').trim() : '';
|
||||
return { result, refreshes, uciWrites, cache };
|
||||
const stateFile = path.join(stateDir, '__example_com.state');
|
||||
const state = {};
|
||||
if (fs.existsSync(stateFile)) {
|
||||
for (const line of readRows(stateFile)) {
|
||||
const at = line.indexOf('=');
|
||||
state[line.slice(0, at)] = line.slice(at + 1);
|
||||
}
|
||||
}
|
||||
return { result, refreshes, uciWrites, cache, state };
|
||||
}
|
||||
|
||||
function runCloudflareScenario(mode) {
|
||||
@@ -251,14 +261,14 @@ function runScheduled({ force = false, checked }) {
|
||||
|
||||
test('scheduled DDNS checks honour each record interval and skip disabled records', () => {
|
||||
const due = runScheduled({ checked: { cfg01: 1000000 - 280, cfg02: 1000000 - 600 } });
|
||||
assert.deepEqual(due.calls, ['cfg01 0', 'commit']);
|
||||
assert.deepEqual(due.calls, ['cfg01 0'], 'scheduled runs never commit /etc/config');
|
||||
|
||||
const idle = runScheduled({ checked: { cfg01: 1000000 - 100, cfg02: 1000000 - 600 } });
|
||||
assert.deepEqual(idle.calls, [], 'nothing due: no update and no config commit');
|
||||
assert.equal(idle.logged, false, 'nothing due: no log noise every five minutes');
|
||||
|
||||
const forced = runScheduled({ force: true, checked: { cfg01: 1000000 - 10, cfg02: 1000000 - 10 } });
|
||||
assert.deepEqual(forced.calls, ['cfg01 1', 'cfg02 1', 'commit']);
|
||||
assert.deepEqual(forced.calls, ['cfg01 1', 'cfg02 1']);
|
||||
});
|
||||
|
||||
test('DDNS interval is at least five minutes and the next run matches the cron grid', () => {
|
||||
@@ -280,7 +290,9 @@ test('successful Cloudflare changes refresh local DNS caches once', () => {
|
||||
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
|
||||
assert.deepEqual(scenario.refreshes, ['*.example.com']);
|
||||
assert.equal(scenario.cache, '198.51.100.20');
|
||||
assert.ok(scenario.uciWrites.includes('minigate.cfg01.status=ok'));
|
||||
assert.equal(scenario.state.status, 'ok');
|
||||
assert.equal(scenario.state.ip, '198.51.100.20');
|
||||
assert.deepEqual(scenario.uciWrites, [], 'runtime state must not be written to /etc/config');
|
||||
});
|
||||
|
||||
test('unchanged forced sync does not flush caches, but a stale local IP does', () => {
|
||||
@@ -299,14 +311,40 @@ test('Cloudflare failure never flushes local caches', () => {
|
||||
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
|
||||
assert.deepEqual(scenario.refreshes, []);
|
||||
assert.equal(scenario.cache, '198.51.100.19');
|
||||
assert.ok(scenario.uciWrites.includes('minigate.cfg01.status=error'));
|
||||
assert.equal(scenario.state.status, 'error');
|
||||
assert.deepEqual(scenario.uciWrites, []);
|
||||
});
|
||||
|
||||
test('cache refresh failure does not turn a successful DDNS update into an error', () => {
|
||||
const scenario = runUpdateScenario({ cfResult: 'changed', refreshResult: 1 });
|
||||
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
|
||||
assert.deepEqual(scenario.refreshes, ['*.example.com']);
|
||||
assert.ok(scenario.uciWrites.includes('minigate.cfg01.status=ok'));
|
||||
assert.equal(scenario.state.status, 'ok');
|
||||
});
|
||||
|
||||
test('a private interface address is never pushed to Cloudflare', () => {
|
||||
for (const ip of ['192.168.2.2', '10.0.0.5', '100.64.1.1', '172.20.0.1']) {
|
||||
const scenario = runUpdateScenario({ cfResult: 'changed', currentIp: ip });
|
||||
assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout);
|
||||
assert.equal(scenario.state.status, 'error', ip);
|
||||
assert.match(scenario.state.message, /不是公网地址/);
|
||||
assert.equal(scenario.cache, '', 'cf_update must not run for ' + ip);
|
||||
assert.deepEqual(scenario.refreshes, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('public address checks reject private, carrier NAT, ULA and link-local ranges', () => {
|
||||
const { output } = runHistory(`
|
||||
for ip in 1.2.3.4 182.147.210.9 10.1.1.1 172.16.0.1 172.32.0.1 192.168.1.1 100.64.0.1 100.128.0.1 127.0.0.1 169.254.1.1 0.1.2.3 224.0.0.1 256.1.1.1 1.2.3; do
|
||||
is_public_ipv4 "$ip" && printf '%s ' "$ip"
|
||||
done
|
||||
printf '| '
|
||||
for ip in 2001:db8::1 240e:1::5 fd00::1 fe80::1 ::1 'x:y'; do
|
||||
is_public_ipv6 "$ip" && printf '%s ' "$ip"
|
||||
done
|
||||
true
|
||||
`);
|
||||
assert.equal(output, '1.2.3.4 182.147.210.9 172.32.0.1 100.128.0.1 | 2001:db8::1 240e:1::5');
|
||||
});
|
||||
|
||||
test('local cache refresh uses sing-box API and dnsmasq SIGHUP without service restart', () => {
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const script = path.join(root, 'root/usr/lib/minigate/geofence.sh');
|
||||
|
||||
function runLibrary(body, accessLog = '') {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-geofence-test-'));
|
||||
const log = path.join(dir, 'access.log');
|
||||
fs.writeFileSync(log, accessLog);
|
||||
const command = `
|
||||
set -u
|
||||
export MINIGATE_GEOFENCE_LIBRARY_ONLY=1
|
||||
export MINIGATE_ACCESS_LOG=${JSON.stringify(log)}
|
||||
export MINIGATE_GEOFENCE_LOG=${JSON.stringify(path.join(dir, 'geofence.log'))}
|
||||
export MINIGATE_GEO_CACHE_DIR=${JSON.stringify(path.join(dir, 'cache'))}
|
||||
. ${JSON.stringify(script)}
|
||||
${body}
|
||||
`;
|
||||
return spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
const entry = client => JSON.stringify({ time: '2026-10-17T07:22:40+08:00', domain: 'box.example.com', client, status: 200 });
|
||||
const ACCESS_LOG = [
|
||||
entry('192.168.2.1'),
|
||||
entry('198.51.100.7'),
|
||||
entry('172.20.0.5'),
|
||||
entry('203.0.113.9'),
|
||||
entry('198.51.100.7'),
|
||||
entry('2001:db8::1'),
|
||||
entry('100.100.1.1'),
|
||||
entry('192.168.2.15')
|
||||
].join('\n') + '\n';
|
||||
|
||||
test('private, loopback, link-local and carrier NAT addresses are recognised', () => {
|
||||
const privateIps = ['0.0.0.0', '10.1.2.3', '127.0.0.1', '169.254.1.1', '172.16.0.1', '172.31.255.255',
|
||||
'192.168.2.15', '100.64.0.1', '100.99.1.1', '100.127.255.255'];
|
||||
const publicIps = ['172.15.0.1', '172.32.0.1', '100.63.255.255', '100.128.0.1', '198.51.100.7', '1.10.0.1', '192.169.0.1'];
|
||||
const check = ips => ips.map(ip => `is_private_ip ${ip} && echo "${ip} private" || echo "${ip} public"`).join('\n');
|
||||
const result = runLibrary(check([...privateIps, ...publicIps]));
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(result.stdout.trim().split('\n'), [
|
||||
...privateIps.map(ip => `${ip} private`),
|
||||
...publicIps.map(ip => `${ip} public`)
|
||||
]);
|
||||
});
|
||||
|
||||
test('client extraction works without grep -P and keeps only unique public IPv4 visitors', () => {
|
||||
const result = runLibrary(`
|
||||
grep() { [ "\${1:-}" = "-oP" ] && { echo "grep: unrecognized option: P" >&2; return 2; }; command grep "$@"; }
|
||||
extract_public_ips
|
||||
`, ACCESS_LOG);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(result.stdout.trim().split('\n'), ['198.51.100.7', '203.0.113.9']);
|
||||
});
|
||||
|
||||
test('scan never looks up or blocks LAN visitors', () => {
|
||||
const result = runLibrary(`
|
||||
uci() {
|
||||
[ "\${1:-}" = "-q" ] && shift
|
||||
case "\${2:-}" in
|
||||
minigate.global.geo_enabled) echo 1 ;;
|
||||
minigate.global.geo_allowed) echo 四川 ;;
|
||||
esac
|
||||
}
|
||||
init_block_set() { :; }
|
||||
nft() { :; }
|
||||
sleep() { :; }
|
||||
lookup_geo() { echo "lookup $1" >&2; echo 广东; }
|
||||
block_ip() { echo "block $1"; }
|
||||
mkdir -p "$CACHE_DIR"
|
||||
scan_and_block
|
||||
`, ACCESS_LOG);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(result.stderr.trim().split('\n'), ['lookup 198.51.100.7', 'lookup 203.0.113.9']);
|
||||
assert.deepEqual(result.stdout.trim().split('\n'), ['block 198.51.100.7', 'block 203.0.113.9']);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const init = path.join(root, 'root/etc/init.d/minigate');
|
||||
|
||||
function runInit(body, env = {}) {
|
||||
return spawnSync('/bin/sh', ['-c', `. ${JSON.stringify(init)}\n${body}`], { encoding: 'utf8', env: { ...process.env, ...env } });
|
||||
}
|
||||
|
||||
test('cron entries are managed by script path and user jobs mentioning minigate survive', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-cron-'));
|
||||
const cron = path.join(dir, 'root');
|
||||
fs.writeFileSync(cron, '0 1 * * * /root/backup-minigate-certs.sh\n*/2 * * * * /bin/sh /usr/lib/minigate/geofence.sh scan\n');
|
||||
let result = runInit('update_cron 1', { MINIGATE_CRON_FILE: cron });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const enabled = fs.readFileSync(cron, 'utf8');
|
||||
assert.match(enabled, /^0 1 \* \* \* \/root\/backup-minigate-certs\.sh$/m);
|
||||
assert.doesNotMatch(enabled, /geofence/);
|
||||
assert.match(enabled, /\/usr\/lib\/minigate\/ddns\.sh$/m);
|
||||
assert.match(enabled, /\/usr\/lib\/minigate\/acme\.sh renew$/m);
|
||||
|
||||
result = runInit('update_cron 0', { MINIGATE_CRON_FILE: cron });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.readFileSync(cron, 'utf8'), '0 1 * * * /root/backup-minigate-certs.sh\n');
|
||||
});
|
||||
|
||||
test('reload digests only include the settings of the page that changed', () => {
|
||||
const result = runInit(`
|
||||
md5sum() { cat; }
|
||||
uci() {
|
||||
cat <<'UCI'
|
||||
minigate.global=global
|
||||
minigate.global.enabled='1'
|
||||
minigate.@ddns[0]=ddns
|
||||
minigate.@ddns[0].domain='a.example.com'
|
||||
minigate.web=proxy
|
||||
minigate.web.domain='b.example.com'
|
||||
minigate.login_guard=login_guard
|
||||
minigate.login_guard.threshold='3'
|
||||
UCI
|
||||
}
|
||||
echo "== ddns"; config_digest ddns
|
||||
echo "== proxy"; config_digest "global proxy proxy_wildcard subproxy"
|
||||
`);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, [
|
||||
'== ddns',
|
||||
'minigate.@ddns[0]=ddns',
|
||||
"minigate.@ddns[0].domain='a.example.com'",
|
||||
'== proxy',
|
||||
'minigate.global=global',
|
||||
"minigate.global.enabled='1'",
|
||||
'minigate.web=proxy',
|
||||
"minigate.web.domain='b.example.com'",
|
||||
''
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('services run under procd, background work drops the procd lock and LuCI pages no longer poll for commits', () => {
|
||||
const source = fs.readFileSync(init, 'utf8');
|
||||
assert.match(source, /procd_open_instance login_guard/);
|
||||
assert.match(source, /procd_set_param env MINIGATE_LG_DIGEST=/);
|
||||
assert.match(source, /run_background\(\) \{\n \( close_procd_lock; "\$@" \) <\/dev\/null >\/dev\/null 2>&1 &/);
|
||||
assert.match(source, /\( close_procd_lock; \/etc\/init\.d\/cron restart \)/);
|
||||
// 只有 eval 字符串里出现多位数描述符,dash 等 shell 才能解析本文件
|
||||
assert.deepEqual(source.split('\n').filter(line => /\d{2,}>&/.test(line)), [" [ -e /proc/self/fd/1000 ] && eval 'exec 1000>&-'"]);
|
||||
for (const page of ['general', 'ddns', 'proxy', 'login_guard']) {
|
||||
const lua = fs.readFileSync(path.join(root, `luasrc/model/cbi/minigate/${page}.lua`), 'utf8');
|
||||
assert.doesNotMatch(lua, /on_after_commit|after_commit\.sh/, page);
|
||||
}
|
||||
assert.equal(fs.existsSync(path.join(root, 'root/usr/lib/minigate/after_commit.sh')), false);
|
||||
});
|
||||
@@ -127,9 +127,10 @@ test('no generated sites means no hosts file', () => {
|
||||
test('reload syncs LAN hosts only after nginx accepted the new configuration', () => {
|
||||
const box = sandbox();
|
||||
const body = ok => `
|
||||
PID_FILE=${JSON.stringify(path.join(box.dir, 'missing.pid'))}
|
||||
prepare_config() { return 0; }
|
||||
start_prepared() { return ${ok ? 0 : 1}; }
|
||||
uci() { echo 1; }
|
||||
lock_proxy() { :; }
|
||||
prepare_config() { return ${ok ? 0 : 1}; }
|
||||
apply_config() { return 0; }
|
||||
sync_lan_hosts() { echo synced; }
|
||||
do_reload
|
||||
`;
|
||||
|
||||
@@ -8,24 +8,29 @@ const { spawn, spawnSync } = require('node:child_process');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const script = path.join(root, 'root/usr/lib/minigate/login_guard.sh');
|
||||
|
||||
function runRepair({ firewallReady }) {
|
||||
// firewallReady: 集合和规则都在;setsEmpty: 防火墙重载后随包集合存在但内容为空
|
||||
function runRepair({ firewallReady, setsEmpty = false }) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-login-guard-test-'));
|
||||
const dataDir = path.join(dir, 'data');
|
||||
const bans = path.join(dataDir, 'bans.txt');
|
||||
const calls = path.join(dir, 'nft.log');
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(bans, '198.51.100.4 9999999999\n198.51.100.5 1\n');
|
||||
fs.writeFileSync(bans, '198.51.100.4 9999999999\n198.51.100.5 1\n2001:db8::9 9999999999\n');
|
||||
const before = fs.statSync(bans).ino;
|
||||
|
||||
const command = `
|
||||
set -u
|
||||
logger() { :; }
|
||||
uci() { return 1; }
|
||||
ubus() { return 1; }
|
||||
nft() {
|
||||
printf '%s\\n' "$*" >> ${JSON.stringify(calls)}
|
||||
case "$*" in
|
||||
'list set inet fw4 login_banned_v4') [ ${firewallReady ? 1 : 0} = 1 ]; return ;;
|
||||
'list chain inet fw4 input') [ ${firewallReady ? 1 : 0} = 1 ] && printf 'ip saddr @login_banned_v4 counter drop\\n'; return 0 ;;
|
||||
'list set inet fw4 login_banned_v4'|'list set inet fw4 login_banned_v6')
|
||||
[ ${firewallReady ? 1 : 0} = 1 ] || return 1
|
||||
[ ${setsEmpty ? 1 : 0} = 1 ] || printf 'elements = { 198.51.100.4 }\\n'
|
||||
return 0 ;;
|
||||
'list chain inet fw4 input') [ ${firewallReady ? 1 : 0} = 1 ] && printf 'ip saddr @login_banned_v4 counter drop\\nip6 saddr @login_banned_v6 counter drop\\n'; return 0 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
@@ -52,56 +57,118 @@ test('periodic nft check does not rewrite the persisted ban list when rules are
|
||||
assert.equal(scenario.rewritten, false, 'bans.txt must not be rewritten on every watchdog tick');
|
||||
});
|
||||
|
||||
test('a firewall reload that drops the set restores persisted bans immediately', () => {
|
||||
test('a firewall reload that drops the set restores persisted IPv4 and IPv6 bans immediately', () => {
|
||||
const scenario = runRepair({ firewallReady: false });
|
||||
assert.equal(scenario.result.status, 0, scenario.result.stderr);
|
||||
assert.match(scenario.calls, /add set inet fw4 login_banned_v4/);
|
||||
assert.match(scenario.calls, /add set inet fw4 login_banned_v4 \{ type ipv4_addr; flags timeout; \}/);
|
||||
assert.match(scenario.calls, /add set inet fw4 login_banned_v6 \{ type ipv6_addr; flags timeout; \}/);
|
||||
assert.match(scenario.calls, /insert rule inet fw4 input ip saddr @login_banned_v4/);
|
||||
assert.match(scenario.calls, /insert rule inet fw4 input ip6 saddr @login_banned_v6/);
|
||||
assert.match(scenario.calls, /add element inet fw4 login_banned_v4 \{ 198\.51\.100\.4 timeout \d+s \}/);
|
||||
assert.match(scenario.calls, /add element inet fw4 login_banned_v6 \{ 2001:db8::9 timeout \d+s \}/);
|
||||
assert.doesNotMatch(scenario.calls, /198\.51\.100\.5/, 'expired bans must not be restored');
|
||||
assert.equal(scenario.bans, '198.51.100.4 9999999999\n');
|
||||
assert.equal(scenario.bans, '198.51.100.4 9999999999\n2001:db8::9 9999999999\n');
|
||||
});
|
||||
|
||||
// BusyBox pgrep 只排除自身,不像 macOS 那样默认排除祖先进程。
|
||||
function busyboxPgrep(dir) {
|
||||
const shim = path.join(dir, 'bin/pgrep');
|
||||
fs.mkdirSync(path.dirname(shim), { recursive: true });
|
||||
fs.writeFileSync(shim, `#!/bin/sh
|
||||
self=$$
|
||||
case "$1" in
|
||||
-f) ps -A -o pid= -o ppid= -o args= | awk -v self="$self" -v re="$2" '
|
||||
{ pid=$1; ppid=$2; if (pid == self || ppid == self) next; $1=""; $2=""; sub(/^ +/, ""); if ($0 ~ re) print pid }' ;;
|
||||
-P) ps -A -o pid= -o ppid= | awk -v self="$self" -v parent="$2" '$2 == parent && $1 != self { print $1 }' ;;
|
||||
esac
|
||||
`);
|
||||
fs.chmodSync(shim, 0o755);
|
||||
return path.dirname(shim);
|
||||
test('packaged firewall sets that come back empty after a reload are refilled', () => {
|
||||
const scenario = runRepair({ firewallReady: true, setsEmpty: true });
|
||||
assert.equal(scenario.result.status, 0, scenario.result.stderr);
|
||||
assert.doesNotMatch(scenario.calls, /add set|insert rule/);
|
||||
assert.match(scenario.calls, /add element inet fw4 login_banned_v4 \{ 198\.51\.100\.4 timeout \d+s \}/);
|
||||
});
|
||||
|
||||
test('fw4 includes declare both ban sets before the input rules that use them', () => {
|
||||
const tablePre = fs.readFileSync(path.join(root, 'root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft'), 'utf8');
|
||||
const chainPre = fs.readFileSync(path.join(root, 'root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft'), 'utf8');
|
||||
assert.match(tablePre, /set login_banned_v4 \{\n\ttype ipv4_addr\n\tflags timeout\n\}/);
|
||||
assert.match(tablePre, /set login_banned_v6 \{\n\ttype ipv6_addr\n\tflags timeout\n\}/);
|
||||
assert.match(chainPre, /^ip saddr @login_banned_v4 counter drop comment "minigate-login-guard"$/m);
|
||||
assert.match(chainPre, /^ip6 saddr @login_banned_v6 counter drop comment "minigate-login-guard"$/m);
|
||||
for (const file of ['Makefile', 'install.sh', 'scripts/build-ipk.sh', 'root/usr/lib/minigate/uninstall.sh']) {
|
||||
assert.match(fs.readFileSync(path.join(root, file), 'utf8'), /nftables\.d\/chain-pre\/input\/20-minigate-login-guard\.nft/, file);
|
||||
}
|
||||
});
|
||||
|
||||
function library(body) {
|
||||
return spawnSync('/bin/sh', ['-c', `
|
||||
export MINIGATE_LG_LIBRARY_ONLY=1
|
||||
export MINIGATE_LG_DATA_DIR=${JSON.stringify(fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-lg-lib-')))}
|
||||
export MINIGATE_LG_RUN_DIR=${JSON.stringify(fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-lg-run-')))}
|
||||
export MINIGATE_LG_LOG=/dev/null
|
||||
logger() { :; }
|
||||
uci() { [ "$3" = minigate.login_guard.whitelist ] && echo "203.0.113.0/20 2001:db8:abcd::/48"; return 0; }
|
||||
. ${JSON.stringify(script)}
|
||||
${body}
|
||||
`], { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
test('stopping the login guard ends its children but not a caller that mentions it', async () => {
|
||||
const init = path.join(root, 'root/etc/init.d/minigate');
|
||||
test('addresses from dropbear and LuCI logs are parsed for both IP families and spoofed usernames', () => {
|
||||
const result = library(`
|
||||
extract_ip "authpriv.warn dropbear[1]: Bad password attempt for 'root' from 198.51.100.7:52144" 1
|
||||
extract_ip "dropbear[1]: Login attempt for nonexistent user 'x from 9.9.9.9:1' from 2001:db8::7:52144" 1
|
||||
extract_ip "luci: failed login on / for root from 2001:db8::8" 0
|
||||
extract_ip "luci: failed login on / for root from bad;rm" 0 || echo rejected
|
||||
`);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout.trim(), '198.51.100.7\n2001:db8::7\n2001:db8::8\nrejected');
|
||||
});
|
||||
|
||||
test('whitelist matches any IPv4 prefix length and IPv6 subnets, private ranges stay exempt', () => {
|
||||
const result = library(`
|
||||
for ip in 203.0.120.9 203.0.128.1 2001:db8:abcd:1::5 2001:db8:abce::5 192.168.2.5 fd00::5 fe80::1 198.51.100.7 2400:da00::1; do
|
||||
is_whitelisted "$ip" && printf 'white:%s ' "$ip" || printf 'watch:%s ' "$ip"
|
||||
done
|
||||
`);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout.trim(), 'white:203.0.120.9 watch:203.0.128.1 white:2001:db8:abcd:1::5 watch:2001:db8:abce::5 white:192.168.2.5 white:fd00::5 white:fe80::1 watch:198.51.100.7 watch:2400:da00::1');
|
||||
});
|
||||
|
||||
test('a ban the firewall rejects is reported instead of being logged as banned', () => {
|
||||
const result = library(`
|
||||
nft() { return 1; }
|
||||
ban_ip 198.51.100.9 && echo banned || echo failed
|
||||
cat "$BANS_FILE"
|
||||
`);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout.trim(), 'failed');
|
||||
});
|
||||
|
||||
test('procd stopping the login guard ends logread and tail children and lifts enforcement', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-login-guard-stop-'));
|
||||
const bin = path.join(dir, 'bin');
|
||||
const followed = path.join(dir, 'access.log');
|
||||
const guardScript = path.join(dir, 'login_guard.sh');
|
||||
const marker = path.join(dir, 'caller-survived');
|
||||
const nftLog = path.join(dir, 'nft.log');
|
||||
fs.mkdirSync(bin);
|
||||
fs.writeFileSync(followed, '');
|
||||
// 与 login_guard.sh run 相同的结构:后台子 shell + tail -f 管道;忽略 SIGPIPE 的 tail 失去读端后不会自行退出
|
||||
fs.writeFileSync(guardScript, `trap "" PIPE\n( while :; do sleep 1; done ) &\ntail -n 0 -f ${JSON.stringify(followed)} | while read -r line; do :; done\n`);
|
||||
const guard = spawn('/bin/sh', [guardScript, 'run'], { detached: true, stdio: 'ignore' });
|
||||
guard.unref();
|
||||
const shim = (name, body) => fs.writeFileSync(path.join(bin, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 });
|
||||
shim('uci', 'case "$3" in minigate.login_guard.enabled) echo 1 ;; *) exit 1 ;; esac');
|
||||
shim('nft', `printf '%s\\n' "$*" >> ${JSON.stringify(nftLog)}; case "$*" in 'list chain'*) echo '@login_banned_v4 @login_banned_v6' ;; 'list set'*) echo elements ;; esac; exit 0`);
|
||||
shim('logger', 'exit 0');
|
||||
shim('ubus', 'exit 1');
|
||||
// logread -f 与 tail -f 一样会一直阻塞,用 tail 模拟
|
||||
shim('logread', `exec tail -n 0 -f ${JSON.stringify(followed)}`);
|
||||
const guard = spawn('/bin/sh', [script, 'run'], {
|
||||
stdio: 'ignore',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
MINIGATE_LG_DATA_DIR: path.join(dir, 'data'),
|
||||
MINIGATE_LG_RUN_DIR: path.join(dir, 'run'),
|
||||
MINIGATE_LG_LOG: path.join(dir, 'lg.log'),
|
||||
MINIGATE_ACCESS_LOG: followed
|
||||
}
|
||||
});
|
||||
const pgrep = pattern => spawnSync('pgrep', ['-f', pattern], { encoding: 'utf8' }).stdout.trim();
|
||||
const started = Date.now();
|
||||
while (!pgrep(`tail -n 0 -f ${followed}`) && Date.now() - started < 3000) await new Promise(resolve => setTimeout(resolve, 100));
|
||||
assert.ok(pgrep(`tail -n 0 -f ${followed}`), 'fake login guard did not start');
|
||||
|
||||
// 调用方的命令行里含有登录防护的命令文本(例如 SSH 一次性执行的脚本),不能被当成登录防护结束
|
||||
const caller = `logger() { :; }; nft() { return 1; }; . ${JSON.stringify(init)}; LOGIN_GUARD=${JSON.stringify(guardScript)}; echo "checking ${guardScript} run" >/dev/null; stop_login_guard; echo ok > ${JSON.stringify(marker)}`;
|
||||
const result = spawnSync('/bin/sh', ['-c', caller], { encoding: 'utf8', env: { ...process.env, PATH: `${busyboxPgrep(dir)}:${process.env.PATH}` } });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.existsSync(marker), true, 'stop_login_guard killed its own caller');
|
||||
while (pgrep(`tail -n 0 -f ${followed}`).split('\n').filter(Boolean).length < 2 && Date.now() - started < 4000) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
assert.equal(pgrep(`tail -n 0 -f ${followed}`).split('\n').filter(Boolean).length, 2, 'both followers should run');
|
||||
guard.kill('SIGTERM');
|
||||
await new Promise(resolve => guard.once('exit', resolve));
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
assert.equal(pgrep(`tail -n 0 -f ${followed}`), '', 'tail -f must not be left behind');
|
||||
assert.equal(pgrep(`${guardScript} run`), '');
|
||||
assert.equal(pgrep(`tail -n 0 -f ${followed}`), '', 'tail -f / logread -f must not be left behind');
|
||||
assert.match(fs.readFileSync(nftLog, 'utf8'), /flush set inet fw4 login_banned_v4[\s\S]*flush set inet fw4 login_banned_v6/);
|
||||
});
|
||||
|
||||
test('upgrades stop the old login guard tree before running the previous init script', () => {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const lua = process.env.MINIGATE_TEST_LUA;
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const controller = path.join(root, 'luasrc/controller/minigate.lua');
|
||||
|
||||
function runLua(source) {
|
||||
const result = spawnSync(lua, ['-'], { encoding: 'utf8', input: source });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function luaFiles(dir) {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
||||
const full = path.join(dir, entry.name);
|
||||
return entry.isDirectory() ? luaFiles(full) : (entry.name.endsWith('.lua') ? [full] : []);
|
||||
});
|
||||
}
|
||||
|
||||
test('every LuCI Lua file compiles', { skip: !lua }, () => {
|
||||
const files = luaFiles(path.join(root, 'luasrc'));
|
||||
assert.ok(files.length >= 6);
|
||||
const checks = files.map(file => `assert(loadfile(${JSON.stringify(file)}))`).join('\n');
|
||||
runLua(checks + '\nprint("ok")');
|
||||
});
|
||||
|
||||
// 加载控制器时只需要 luci.http;其他模块在函数内部按需 require
|
||||
const controllerPrelude = `
|
||||
local response, status_code, calls, form = nil, nil, {}, {}
|
||||
luci = {http={
|
||||
formvalue=function(name) return form[name] end,
|
||||
prepare_content=function() end,
|
||||
write_json=function(v) response=v end,
|
||||
status=function(code) status_code=code end
|
||||
}}
|
||||
package.preload['luci.sys'] = function() return {
|
||||
call=function(cmd) calls[#calls + 1] = cmd; return 0 end,
|
||||
exec=function() return '' end
|
||||
} end
|
||||
package.preload['luci.model.uci'] = function() return {cursor=function() return {
|
||||
get=function(_, _, sec) if sec == 'cfg01' then return 'ddns' end end,
|
||||
foreach=function() end
|
||||
} end} end
|
||||
dofile(${JSON.stringify(controller)})
|
||||
local c = luci.controller.minigate
|
||||
`;
|
||||
|
||||
test('certificate expiry parsing is timezone independent', { skip: !lua }, () => {
|
||||
const expected = Date.UTC(2026, 11, 16, 12, 0, 0) / 1000;
|
||||
const out = runLua(controllerPrelude + `
|
||||
print(c.parse_cert_time('Dec 16 12:00:00 2026 GMT'))
|
||||
print(c.parse_cert_time('Feb 1 00:00:00 2028 GMT'))
|
||||
print(tostring(c.parse_cert_time('garbage')))
|
||||
`);
|
||||
assert.deepEqual(out.split('\n'), [String(expected), String(Date.UTC(2028, 1, 1) / 1000), 'nil']);
|
||||
});
|
||||
|
||||
test('log view merges sources by time and keeps untimed lines with their entry', { skip: !lua }, () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-logs-'));
|
||||
fs.writeFileSync(path.join(dir, 'ddns.log'), '2026-10-28 10:00:01 [DDNS] a\n2026-10-28 10:00:05 [DDNS] c\n');
|
||||
fs.writeFileSync(path.join(dir, 'nginx.log'), '2026/10/28 10:00:03 [emerg] b\n');
|
||||
fs.writeFileSync(path.join(dir, 'update.log'), '2026-10-28 10:00:04 start\ncurl output without time\n');
|
||||
const out = runLua(controllerPrelude + `
|
||||
print(c.merge_logs({{'DDNS', ${JSON.stringify(path.join(dir, 'ddns.log'))}}, {'Nginx', ${JSON.stringify(path.join(dir, 'nginx.log'))}},
|
||||
{'更新', ${JSON.stringify(path.join(dir, 'update.log'))}}, {'缺失', '/missing.log'}}, 300))
|
||||
print('--')
|
||||
print(c.merge_logs({{'DDNS', ${JSON.stringify(path.join(dir, 'ddns.log'))}}}, 1))
|
||||
`);
|
||||
assert.equal(out, [
|
||||
'[DDNS] 2026-10-28 10:00:01 [DDNS] a',
|
||||
'[Nginx] 2026/10/28 10:00:03 [emerg] b',
|
||||
'[更新] 2026-10-28 10:00:04 start',
|
||||
'[更新] curl output without time',
|
||||
'[DDNS] 2026-10-28 10:00:05 [DDNS] c',
|
||||
'--',
|
||||
'[DDNS] 2026-10-28 10:00:05 [DDNS] c'
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('state files are read by the same domain key the shell scripts write', { skip: !lua }, () => {
|
||||
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-state-')), 'proxy.status');
|
||||
fs.writeFileSync(file, 'result=ok\nmessage=配置检查通过\nwarning=one\nwarning=two=2\n');
|
||||
const out = runLua(controllerPrelude + `
|
||||
local st = c.read_state(${JSON.stringify(file)})
|
||||
print(st.result, st.message, #st.warnings, st.warnings[2])
|
||||
print(c.state_key('*.example.com'), c.state_key('home.example.com'))
|
||||
print(tostring(c.read_state('/missing/state')))
|
||||
`);
|
||||
assert.equal(out, 'ok\t配置检查通过\t2\ttwo=2\n__example_com\thome_example_com\nnil');
|
||||
});
|
||||
|
||||
test('login guard endpoints accept IPv6, quote the address and reject shell text', { skip: !lua }, () => {
|
||||
const out = runLua(controllerPrelude + `
|
||||
for _, ip in ipairs({'2001:db8::1', '198.51.100.7', "1.2.3.4'; reboot; '", 'abc'}) do
|
||||
form.ip = ip; calls = {}; response = nil
|
||||
c.action_lg_ban()
|
||||
print(response.success and calls[1] or 'rejected')
|
||||
end
|
||||
`);
|
||||
assert.equal(out, [
|
||||
"/bin/sh /usr/lib/minigate/login_guard.sh ban '2001:db8::1' >/dev/null 2>&1",
|
||||
"/bin/sh /usr/lib/minigate/login_guard.sh ban '198.51.100.7' >/dev/null 2>&1",
|
||||
'rejected',
|
||||
'rejected'
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('manual DDNS sync starts a background job only for a real DDNS section', { skip: !lua }, () => {
|
||||
const out = runLua(controllerPrelude + `
|
||||
form.section = 'cfg01'; c.action_ddns_sync()
|
||||
print(response.success, calls[1])
|
||||
form.section = 'cfg01;reboot'; calls = {}; c.action_ddns_sync()
|
||||
print(response.success, status_code, #calls)
|
||||
`);
|
||||
const [started, rejected] = out.split('\n');
|
||||
assert.match(started, /^true\t\( \/bin\/sh \/usr\/lib\/minigate\/job\.sh ddns-cfg01 \/bin\/sh \/usr\/lib\/minigate\/ddns\.sh single 'cfg01' \) <\/dev\/null >\/dev\/null 2>&1 &$/);
|
||||
assert.equal(rejected, 'false\t400\t0');
|
||||
});
|
||||
|
||||
// 用最小 CBI 桩加载每个页面,并执行表单渲染时会调用的 cfgvalue
|
||||
const cbiPrelude = `
|
||||
local options = {}
|
||||
local function node()
|
||||
local n = {}
|
||||
function n:option(kind, name, title)
|
||||
local o = {kind=kind, name=name, choices={}}
|
||||
function o:value(k, v) self.choices[k] = v or k end
|
||||
function o:depends() end
|
||||
options[name] = o
|
||||
return o
|
||||
end
|
||||
function n:tab() end
|
||||
function n:taboption(_, ...) return n:option(...) end
|
||||
return n
|
||||
end
|
||||
function Map(config, title)
|
||||
local m = node()
|
||||
m.title = title
|
||||
m.uci = {get=function(_, _, sec, opt) local v; if opt == 'domain' then v = 'home.example.com' end; return v end}
|
||||
function m:section() return node() end
|
||||
return m
|
||||
end
|
||||
for _, name in ipairs({'Flag','Value','DynamicList','ListValue','DummyValue','MultiValue','NamedSection','TypedSection','SimpleSection'}) do _G[name] = name end
|
||||
luci = {dispatcher={build_url=function(...) return '/cgi-bin/luci/' .. table.concat({...}, '/') end}}
|
||||
package.preload['luci.sys'] = function() return {exec=function() return '' end, call=function() return 0 end} end
|
||||
package.preload['nixio.fs'] = function() return {access=function() return false end, dir=function() return nil end, stat=function() return nil end} end
|
||||
package.preload['luci.model.uci'] = function() return {cursor=function() return {
|
||||
foreach=function(_, config, kind, fn) if kind == 'ddns' then fn({domain='home.example.com'}); fn({domain='*.example.com'}) end end,
|
||||
get=function(_, _, _, opt) local v; if opt == 'staging' then v = '1' end; return v end
|
||||
} end} end
|
||||
package.preload['luci.controller.minigate'] = function() return {
|
||||
ddns_state=function() return {status='error', message='IPv4 192.168.2.2 不是公网地址', ip='198.51.100.1'} end,
|
||||
cert_list=function() return {{domain='*.example.com', expires_date='2026-11-02', days_left=5, staging=true}} end
|
||||
} end
|
||||
package.preload['luci.cbi.datatypes'] = function() return {
|
||||
ip4addr=function(v) return v:match('^%d+%.%d+%.%d+%.%d+$') ~= nil end,
|
||||
ip6addr=function(v) return v:match('^[%x:]+$') ~= nil and v:find(':') ~= nil end
|
||||
} end
|
||||
local function page(name) return assert(dofile(${JSON.stringify(path.join(root, 'luasrc/model/cbi/minigate'))} .. '/' .. name .. '.lua')) end
|
||||
`;
|
||||
|
||||
test('every LuCI page loads and renders its status blocks', { skip: !lua }, () => {
|
||||
const out = runLua(cbiPrelude + `
|
||||
for _, name in ipairs({'general', 'ddns', 'acme', 'proxy', 'login_guard'}) do
|
||||
options = {}
|
||||
local m = page(name)
|
||||
assert(m.title:match('^MiniGate'), name .. ' title')
|
||||
for key, o in pairs(options) do
|
||||
if o.kind == 'DummyValue' and type(o.cfgvalue) == 'function' then
|
||||
assert(type(o.cfgvalue(o, 'cfg01')) == 'string', name .. '.' .. key)
|
||||
end
|
||||
end
|
||||
end
|
||||
options = {}; page('ddns')
|
||||
print(options._info.cfgvalue(options._info, 'cfg01'):match('不是公网地址') ~= nil)
|
||||
options = {}; page('acme')
|
||||
local certs = options._certs.cfgvalue(options._certs)
|
||||
print(certs:match('剩 5 天') ~= nil, certs:match('测试证书') ~= nil, certs:match('测试模式已开启') ~= nil)
|
||||
options = {}; page('general')
|
||||
print(options.enabled ~= nil, options.ipv6_listen == nil)
|
||||
`);
|
||||
assert.equal(out, 'true\ntrue\ttrue\ttrue\ntrue\ttrue');
|
||||
});
|
||||
|
||||
test('LuCI forms validate domains, Cloudflare IDs, intervals, thresholds and whitelists', { skip: !lua }, () => {
|
||||
const out = runLua(cbiPrelude + `
|
||||
local function ok(o, v) return (o.validate(o, v, 'cfg01')) ~= nil end
|
||||
options = {}; page('ddns')
|
||||
print(ok(options.domain, 'home.example.com'), ok(options.domain, '*.example.com'), ok(options.domain, 'bad domain.com'),
|
||||
ok(options.domain, '-a.example.com'), ok(options.domain, 'localhost'))
|
||||
print(ok(options.cf_zone_id, string.rep('a1', 16)), ok(options.cf_zone_id, 'zone'), ok(options.cf_api_token, 'abc_DEF-123'), ok(options.cf_api_token, 'abc def'))
|
||||
print(ok(options.check_interval, '300'), ok(options.check_interval, '60'))
|
||||
options = {}; page('proxy')
|
||||
print(ok(options.prefix, 'app'), ok(options.prefix, 'a.b'), ok(options.prefix, '-x'), options.target_addr.datatype)
|
||||
options = {}; page('login_guard')
|
||||
print(options.threshold.datatype, ok(options.whitelist, {'203.0.113.0/20', '2001:db8::/48', '8.8.8.8'}), ok(options.whitelist, {'1.2.3.0/255.255.255.0'}), ok(options.whitelist, '10.0.0.0/33'))
|
||||
`);
|
||||
assert.equal(out, [
|
||||
'true\ttrue\tfalse\tfalse\tfalse',
|
||||
'true\tfalse\ttrue\tfalse',
|
||||
'true\tfalse',
|
||||
'true\tfalse\tfalse\thost',
|
||||
'range(1,100)\ttrue\tfalse\tfalse'
|
||||
].join('\n'));
|
||||
});
|
||||
@@ -65,3 +65,130 @@ test('log trimming is scheduled and the visitor list reads the rotated access lo
|
||||
assert.match(init, /\/bin\/sh \/usr\/lib\/minigate\/proxy\.sh trim-logs/);
|
||||
assert.match(controller, /cat \/var\/log\/minigate-access\.log\.1 \/var\/log\/minigate-access\.log 2>\/dev\/null \| tail -n 500/);
|
||||
});
|
||||
|
||||
// 用假的 uci / nginx 生成完整配置,检查跳过规则、回滚和状态文件
|
||||
function generate(db, { nginxFails = false } = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-proxy-gen-'));
|
||||
const bin = path.join(dir, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
fs.mkdirSync(path.join(dir, 'sites'));
|
||||
fs.writeFileSync(path.join(dir, 'uci.db'), db.trim() + '\n');
|
||||
fs.writeFileSync(path.join(bin, 'uci'), `#!/bin/sh
|
||||
DB=${JSON.stringify(path.join(dir, 'uci.db'))}
|
||||
[ "$1" = "-q" ] && shift
|
||||
case "$1" in
|
||||
show) grep "^$2" "$DB" ;;
|
||||
get) v=$(grep "^$2=" "$DB" | head -1 | cut -d= -f2-); [ -n "$v" ] && echo "$v" || exit 1 ;;
|
||||
esac
|
||||
`, { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(bin, 'nginx'), `#!/bin/sh
|
||||
[ "$1" = "-v" ] && { echo "nginx version: nginx/1.26.3" >&2; exit 0; }
|
||||
${nginxFails ? 'echo "nginx: [emerg] unknown directive in site_1.conf:3" >&2; exit 1' : 'exit 0'}
|
||||
`, { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(bin, 'openssl'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(dir, 'sites/site_1.conf'), '# previous good site\n');
|
||||
fs.writeFileSync(path.join(dir, 'main.conf'), '# previous good main\n');
|
||||
const result = spawnSync('/bin/sh', [script, 'prepare'], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
TMPDIR: dir,
|
||||
MINIGATE_PROXY_LOCK: path.join(dir, 'proxy.lock'),
|
||||
MINIGATE_PROXY_STATUS: path.join(dir, 'proxy.status'),
|
||||
MINIGATE_NGINX_CONF: path.join(dir, 'main.conf'),
|
||||
MINIGATE_MIME_TYPES: '/dev/null',
|
||||
MINIGATE_SITES_DIR: path.join(dir, 'sites'),
|
||||
MINIGATE_STREAMS_DIR: path.join(dir, 'streams'),
|
||||
MINIGATE_CERT_DIR: path.join(dir, 'certs'),
|
||||
MINIGATE_LOGFILE: path.join(dir, 'proxy.log'),
|
||||
MINIGATE_SOCKET_DIR: path.join(dir, 'sock')
|
||||
}
|
||||
});
|
||||
const sites = fs.readdirSync(path.join(dir, 'sites')).sort();
|
||||
const conf = sites.map(name => fs.readFileSync(path.join(dir, 'sites', name), 'utf8')).join('\n');
|
||||
const status = fs.existsSync(path.join(dir, 'proxy.status')) ? fs.readFileSync(path.join(dir, 'proxy.status'), 'utf8') : '';
|
||||
const leftovers = fs.readdirSync(dir).filter(name => name.startsWith('minigate-gen.'));
|
||||
return { result, dir, sites, conf, status, leftovers, main: fs.readFileSync(path.join(dir, 'main.conf'), 'utf8') };
|
||||
}
|
||||
|
||||
const BASE = `
|
||||
minigate.global=global
|
||||
minigate.global.enabled=1
|
||||
minigate.global.http_redirect=0
|
||||
minigate.wc=proxy_wildcard
|
||||
minigate.wc.enabled=WC_ENABLED
|
||||
minigate.wc.domain=*.example.com
|
||||
minigate.wc.listen_port=443
|
||||
minigate.wc.ssl=1
|
||||
minigate.sub1=subproxy
|
||||
minigate.sub1.parent_domain=example.com
|
||||
minigate.sub1.prefix=nas
|
||||
minigate.sub1.target_addr=192.168.2.10
|
||||
minigate.sub1.target_port=5000
|
||||
`;
|
||||
|
||||
test('subdomains are skipped when their wildcard parent is disabled instead of falling back to plain HTTP', () => {
|
||||
const disabled = generate(BASE.replace('WC_ENABLED', '0'));
|
||||
assert.equal(disabled.result.status, 0, disabled.result.stderr);
|
||||
assert.doesNotMatch(disabled.conf, /nas\.example\.com/);
|
||||
assert.match(disabled.status, /warning=跳过 nas\.example\.com:通配符主域名 \*\.example\.com 未启用或不存在/);
|
||||
|
||||
const enabled = generate(BASE.replace('WC_ENABLED', '1'));
|
||||
assert.equal(enabled.result.status, 0, enabled.result.stderr);
|
||||
assert.match(enabled.conf, /listen 443 ssl;\n server_name nas\.example\.com;/);
|
||||
assert.doesNotMatch(enabled.conf, /listen 443;\n server_name nas/);
|
||||
});
|
||||
|
||||
test('a disabled subdomain rule is not generated', () => {
|
||||
const result = generate(BASE.replace('WC_ENABLED', '1') + 'minigate.sub1.enabled=0\n');
|
||||
assert.equal(result.result.status, 0, result.result.stderr);
|
||||
assert.doesNotMatch(result.conf, /nas\.example\.com/);
|
||||
});
|
||||
|
||||
test('HTTP and HTTPS rules on one port never produce two default servers', () => {
|
||||
const result = generate(BASE.replace('WC_ENABLED', '1') + `
|
||||
minigate.plain=proxy
|
||||
minigate.plain.enabled=1
|
||||
minigate.plain.domain=plain.example.org
|
||||
minigate.plain.listen_port=443
|
||||
minigate.plain.target_addr=192.168.2.30
|
||||
minigate.plain.ssl=0
|
||||
`);
|
||||
assert.equal(result.result.status, 0, result.result.stderr);
|
||||
// 普通规则先生成并占用端口,之后的子域名 HTTPS 规则被跳过
|
||||
assert.equal((result.conf.match(/default_server/g) || []).length, 1);
|
||||
assert.match(result.conf, /server_name plain\.example\.org;/);
|
||||
assert.doesNotMatch(result.conf, /nas\.example\.com/);
|
||||
assert.match(result.status, /warning=跳过 nas\.example\.com:端口 443 已用于HTTP,不能同时用于HTTPS/);
|
||||
});
|
||||
|
||||
test('missing certificates and every site get WebSocket support and a visible warning', () => {
|
||||
const result = generate(BASE.replace('WC_ENABLED', '1'));
|
||||
assert.match(result.status, /warning=nas\.example\.com 没有可用证书/);
|
||||
assert.match(result.conf, /proxy_set_header Connection \$minigate_connection_upgrade;/);
|
||||
assert.match(result.main, /map \$http_upgrade \$minigate_connection_upgrade/);
|
||||
assert.match(result.status, /^result=ok$/m);
|
||||
assert.deepEqual(result.leftovers, [], 'temporary generation directory is removed');
|
||||
});
|
||||
|
||||
test('a configuration nginx rejects is rolled back so the last working files stay on disk', () => {
|
||||
const result = generate(BASE.replace('WC_ENABLED', '1'), { nginxFails: true });
|
||||
assert.equal(result.result.status, 1);
|
||||
assert.deepEqual(result.sites, ['site_1.conf']);
|
||||
assert.equal(fs.readFileSync(path.join(result.dir, 'sites/site_1.conf'), 'utf8'), '# previous good site\n');
|
||||
assert.equal(result.main, '# previous good main\n');
|
||||
assert.match(result.status, /^result=error$/m);
|
||||
assert.match(result.status, /配置检查失败,已保留上一份配置:\[emerg\] unknown directive/);
|
||||
assert.deepEqual(result.leftovers, []);
|
||||
});
|
||||
|
||||
test('nginx runs in the foreground under procd and clears stale sockets first', () => {
|
||||
const source = fs.readFileSync(script, 'utf8');
|
||||
assert.match(source, /do_run\(\) \{\n mkdir -p "\$SOCKET_DIR"\n rm -f "\$SOCKET_DIR"\/\*\.sock\n exec nginx -c "\$NGINX_CONF" -g 'daemon off;'/);
|
||||
const init = fs.readFileSync(path.join(root, 'root/etc/init.d/minigate'), 'utf8');
|
||||
assert.match(init, /USE_PROCD=1/);
|
||||
assert.match(init, /procd_set_param command \/bin\/sh "\$PROXY" run/);
|
||||
assert.match(init, /procd_add_reload_trigger "minigate"/);
|
||||
assert.match(source, /MINIGATE_SKIP_PREPARE=1 "\$INIT_SCRIPT" start 6>&-/);
|
||||
});
|
||||
@@ -131,6 +131,95 @@ test('overview renders legacy visitor responses as unknown and escapes log conte
|
||||
assert.doesNotMatch(html, /<img|已转发|在线|离线|拦截 undefined/);
|
||||
});
|
||||
|
||||
const statusData = extra => ({
|
||||
enabled: '1', ddns_list: [], proxy_rules: [], certs: [], acme: {}, proxy_warnings: [], ...extra
|
||||
});
|
||||
|
||||
test('overview shows a notice only while MiniGate is disabled', () => {
|
||||
const page = fakePage(overviewScript);
|
||||
take(page, 'su').callback(null, statusData({ enabled: '0' }));
|
||||
assert.equal(page.element('mg-disabled').hidden, false);
|
||||
page.context.mgLoadStatus();
|
||||
take(page, 'su').callback(null, statusData({ enabled: '1' }));
|
||||
assert.equal(page.element('mg-disabled').hidden, true);
|
||||
});
|
||||
|
||||
test('overview DDNS card lists every record and takes the worst status', () => {
|
||||
const page = fakePage(overviewScript);
|
||||
take(page, 'su').callback(null, statusData({
|
||||
ddns_status: 'error',
|
||||
ddns_list: [
|
||||
{ domain: 'a.example.com', enabled: '1', status: 'ok', last_ip: '198.51.100.1', last_update: '2026-10-28 10:00:00' },
|
||||
{ domain: 'b.example.com', enabled: '1', status: 'error', status_msg: 'IPv4 192.168.2.2 不是公网地址<b>' }
|
||||
]
|
||||
}));
|
||||
assert.match(page.element('mg-d1').innerHTML, /异常/);
|
||||
const body = page.element('mg-d2').innerHTML;
|
||||
assert.match(body, /a\.example\.com[\s\S]*A 198\.51\.100\.1/);
|
||||
assert.match(body, /b\.example\.com/);
|
||||
assert.match(body, /不是公网地址<b>/);
|
||||
assert.doesNotMatch(body, /<b>/);
|
||||
});
|
||||
|
||||
test('overview certificate card shows expiry dates, days left and test certificates', () => {
|
||||
const page = fakePage(overviewScript);
|
||||
take(page, 'su').callback(null, statusData({
|
||||
acme: { enabled: '1', result: 'error', message: '签发失败 (exit=1)' },
|
||||
certs: [
|
||||
{ domain: '*.example.com', expires_date: '2026-11-02', days_left: 5, staging: false },
|
||||
{ domain: 'test.example.com', expires_date: '2027-01-20', days_left: 84, staging: true }
|
||||
]
|
||||
}));
|
||||
assert.match(page.element('mg-a1').innerHTML, /即将到期/);
|
||||
const body = page.element('mg-a2').innerHTML;
|
||||
assert.match(body, /到期 2026-11-02(剩 5 天)/);
|
||||
assert.match(body, /测试证书,浏览器不信任/);
|
||||
assert.match(body, /最近一次签发:签发失败/);
|
||||
|
||||
const staging = fakePage(overviewScript);
|
||||
take(staging, 'su').callback(null, statusData({ certs: [{ domain: 'x.example.com', expires_date: '2027-01-20', days_left: 84, staging: true }] }));
|
||||
assert.match(staging.element('mg-a1').innerHTML, /测试证书/);
|
||||
});
|
||||
|
||||
test('overview proxy card links sites safely and surfaces configuration problems', () => {
|
||||
const page = fakePage(overviewScript);
|
||||
take(page, 'su').callback(null, statusData({
|
||||
proxy_running: true,
|
||||
proxy_check: 'error',
|
||||
proxy_message: '配置检查失败,已保留上一份配置:[emerg] x',
|
||||
proxy_warnings: ['跳过 nas.example.com:通配符主域名 *.example.com 未启用或不存在'],
|
||||
proxy_rules: [{ scheme: 'https', domain: 'box.example.com', listen_port: '2000', target: '192.168.2.2:8080' },
|
||||
{ scheme: 'https', domain: '"><img>', listen_port: '443', target: 'x' }]
|
||||
}));
|
||||
assert.match(page.element('mg-p1').innerHTML, /最近一次配置未生效/);
|
||||
const body = page.element('mg-p2').innerHTML;
|
||||
assert.match(body, /<a class="mg-link" href="https:\/\/box\.example\.com:2000" target="_blank" rel="noopener noreferrer">/);
|
||||
assert.match(body, /已保留上一份配置/);
|
||||
assert.match(body, /跳过 nas\.example\.com/);
|
||||
assert.doesNotMatch(body, /"><img>/);
|
||||
});
|
||||
|
||||
function ddnsPageScript() {
|
||||
const source = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/ddns.lua'), 'utf8');
|
||||
const start = source.indexOf('<script type="text/javascript">');
|
||||
const end = source.indexOf('</script>', start);
|
||||
return source.slice(start + '<script type="text/javascript">'.length, end).replace(/\]\]\.\.(\w+)\.\.\[\[/g, '$1');
|
||||
}
|
||||
|
||||
test('manual DDNS sync runs in the background and updates the row without reloading the page', () => {
|
||||
const page = fakePage(ddnsPageScript());
|
||||
let reloaded = false;
|
||||
page.context.location.reload = () => { reloaded = true; };
|
||||
const button = page.element('sync-button');
|
||||
page.context.doSync('cfg01', button);
|
||||
take(page, 'url').callback(null, { success: true });
|
||||
take(page, 'job').callback(null, { running: false, done: true, success: true, status: 'ok', ip: '198.51.100.20', updated: '2026-10-28 10:00:00' });
|
||||
assert.equal(reloaded, false);
|
||||
assert.equal(button.disabled, false);
|
||||
assert.match(page.element('sr-cfg01').innerHTML, /同步完成 198\.51\.100\.20/);
|
||||
assert.match(page.element('ss-cfg01').innerHTML, /A: 198\.51\.100\.20/);
|
||||
});
|
||||
|
||||
test('login guard renders empty JSON objects as empty lists and uses POST for changes', () => {
|
||||
const page = fakePage(loginGuardScript);
|
||||
take(page, 'lu').callback(null, { enabled: '1', running: true, banned: {}, banned_total: 0, watching: {}, watching_total: 0, threshold: 3 });
|
||||
@@ -151,7 +240,11 @@ test('state-changing LuCI endpoints require POST with the CSRF token', () => {
|
||||
}
|
||||
const ddns = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/ddns.lua'), 'utf8');
|
||||
const acme = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/acme.lua'), 'utf8');
|
||||
assert.match(ddns, /XHR\.post\("\]\]\.\.url\.\.\[\[",\{section:s\}/);
|
||||
assert.doesNotMatch(acme, /XHR\.get\(/);
|
||||
assert.match(ddns, /mgRequest\('post',"\]\]\.\.url\.\.\[\[",\{section:s\}/);
|
||||
// 安装与签发用 POST 启动后台任务,只有读取任务状态用 GET
|
||||
assert.match(acme, /acmePost\('\]\]\.\.iu\.\.\[\['/);
|
||||
assert.match(acme, /acmePost\('\]\]\.\.su\.\.\[\['/);
|
||||
assert.match(acme, /acmeGet\('\]\]\.\.ju\.\.\[\['/);
|
||||
assert.match(controller, /"acme_job"\}, call\("action_acme_job"\)\)/);
|
||||
assert.match(controller, /sec:match\("\^\[%w_\]\+\$"\)/);
|
||||
});
|
||||
@@ -196,7 +196,8 @@ test('LuCI exposes only a confirmed POST uninstall action and runs a temporary s
|
||||
test('the package contains the uninstaller and supports both OpenWrt package managers', () => {
|
||||
const makefile = fs.readFileSync(path.join(root, 'Makefile'), 'utf8');
|
||||
const source = fs.readFileSync(script, 'utf8');
|
||||
assert.match(makefile, /root\/usr\/lib\/minigate\/uninstall\.sh/);
|
||||
assert.match(makefile, /\$\(INSTALL_BIN\) \.\/root\/usr\/lib\/minigate\/\*\.sh /);
|
||||
assert.ok(fs.existsSync(path.join(root, 'root/usr/lib/minigate/uninstall.sh')));
|
||||
assert.match(source, /opkg remove "\$package"/);
|
||||
assert.match(source, /apk del "\$package"/);
|
||||
assert.match(source, /for package in minigate luci-app-minigate/);
|
||||
|
||||
Reference in new issue
Block a user